1use std::collections::HashSet;
2
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6use crate::error::Result;
7use crate::hash::compute_content_hash;
8use crate::model::event::{AgentEvent, EventType};
9use crate::model::memory::{MemoryRecord, MemoryType, Scope};
10use crate::query::MnemoEngine;
11use crate::storage::MemoryFilter;
12#[allow(unused_imports)]
13use base64::Engine as _;
14
15#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct TemporalRange {
17 pub after: Option<String>,
18 pub before: Option<String>,
19}
20
21impl TemporalRange {
22 pub fn new() -> Self {
23 Self::default()
24 }
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct RecallRequest {
29 pub query: String,
30 pub agent_id: Option<String>,
31 pub limit: Option<usize>,
32 pub memory_type: Option<MemoryType>,
33 pub memory_types: Option<Vec<MemoryType>>,
34 pub scope: Option<Scope>,
35 pub min_importance: Option<f32>,
36 pub tags: Option<Vec<String>>,
37 pub org_id: Option<String>,
38 pub strategy: Option<String>,
39 pub temporal_range: Option<TemporalRange>,
40 pub recency_half_life_hours: Option<f64>,
41 pub hybrid_weights: Option<Vec<f32>>,
42 pub rrf_k: Option<f32>,
43 pub as_of: Option<String>,
44 pub explain: Option<bool>,
48 pub with_provenance: Option<bool>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub mode: Option<crate::retrieval::RetrievalMode>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub current_fact_resolver:
71 Option<crate::query::current_fact_resolver::CurrentFactResolverConfig>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub orientation_cache: Option<crate::query::orientation_cache::OrientationCacheConfig>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub evidence_budget: Option<crate::query::evidence::EvidenceBudget>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub retained_token_budget: Option<usize>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub domain_scope: Option<crate::retrieval::DomainScope>,
112}
113
114impl RecallRequest {
115 pub fn new(query: String) -> Self {
116 Self {
117 query,
118 agent_id: None,
119 limit: None,
120 memory_type: None,
121 memory_types: None,
122 scope: None,
123 min_importance: None,
124 tags: None,
125 org_id: None,
126 strategy: None,
127 temporal_range: None,
128 recency_half_life_hours: None,
129 hybrid_weights: None,
130 rrf_k: None,
131 as_of: None,
132 explain: None,
133 with_provenance: None,
134 mode: None,
135 current_fact_resolver: None,
136 orientation_cache: None,
137 evidence_budget: None,
138 retained_token_budget: None,
139 domain_scope: None,
140 }
141 }
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
150pub struct SupersededRecord {
151 pub id: Uuid,
152 pub fact_id: String,
153 pub superseded_by: Uuid,
154 pub superseded_at: String,
156 pub prior_updated_at: String,
158}
159
160#[derive(Debug, Clone, Default, Serialize, Deserialize)]
166pub struct ScoreBreakdown {
167 pub vector: f32,
168 pub bm25: f32,
169 pub graph: f32,
170 pub recency: f32,
171 pub rrf_rank: u32,
173}
174
175#[non_exhaustive]
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct RecallResponse {
178 pub memories: Vec<ScoredMemory>,
179 pub total: usize,
180 #[serde(skip_serializing_if = "Option::is_none", default)]
185 pub provenance: Option<crate::provenance::ReadProvenance>,
186 #[serde(skip_serializing_if = "Option::is_none", default)]
192 pub superseded: Option<Vec<SupersededRecord>>,
193 #[serde(skip_serializing_if = "Option::is_none", default)]
200 pub orientation_cache: Option<crate::query::orientation_cache::RenderedContextMap>,
201 #[serde(skip_serializing_if = "Option::is_none", default)]
207 pub evidence_selection: Option<crate::query::evidence::EvidenceSelectionReport>,
208 #[serde(skip_serializing_if = "Option::is_none", default)]
215 pub retained_evidence: Option<crate::query::retained::RetentionReport>,
216 #[serde(skip_serializing_if = "Option::is_none", default)]
224 pub reconstruction: Option<ReconstructedBelief>,
225}
226
227impl RecallResponse {
228 pub fn new(memories: Vec<ScoredMemory>, total: usize) -> Self {
229 Self {
230 memories,
231 total,
232 provenance: None,
233 superseded: None,
234 orientation_cache: None,
235 evidence_selection: None,
236 retained_evidence: None,
237 reconstruction: None,
238 }
239 }
240}
241
242#[non_exhaustive]
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct ReconstructedBelief {
254 pub cue: String,
256 pub summary: String,
259 pub source_ids: Vec<Uuid>,
261 pub linked_context_ids: Vec<Uuid>,
264 pub confidence: f32,
266}
267
268#[non_exhaustive]
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct ScoredMemory {
271 pub id: Uuid,
272 pub content: String,
273 pub agent_id: String,
274 pub memory_type: MemoryType,
275 pub scope: Scope,
276 pub importance: f32,
277 pub tags: Vec<String>,
278 pub metadata: serde_json::Value,
279 pub score: f32,
280 pub access_count: u64,
281 pub created_at: String,
282 pub updated_at: String,
283 #[serde(skip_serializing_if = "Option::is_none")]
284 pub score_breakdown: Option<ScoreBreakdown>,
285}
286
287impl From<(MemoryRecord, f32)> for ScoredMemory {
288 fn from((record, score): (MemoryRecord, f32)) -> Self {
289 Self {
290 id: record.id,
291 content: record.content,
292 agent_id: record.agent_id,
293 memory_type: record.memory_type,
294 scope: record.scope,
295 importance: record.importance,
296 tags: record.tags,
297 metadata: record.metadata,
298 score,
299 access_count: record.access_count,
300 created_at: record.created_at,
301 updated_at: record.updated_at,
302 score_breakdown: None,
303 }
304 }
305}
306
307async fn get_memory_cached(engine: &MnemoEngine, id: Uuid) -> Result<Option<MemoryRecord>> {
309 if let Some(ref cache) = engine.cache
310 && let Some(record) = cache.get(id)
311 {
312 return Ok(Some(record));
313 }
314 let result = engine.storage.get_memory(id).await?;
315 if let Some(ref record) = result
316 && let Some(ref cache) = engine.cache
317 {
318 cache.put(record.clone());
319 }
320 Ok(result)
321}
322
323pub async fn execute(engine: &MnemoEngine, request: RecallRequest) -> Result<RecallResponse> {
324 let limit = request.limit.unwrap_or(10).min(100);
325 let agent_id = request
326 .agent_id
327 .clone()
328 .unwrap_or_else(|| engine.default_agent_id.clone());
329 super::validate_agent_id(&agent_id)?;
330
331 let strategy = if let Some(ref mode) = request.mode {
336 mode.to_strategy_str()
337 } else if request
338 .domain_scope
339 .as_ref()
340 .map(|s| !s.is_empty())
341 .unwrap_or(false)
342 {
343 "domain_scoped"
347 } else {
348 request.strategy.as_deref().unwrap_or("auto")
349 };
350
351 let query_embedding = engine.embedding.embed(&request.query).await?;
353
354 let accessible_ids: HashSet<Uuid> = engine
356 .storage
357 .list_accessible_memory_ids(&agent_id, super::MAX_BATCH_QUERY_LIMIT)
358 .await?
359 .into_iter()
360 .collect();
361 let perm_filter = |id: Uuid| accessible_ids.contains(&id);
362
363 let mut scored_memories: Vec<(MemoryRecord, f32)> = Vec::new();
364 let mut breakdowns: std::collections::HashMap<Uuid, ScoreBreakdown> =
365 std::collections::HashMap::new();
366
367 match strategy {
368 "lexical" => {
369 if let Some(ref ft) = engine.full_text {
371 let bm25_results = ft.search(&request.query, limit * 3)?;
372 for (id, score) in bm25_results {
373 if let Some(record) = get_memory_cached(engine, id).await?
374 && passes_filters(&record, &request, &agent_id, engine).await
375 {
376 scored_memories.push((record, score));
377 }
378 }
379 }
380 }
381 "semantic" => {
382 let search_results =
384 engine
385 .index
386 .filtered_search(&query_embedding, limit * 3, &perm_filter)?;
387 for (id, distance) in search_results {
388 if let Some(record) = get_memory_cached(engine, id).await?
389 && passes_filters(&record, &request, &agent_id, engine).await
390 {
391 let score = 1.0 - distance;
392 scored_memories.push((record, score));
393 }
394 }
395 }
396 "domain_scoped" => {
397 let domain_ids: Option<HashSet<Uuid>> = match request.domain_scope.as_ref() {
407 Some(scope) if !scope.is_empty() => {
408 let coarse = MemoryFilter {
411 agent_id: None,
412 memory_type: None,
413 scope: None,
414 tags: None,
415 min_importance: None,
416 org_id: scope.org_id.clone(),
417 thread_id: None,
418 include_deleted: false,
419 };
420 let records = engine
421 .storage
422 .list_memories(&coarse, super::MAX_BATCH_QUERY_LIMIT, 0)
423 .await?;
424 Some(
425 records
426 .iter()
427 .filter(|r| scope.matches(r))
428 .map(|r| r.id)
429 .collect(),
430 )
431 }
432 _ => None,
435 };
436
437 let domain_filter = |id: Uuid| {
438 perm_filter(id) && domain_ids.as_ref().map(|d| d.contains(&id)).unwrap_or(true)
439 };
440 let search_results =
441 engine
442 .index
443 .filtered_search(&query_embedding, limit * 3, &domain_filter)?;
444 for (id, distance) in search_results {
445 if let Some(record) = get_memory_cached(engine, id).await?
446 && passes_filters(&record, &request, &agent_id, engine).await
447 {
448 let score = 1.0 - distance;
449 scored_memories.push((record, score));
450 }
451 }
452 }
453 "graph" => {
454 let search_results =
456 engine
457 .index
458 .filtered_search(&query_embedding, limit * 3, &perm_filter)?;
459 let mut seeds: Vec<(Uuid, f32)> = Vec::new();
460 for (id, distance) in &search_results {
461 if let Some(record) = get_memory_cached(engine, *id).await?
462 && passes_filters(&record, &request, &agent_id, engine).await
463 {
464 seeds.push((*id, 1.0 - distance));
465 }
466 }
467
468 let max_hops = 2;
470 let mut seen: HashSet<Uuid> = seeds.iter().map(|(id, _)| *id).collect();
471 let mut graph_ranked: Vec<(Uuid, f32)> = Vec::new();
472
473 for &(id, _) in &seeds {
475 graph_ranked.push((id, 1.0));
476 }
477
478 let mut frontier: Vec<Uuid> = seeds.iter().map(|(id, _)| *id).collect();
480 let mut decay = 0.5_f32;
481 for _hop in 0..max_hops {
482 let mut next_frontier: Vec<Uuid> = Vec::new();
483 for &id in &frontier {
484 let from_rels = engine.storage.get_relations_from(id).await?;
485 let to_rels = engine.storage.get_relations_to(id).await?;
486 for rel in from_rels.iter().chain(to_rels.iter()) {
487 let related_id = if rel.source_id == id {
488 rel.target_id
489 } else {
490 rel.source_id
491 };
492 if seen.insert(related_id)
493 && let Some(record) = get_memory_cached(engine, related_id).await?
494 && passes_filters(&record, &request, &agent_id, engine).await
495 {
496 graph_ranked.push((related_id, decay));
497 next_frontier.push(related_id);
498 }
499 }
500 }
501 frontier = next_frontier;
502 decay *= 0.5;
503 }
504
505 let mut v_sorted: Vec<(Uuid, f32)> = seeds.clone();
507 v_sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
508 graph_ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
509
510 let ranked_lists = vec![v_sorted, graph_ranked];
511 let rrf_k = request.rrf_k.unwrap_or(60.0);
512 let fused = if let Some(ref weights) = request.hybrid_weights {
513 crate::query::retrieval::weighted_reciprocal_rank_fusion(
514 &ranked_lists,
515 rrf_k,
516 weights,
517 )
518 } else {
519 crate::query::retrieval::reciprocal_rank_fusion(&ranked_lists, rrf_k)
520 };
521
522 for (id, score) in fused {
523 if let Some(record) = get_memory_cached(engine, id).await?
524 && passes_filters(&record, &request, &agent_id, engine).await
525 {
526 scored_memories.push((record, score));
527 }
528 }
529 }
530 "exact" => {
531 let filter = MemoryFilter {
534 agent_id: Some(agent_id.clone()),
535 memory_type: request.memory_type,
536 scope: request.scope,
537 tags: request.tags.clone(),
538 min_importance: request.min_importance,
539 org_id: request.org_id.clone(),
540 thread_id: None,
541 include_deleted: request.as_of.is_some(),
542 };
543 let memories = engine.storage.list_memories(&filter, limit, 0).await?;
544 for record in memories {
545 if passes_filters(&record, &request, &agent_id, engine).await {
546 scored_memories.push((record, 1.0));
547 }
548 }
549 }
550 _ => {
551 let vector_results =
553 engine
554 .index
555 .filtered_search(&query_embedding, limit * 3, &perm_filter)?;
556 let mut vector_ranked: Vec<(Uuid, f32)> = Vec::new();
557 for (id, distance) in vector_results {
558 vector_ranked.push((id, 1.0 - distance));
559 }
560
561 if let Some(ref ft) = engine.full_text {
562 let bm25_results = ft.search(&request.query, limit * 3)?;
564
565 let mut recency_ranked: Vec<(Uuid, f32)> = Vec::new();
567 for &(id, _) in &vector_ranked {
568 if let Some(record) = get_memory_cached(engine, id).await? {
569 let r_score = crate::query::retrieval::recency_score(
570 &record.created_at,
571 request.recency_half_life_hours.unwrap_or(168.0),
572 );
573 recency_ranked.push((id, r_score));
574 }
575 }
576 for &(id, _) in &bm25_results {
578 if !recency_ranked.iter().any(|(rid, _)| *rid == id)
579 && let Some(record) = get_memory_cached(engine, id).await?
580 {
581 let r_score = crate::query::retrieval::recency_score(
582 &record.created_at,
583 request.recency_half_life_hours.unwrap_or(168.0),
584 );
585 recency_ranked.push((id, r_score));
586 }
587 }
588
589 let mut v_sorted = vector_ranked.clone();
591 v_sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
592 let mut b_sorted = bm25_results;
593 b_sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
594 recency_ranked
595 .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
596
597 let max_hops = 2;
599 let mut graph_ranked: Vec<(Uuid, f32)> = Vec::new();
600 let top_seeds: Vec<Uuid> =
601 vector_ranked.iter().take(10).map(|(id, _)| *id).collect();
602 let mut graph_seen: HashSet<Uuid> = top_seeds.iter().copied().collect();
603 for &seed_id in &top_seeds {
604 graph_ranked.push((seed_id, 1.0));
605 }
606 let mut frontier: Vec<Uuid> = top_seeds;
607 let mut decay = 0.5_f32;
608 for _hop in 0..max_hops {
609 let mut next_frontier: Vec<Uuid> = Vec::new();
610 for &fid in &frontier {
611 match engine.storage.get_relations_from(fid).await {
612 Ok(from_rels) => {
613 for rel in &from_rels {
614 if graph_seen.insert(rel.target_id) {
615 graph_ranked.push((rel.target_id, decay));
616 next_frontier.push(rel.target_id);
617 }
618 }
619 }
620 Err(e) => {
621 tracing::warn!(memory_id = %fid, error = %e, "graph expansion: failed to get outgoing relations");
622 }
623 }
624 match engine.storage.get_relations_to(fid).await {
625 Ok(to_rels) => {
626 for rel in &to_rels {
627 if graph_seen.insert(rel.source_id) {
628 graph_ranked.push((rel.source_id, decay));
629 next_frontier.push(rel.source_id);
630 }
631 }
632 }
633 Err(e) => {
634 tracing::warn!(memory_id = %fid, error = %e, "graph expansion: failed to get incoming relations");
635 }
636 }
637 }
638 frontier = next_frontier;
639 decay *= 0.5;
640 }
641 graph_ranked
642 .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
643
644 let explain = request.explain.unwrap_or(false);
648 type SignalMap = std::collections::HashMap<Uuid, f32>;
649 let (vector_map, bm25_map, recency_map, graph_map): (
650 SignalMap,
651 SignalMap,
652 SignalMap,
653 SignalMap,
654 ) = if explain {
655 (
656 v_sorted.iter().copied().collect(),
657 b_sorted.iter().copied().collect(),
658 recency_ranked.iter().copied().collect(),
659 graph_ranked.iter().copied().collect(),
660 )
661 } else {
662 Default::default()
663 };
664
665 let ranked_lists = vec![v_sorted, b_sorted, recency_ranked, graph_ranked];
666 let rrf_k = request.rrf_k.unwrap_or(60.0);
667 let fused = if let Some(ref weights) = request.hybrid_weights {
668 crate::query::retrieval::weighted_reciprocal_rank_fusion(
669 &ranked_lists,
670 rrf_k,
671 weights,
672 )
673 } else {
674 crate::query::retrieval::reciprocal_rank_fusion(&ranked_lists, rrf_k)
675 };
676
677 for (rank, (id, score)) in fused.into_iter().enumerate() {
678 if let Some(record) = get_memory_cached(engine, id).await?
679 && passes_filters(&record, &request, &agent_id, engine).await
680 {
681 scored_memories.push((record, score));
682 if explain {
683 breakdowns.insert(
684 id,
685 ScoreBreakdown {
686 vector: vector_map.get(&id).copied().unwrap_or(0.0),
687 bm25: bm25_map.get(&id).copied().unwrap_or(0.0),
688 graph: graph_map.get(&id).copied().unwrap_or(0.0),
689 recency: recency_map.get(&id).copied().unwrap_or(0.0),
690 rrf_rank: rank as u32,
691 },
692 );
693 }
694 }
695 }
696 } else {
697 for (id, score) in vector_ranked {
699 if let Some(record) = get_memory_cached(engine, id).await?
700 && passes_filters(&record, &request, &agent_id, engine).await
701 {
702 scored_memories.push((record, score));
703 }
704 }
705 }
706 }
707 }
708
709 scored_memories.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
711 scored_memories.truncate(limit);
712
713 let evidence_selection = if let Some(ref budget) = request.evidence_budget {
722 let cosine_default = crate::query::evidence::CosineScorer;
723 let scorer: &dyn crate::query::evidence::EvidenceScorer =
724 match (budget.scorer, engine.evidence_scorer.as_ref()) {
725 (crate::query::evidence::ScorerKind::Delta, Some(s)) => s.as_ref(),
726 _ => &cosine_default,
727 };
728 let q_emb: Option<&[f32]> = if query_embedding.iter().any(|v| *v != 0.0) {
732 Some(query_embedding.as_slice())
733 } else {
734 None
735 };
736 let candidates: Vec<crate::query::evidence::EvidenceCandidate<'_>> = scored_memories
737 .iter()
738 .map(|(r, score)| crate::query::evidence::EvidenceCandidate {
739 content: &r.content,
740 embedding: r.embedding.as_deref(),
741 retrieval_score: *score,
742 })
743 .collect();
744 let selection = crate::query::evidence::select_within_budget(
745 &candidates,
746 budget,
747 scorer,
748 &request.query,
749 q_emb,
750 );
751 let keep = selection.keep;
752 drop(candidates);
753 scored_memories.truncate(keep);
754 Some(selection.report)
755 } else {
756 None
757 };
758
759 let _total_pre_resolver = scored_memories.len();
760
761 for (record, _) in &scored_memories {
763 if let Err(e) = engine.storage.touch_memory(record.id).await {
764 tracing::warn!(memory_id = %record.id, error = %e, "failed to update access timestamp");
765 }
766 }
767
768 if let Some(ref enc) = engine.encryption {
770 for (record, _) in &mut scored_memories {
771 match base64::engine::general_purpose::STANDARD.decode(&record.content) {
772 Ok(encrypted_bytes) => match enc.decrypt(&encrypted_bytes) {
773 Ok(decrypted) => match String::from_utf8(decrypted) {
774 Ok(plaintext) => record.content = plaintext,
775 Err(e) => {
776 tracing::error!(memory_id = %record.id, error = %e, "decrypted content is not valid UTF-8");
777 record.content = "[content unavailable: decryption error]".to_string();
778 }
779 },
780 Err(e) => {
781 tracing::error!(memory_id = %record.id, error = %e, "failed to decrypt memory content");
782 record.content = "[content unavailable: decryption error]".to_string();
783 }
784 },
785 Err(e) => {
786 tracing::error!(memory_id = %record.id, error = %e, "failed to decode encrypted content");
787 record.content = "[content unavailable: decryption error]".to_string();
788 }
789 }
790 }
791 }
792
793 let provenance_records: Option<Vec<MemoryRecord>> =
798 if request.with_provenance == Some(true) && engine.provenance_signer.is_some() {
799 Some(scored_memories.iter().map(|(r, _)| r.clone()).collect())
800 } else {
801 None
802 };
803
804 let memories: Vec<ScoredMemory> = scored_memories
805 .into_iter()
806 .map(|(record, score)| {
807 let id = record.id;
808 let mut scored = ScoredMemory::from((record, score));
809 if let Some(breakdown) = breakdowns.remove(&id) {
810 scored.score_breakdown = Some(breakdown);
811 }
812 scored
813 })
814 .collect();
815
816 let (memories, superseded_chain) = if let Some(ref cfg) = request.current_fact_resolver {
824 let out = crate::query::current_fact_resolver::resolve(cfg, memories);
825 let chain = if cfg.include_supersession_chain && !out.superseded.is_empty() {
826 Some(out.superseded)
827 } else {
828 None
829 };
830 (out.kept, chain)
831 } else {
832 (memories, None)
833 };
834 let total = memories.len();
835
836 let reconstruction = if strategy == "reconstruct" {
842 Some(reconstruct_belief(engine, &request, &agent_id, &memories).await)
843 } else {
844 None
845 };
846
847 let orientation_rendered = match (
854 request.orientation_cache.as_ref(),
855 engine.orientation_cache_store.as_ref(),
856 ) {
857 (Some(cfg), Some(store)) => {
858 let ns = crate::query::orientation_cache::resolve_namespace(
859 cfg,
860 &agent_id,
861 request.org_id.as_deref(),
862 );
863 let rendered =
864 crate::query::orientation_cache::update_and_render(store, cfg, &ns, &memories);
865 if cfg.include_in_response {
866 Some(rendered)
867 } else {
868 None
869 }
870 }
871 _ => None,
872 };
873
874 let now = chrono::Utc::now().to_rfc3339();
876 let event_content_hash = compute_content_hash(&request.query, &agent_id, &now);
877 let prev_event_hash = match engine.storage.get_latest_event_hash(&agent_id, None).await {
878 Ok(hash) => hash,
879 Err(e) => {
880 tracing::warn!(error = %e, "failed to get latest event hash, starting new chain segment");
881 None
882 }
883 };
884 let event_prev_hash = Some(crate::hash::compute_chain_hash(
885 &event_content_hash,
886 prev_event_hash.as_deref(),
887 ));
888 let mut event = AgentEvent {
889 id: Uuid::now_v7(),
890 agent_id: agent_id.clone(),
891 thread_id: None,
892 run_id: None,
893 parent_event_id: None,
894 event_type: EventType::MemoryRead,
895 payload: serde_json::json!({
896 "query": request.query,
897 "results": total,
898 "strategy": strategy,
899 }),
900 trace_id: None,
901 span_id: None,
902 model: None,
903 tokens_input: None,
904 tokens_output: None,
905 latency_ms: None,
906 cost_usd: None,
907 timestamp: now.clone(),
908 logical_clock: 0,
909 content_hash: event_content_hash,
910 prev_hash: event_prev_hash,
911 embedding: None,
912 };
913 if engine.embed_events
915 && let Ok(emb) = engine.embedding.embed(&event.payload.to_string()).await
916 {
917 event.embedding = Some(emb);
918 }
919 if let Err(e) = engine.storage.insert_event(&event).await {
920 tracing::error!(event_id = %event.id, error = %e, "failed to insert audit event");
921 }
922
923 let provenance = if let (Some(records), Some(signer)) =
928 (provenance_records, engine.provenance_signer.as_ref())
929 {
930 match signer.sign(&agent_id, &request.query, &records) {
931 Ok(p) => Some(p),
932 Err(e) => {
933 tracing::warn!(error = %e, "failed to sign read provenance; degrading to no-provenance response");
934 None
935 }
936 }
937 } else {
938 None
939 };
940
941 let retained_evidence = request.retained_token_budget.map(|budget| {
950 let retain_now = chrono::Utc::now();
951 let candidates: Vec<crate::query::retained::RetentionCandidate<'_>> = memories
952 .iter()
953 .map(|m| {
954 let age_hours = chrono::DateTime::parse_from_rfc3339(&m.updated_at)
955 .or_else(|_| chrono::DateTime::parse_from_rfc3339(&m.created_at))
956 .map(|ts| {
957 (retain_now - ts.with_timezone(&chrono::Utc)).num_seconds() as f64 / 3600.0
958 })
959 .unwrap_or(0.0);
960 crate::query::retained::RetentionCandidate {
961 id: m.id,
962 content: &m.content,
963 access_count: m.access_count,
964 age_hours,
965 retrieval_score: m.score,
966 }
967 })
968 .collect();
969 crate::query::retained::retain_within_budget(
970 &candidates,
971 budget,
972 crate::query::retained::DEFAULT_EXCERPT_TOKENS,
973 )
974 });
975
976 Ok(RecallResponse {
977 memories,
978 total,
979 provenance,
980 superseded: superseded_chain,
981 orientation_cache: orientation_rendered,
982 evidence_selection,
983 retained_evidence,
984 reconstruction,
985 })
986}
987
988async fn reconstruct_belief(
994 engine: &MnemoEngine,
995 request: &RecallRequest,
996 agent_id: &str,
997 memories: &[ScoredMemory],
998) -> ReconstructedBelief {
999 let cue = request.query.clone();
1000 if memories.is_empty() {
1001 return ReconstructedBelief {
1002 cue: cue.clone(),
1003 summary: format!("No memories matched the cue \"{cue}\"."),
1004 source_ids: Vec::new(),
1005 linked_context_ids: Vec::new(),
1006 confidence: 0.0,
1007 };
1008 }
1009
1010 let source_ids: Vec<Uuid> = memories.iter().map(|m| m.id).collect();
1011 let mut seen: HashSet<Uuid> = source_ids.iter().copied().collect();
1012
1013 let mut linked: Vec<(Uuid, String)> = Vec::new();
1017 for m in memories {
1018 let from_rels = engine
1019 .storage
1020 .get_relations_from(m.id)
1021 .await
1022 .unwrap_or_default();
1023 let to_rels = engine
1024 .storage
1025 .get_relations_to(m.id)
1026 .await
1027 .unwrap_or_default();
1028 for rel in from_rels.iter().chain(to_rels.iter()) {
1029 let linked_id = if rel.source_id == m.id {
1030 rel.target_id
1031 } else {
1032 rel.source_id
1033 };
1034 if seen.insert(linked_id)
1035 && let Ok(Some(mut rec)) = engine.storage.get_memory(linked_id).await
1036 && passes_filters(&rec, request, agent_id, engine).await
1037 {
1038 decrypt_record_content(engine, &mut rec);
1039 linked.push((linked_id, rec.content));
1040 }
1041 }
1042 }
1043
1044 let mut summary = format!("Reconstructed belief for cue \"{cue}\":\n\nDirect evidence:\n");
1046 for (i, m) in memories.iter().enumerate() {
1047 summary.push_str(&format!("{}. {}\n", i + 1, excerpt(&m.content, 200)));
1048 }
1049 if linked.is_empty() {
1050 summary.push_str("\n(No linked context found in the memory graph.)\n");
1051 } else {
1052 summary.push_str("\nLinked context (from graph relations):\n");
1053 for (_, content) in &linked {
1054 summary.push_str(&format!("- {}\n", excerpt(content, 160)));
1055 }
1056 }
1057
1058 let confidence = memories.iter().map(|m| m.score).sum::<f32>() / memories.len() as f32;
1059
1060 ReconstructedBelief {
1061 cue,
1062 summary,
1063 source_ids,
1064 linked_context_ids: linked.into_iter().map(|(id, _)| id).collect(),
1065 confidence,
1066 }
1067}
1068
1069fn excerpt(content: &str, max: usize) -> String {
1071 let line = content.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
1072 let trimmed = line.trim();
1073 if trimmed.chars().count() <= max {
1074 trimmed.to_string()
1075 } else {
1076 let mut out: String = trimmed.chars().take(max).collect();
1077 out.push('…');
1078 out
1079 }
1080}
1081
1082fn decrypt_record_content(engine: &MnemoEngine, record: &mut MemoryRecord) {
1087 if let Some(ref enc) = engine.encryption {
1088 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&record.content)
1089 && let Ok(plain) = enc.decrypt(&bytes)
1090 && let Ok(text) = String::from_utf8(plain)
1091 {
1092 record.content = text;
1093 } else {
1094 record.content = "[content unavailable: decryption error]".to_string();
1095 }
1096 }
1097}
1098
1099async fn passes_filters(
1100 record: &MemoryRecord,
1101 request: &RecallRequest,
1102 agent_id: &str,
1103 engine: &MnemoEngine,
1104) -> bool {
1105 if record
1110 .tags
1111 .iter()
1112 .any(|t| t == crate::query::experience::EXPERIENCE_PLAN_TAG)
1113 && !request
1114 .tags
1115 .as_ref()
1116 .map(|ts| {
1117 ts.iter()
1118 .any(|t| t == crate::query::experience::EXPERIENCE_PLAN_TAG)
1119 })
1120 .unwrap_or(false)
1121 {
1122 return false;
1123 }
1124
1125 if request.as_of.is_none() && record.is_deleted() {
1127 return false;
1128 }
1129
1130 if let Some(ref expires_at) = record.expires_at
1132 && let Ok(exp) = chrono::DateTime::parse_from_rfc3339(expires_at)
1133 && exp < chrono::Utc::now()
1134 {
1135 return false;
1136 }
1137
1138 if record.quarantined {
1140 return false;
1141 }
1142
1143 if let Some(ref s) = request.scope
1145 && record.scope != *s
1146 {
1147 return false;
1148 }
1149
1150 if let Some(ref mts) = request.memory_types {
1152 if !mts.contains(&record.memory_type) {
1153 return false;
1154 }
1155 } else if let Some(ref mt) = request.memory_type
1156 && record.memory_type != *mt
1157 {
1158 return false;
1159 }
1160
1161 if let Some(min_imp) = request.min_importance
1163 && record.importance < min_imp
1164 {
1165 return false;
1166 }
1167
1168 if let Some(ref req_tags) = request.tags
1170 && !req_tags.iter().any(|t| record.tags.contains(t))
1171 {
1172 return false;
1173 }
1174
1175 if let Some(ref tr) = request.temporal_range {
1177 if let Some(ref after) = tr.after
1178 && let (Ok(after_dt), Ok(record_dt)) = (
1179 chrono::DateTime::parse_from_rfc3339(after),
1180 chrono::DateTime::parse_from_rfc3339(&record.created_at),
1181 )
1182 && record_dt < after_dt
1183 {
1184 return false;
1185 }
1186 if let Some(ref before) = tr.before
1187 && let (Ok(before_dt), Ok(record_dt)) = (
1188 chrono::DateTime::parse_from_rfc3339(before),
1189 chrono::DateTime::parse_from_rfc3339(&record.created_at),
1190 )
1191 && record_dt > before_dt
1192 {
1193 return false;
1194 }
1195 }
1196
1197 if let Some(ref as_of) = request.as_of {
1199 if let (Ok(as_of_dt), Ok(record_dt)) = (
1200 chrono::DateTime::parse_from_rfc3339(as_of),
1201 chrono::DateTime::parse_from_rfc3339(&record.created_at),
1202 ) && record_dt > as_of_dt
1203 {
1204 return false;
1206 }
1207 if let Some(ref deleted_at) = record.deleted_at
1209 && let (Ok(del_dt), Ok(as_of_dt)) = (
1210 chrono::DateTime::parse_from_rfc3339(deleted_at),
1211 chrono::DateTime::parse_from_rfc3339(as_of),
1212 )
1213 && del_dt <= as_of_dt
1214 {
1215 return false;
1216 }
1217 }
1218
1219 match record.scope {
1221 Scope::Public | Scope::Global => true,
1222 Scope::Shared => {
1223 record.agent_id == agent_id
1224 || engine
1225 .storage
1226 .check_permission(
1227 record.id,
1228 agent_id,
1229 crate::model::acl::Permission::Read,
1230 )
1231 .await
1232 .unwrap_or_else(|e| {
1233 tracing::warn!(memory_id = %record.id, error = %e, "permission check failed, denying access");
1234 false
1235 })
1236 }
1237 Scope::Private => record.agent_id == agent_id,
1238 }
1239}