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 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub reasoning_trust: Option<crate::retrieval::ReasoningTrustPolicy>,
120}
121
122impl RecallRequest {
123 pub fn new(query: String) -> Self {
124 Self {
125 query,
126 agent_id: None,
127 limit: None,
128 memory_type: None,
129 memory_types: None,
130 scope: None,
131 min_importance: None,
132 tags: None,
133 org_id: None,
134 strategy: None,
135 temporal_range: None,
136 recency_half_life_hours: None,
137 hybrid_weights: None,
138 rrf_k: None,
139 as_of: None,
140 explain: None,
141 with_provenance: None,
142 mode: None,
143 current_fact_resolver: None,
144 orientation_cache: None,
145 evidence_budget: None,
146 retained_token_budget: None,
147 domain_scope: None,
148 reasoning_trust: None,
149 }
150 }
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
159pub struct SupersededRecord {
160 pub id: Uuid,
161 pub fact_id: String,
162 pub superseded_by: Uuid,
163 pub superseded_at: String,
165 pub prior_updated_at: String,
167}
168
169#[derive(Debug, Clone, Default, Serialize, Deserialize)]
175pub struct ScoreBreakdown {
176 pub vector: f32,
177 pub bm25: f32,
178 pub graph: f32,
179 pub recency: f32,
180 pub rrf_rank: u32,
182}
183
184#[non_exhaustive]
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct RecallResponse {
187 pub memories: Vec<ScoredMemory>,
188 pub total: usize,
189 #[serde(skip_serializing_if = "Option::is_none", default)]
194 pub provenance: Option<crate::provenance::ReadProvenance>,
195 #[serde(skip_serializing_if = "Option::is_none", default)]
201 pub superseded: Option<Vec<SupersededRecord>>,
202 #[serde(skip_serializing_if = "Option::is_none", default)]
209 pub orientation_cache: Option<crate::query::orientation_cache::RenderedContextMap>,
210 #[serde(skip_serializing_if = "Option::is_none", default)]
216 pub evidence_selection: Option<crate::query::evidence::EvidenceSelectionReport>,
217 #[serde(skip_serializing_if = "Option::is_none", default)]
224 pub retained_evidence: Option<crate::query::retained::RetentionReport>,
225 #[serde(skip_serializing_if = "Option::is_none", default)]
233 pub reconstruction: Option<ReconstructedBelief>,
234}
235
236impl RecallResponse {
237 pub fn new(memories: Vec<ScoredMemory>, total: usize) -> Self {
238 Self {
239 memories,
240 total,
241 provenance: None,
242 superseded: None,
243 orientation_cache: None,
244 evidence_selection: None,
245 retained_evidence: None,
246 reconstruction: None,
247 }
248 }
249}
250
251#[non_exhaustive]
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct ReconstructedBelief {
263 pub cue: String,
265 pub summary: String,
268 pub source_ids: Vec<Uuid>,
270 pub linked_context_ids: Vec<Uuid>,
273 pub confidence: f32,
275}
276
277#[non_exhaustive]
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct ScoredMemory {
280 pub id: Uuid,
281 pub content: String,
282 pub agent_id: String,
283 pub memory_type: MemoryType,
284 pub scope: Scope,
285 pub importance: f32,
286 pub tags: Vec<String>,
287 pub metadata: serde_json::Value,
288 pub score: f32,
289 pub access_count: u64,
290 pub created_at: String,
291 pub updated_at: String,
292 #[serde(skip_serializing_if = "Option::is_none")]
293 pub score_breakdown: Option<ScoreBreakdown>,
294}
295
296impl From<(MemoryRecord, f32)> for ScoredMemory {
297 fn from((record, score): (MemoryRecord, f32)) -> Self {
298 Self {
299 id: record.id,
300 content: record.content,
301 agent_id: record.agent_id,
302 memory_type: record.memory_type,
303 scope: record.scope,
304 importance: record.importance,
305 tags: record.tags,
306 metadata: record.metadata,
307 score,
308 access_count: record.access_count,
309 created_at: record.created_at,
310 updated_at: record.updated_at,
311 score_breakdown: None,
312 }
313 }
314}
315
316async fn get_memory_cached(engine: &MnemoEngine, id: Uuid) -> Result<Option<MemoryRecord>> {
318 if let Some(ref cache) = engine.cache
319 && let Some(record) = cache.get(id)
320 {
321 return Ok(Some(record));
322 }
323 let result = engine.storage.get_memory(id).await?;
324 if let Some(ref record) = result
325 && let Some(ref cache) = engine.cache
326 {
327 cache.put(record.clone());
328 }
329 Ok(result)
330}
331
332pub async fn execute(engine: &MnemoEngine, request: RecallRequest) -> Result<RecallResponse> {
333 let limit = request.limit.unwrap_or(10).min(100);
334 let agent_id = request
335 .agent_id
336 .clone()
337 .unwrap_or_else(|| engine.default_agent_id.clone());
338 super::validate_agent_id(&agent_id)?;
339
340 let strategy = if let Some(ref mode) = request.mode {
345 mode.to_strategy_str()
346 } else if request
347 .domain_scope
348 .as_ref()
349 .map(|s| !s.is_empty())
350 .unwrap_or(false)
351 {
352 "domain_scoped"
356 } else {
357 request.strategy.as_deref().unwrap_or("auto")
358 };
359
360 let needs_semantic = matches!(
367 strategy,
368 "semantic" | "hybrid" | "auto" | "graph" | "domain_scoped"
369 );
370 if needs_semantic && !engine.embedding.is_semantic_capable() {
371 return Err(crate::error::Error::EmbedderNotConfigured {
372 requested: strategy.to_string(),
373 backend: engine.storage.backend_name().to_string(),
374 });
375 }
376
377 let query_embedding = engine.embedding.embed(&request.query).await?;
379
380 let accessible_ids: HashSet<Uuid> = engine
382 .storage
383 .list_accessible_memory_ids(&agent_id, super::MAX_BATCH_QUERY_LIMIT)
384 .await?
385 .into_iter()
386 .collect();
387 let perm_filter = |id: Uuid| accessible_ids.contains(&id);
388
389 let mut scored_memories: Vec<(MemoryRecord, f32)> = Vec::new();
390 let mut breakdowns: std::collections::HashMap<Uuid, ScoreBreakdown> =
391 std::collections::HashMap::new();
392
393 match strategy {
394 "lexical" => {
395 if let Some(ref ft) = engine.full_text {
397 let bm25_results = ft.search(&request.query, limit * 3)?;
398 for (id, score) in bm25_results {
399 if let Some(record) = get_memory_cached(engine, id).await?
400 && passes_filters(&record, &request, &agent_id, engine).await
401 {
402 scored_memories.push((record, score));
403 }
404 }
405 }
406 }
407 "semantic" => {
408 let search_results = engine
410 .index
411 .filtered_search(&query_embedding, limit * 3, &perm_filter)
412 .await?;
413 for (id, distance) in search_results {
414 if let Some(record) = get_memory_cached(engine, id).await?
415 && passes_filters(&record, &request, &agent_id, engine).await
416 {
417 let score = 1.0 - distance;
418 scored_memories.push((record, score));
419 }
420 }
421 }
422 "domain_scoped" => {
423 let domain_ids: Option<HashSet<Uuid>> = match request.domain_scope.as_ref() {
433 Some(scope) if !scope.is_empty() => {
434 let coarse = MemoryFilter {
437 agent_id: None,
438 memory_type: None,
439 scope: None,
440 tags: None,
441 min_importance: None,
442 org_id: scope.org_id.clone(),
443 thread_id: None,
444 include_deleted: false,
445 };
446 let records = engine
447 .storage
448 .list_memories(&coarse, super::MAX_BATCH_QUERY_LIMIT, 0)
449 .await?;
450 Some(
451 records
452 .iter()
453 .filter(|r| scope.matches(r))
454 .map(|r| r.id)
455 .collect(),
456 )
457 }
458 _ => None,
461 };
462
463 let domain_filter = |id: Uuid| {
464 perm_filter(id) && domain_ids.as_ref().map(|d| d.contains(&id)).unwrap_or(true)
465 };
466 let search_results = engine
467 .index
468 .filtered_search(&query_embedding, limit * 3, &domain_filter)
469 .await?;
470 for (id, distance) in search_results {
471 if let Some(record) = get_memory_cached(engine, id).await?
472 && passes_filters(&record, &request, &agent_id, engine).await
473 {
474 let score = 1.0 - distance;
475 scored_memories.push((record, score));
476 }
477 }
478 }
479 "graph" => {
480 let search_results = engine
482 .index
483 .filtered_search(&query_embedding, limit * 3, &perm_filter)
484 .await?;
485 let mut seeds: Vec<(Uuid, f32)> = Vec::new();
486 for (id, distance) in &search_results {
487 if let Some(record) = get_memory_cached(engine, *id).await?
488 && passes_filters(&record, &request, &agent_id, engine).await
489 {
490 seeds.push((*id, 1.0 - distance));
491 }
492 }
493
494 let max_hops = 2;
496 let mut seen: HashSet<Uuid> = seeds.iter().map(|(id, _)| *id).collect();
497 let mut graph_ranked: Vec<(Uuid, f32)> = Vec::new();
498
499 for &(id, _) in &seeds {
501 graph_ranked.push((id, 1.0));
502 }
503
504 let mut frontier: Vec<Uuid> = seeds.iter().map(|(id, _)| *id).collect();
506 let mut decay = 0.5_f32;
507 for _hop in 0..max_hops {
508 let mut next_frontier: Vec<Uuid> = Vec::new();
509 for &id in &frontier {
510 let from_rels = engine.storage.get_relations_from(id).await?;
511 let to_rels = engine.storage.get_relations_to(id).await?;
512 for rel in from_rels.iter().chain(to_rels.iter()) {
513 let related_id = if rel.source_id == id {
514 rel.target_id
515 } else {
516 rel.source_id
517 };
518 if seen.insert(related_id)
519 && let Some(record) = get_memory_cached(engine, related_id).await?
520 && passes_filters(&record, &request, &agent_id, engine).await
521 {
522 graph_ranked.push((related_id, decay));
523 next_frontier.push(related_id);
524 }
525 }
526 }
527 frontier = next_frontier;
528 decay *= 0.5;
529 }
530
531 let mut v_sorted: Vec<(Uuid, f32)> = seeds.clone();
533 v_sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
534 graph_ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
535
536 let ranked_lists = vec![v_sorted, graph_ranked];
537 let rrf_k = request.rrf_k.unwrap_or(60.0);
538 let fused = if let Some(ref weights) = request.hybrid_weights {
539 crate::query::retrieval::weighted_reciprocal_rank_fusion(
540 &ranked_lists,
541 rrf_k,
542 weights,
543 )
544 } else {
545 crate::query::retrieval::reciprocal_rank_fusion(&ranked_lists, rrf_k)
546 };
547
548 for (id, score) in fused {
549 if let Some(record) = get_memory_cached(engine, id).await?
550 && passes_filters(&record, &request, &agent_id, engine).await
551 {
552 scored_memories.push((record, score));
553 }
554 }
555 }
556 "exact" => {
557 let filter = MemoryFilter {
560 agent_id: Some(agent_id.clone()),
561 memory_type: request.memory_type,
562 scope: request.scope,
563 tags: request.tags.clone(),
564 min_importance: request.min_importance,
565 org_id: request.org_id.clone(),
566 thread_id: None,
567 include_deleted: request.as_of.is_some(),
568 };
569 let memories = engine.storage.list_memories(&filter, limit, 0).await?;
570 for record in memories {
571 if passes_filters(&record, &request, &agent_id, engine).await {
572 scored_memories.push((record, 1.0));
573 }
574 }
575 }
576 _ => {
577 let vector_results = engine
579 .index
580 .filtered_search(&query_embedding, limit * 3, &perm_filter)
581 .await?;
582 let mut vector_ranked: Vec<(Uuid, f32)> = Vec::new();
583 for (id, distance) in vector_results {
584 vector_ranked.push((id, 1.0 - distance));
585 }
586
587 if let Some(ref ft) = engine.full_text {
588 let bm25_results = ft.search(&request.query, limit * 3)?;
590
591 let mut recency_ranked: Vec<(Uuid, f32)> = Vec::new();
593 for &(id, _) in &vector_ranked {
594 if let Some(record) = get_memory_cached(engine, id).await? {
595 let r_score = crate::query::retrieval::recency_score(
596 &record.created_at,
597 request.recency_half_life_hours.unwrap_or(168.0),
598 );
599 recency_ranked.push((id, r_score));
600 }
601 }
602 for &(id, _) in &bm25_results {
604 if !recency_ranked.iter().any(|(rid, _)| *rid == id)
605 && let Some(record) = get_memory_cached(engine, id).await?
606 {
607 let r_score = crate::query::retrieval::recency_score(
608 &record.created_at,
609 request.recency_half_life_hours.unwrap_or(168.0),
610 );
611 recency_ranked.push((id, r_score));
612 }
613 }
614
615 let mut v_sorted = vector_ranked.clone();
617 v_sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
618 let mut b_sorted = bm25_results;
619 b_sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
620 recency_ranked
621 .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
622
623 let max_hops = 2;
625 let mut graph_ranked: Vec<(Uuid, f32)> = Vec::new();
626 let top_seeds: Vec<Uuid> =
627 vector_ranked.iter().take(10).map(|(id, _)| *id).collect();
628 let mut graph_seen: HashSet<Uuid> = top_seeds.iter().copied().collect();
629 for &seed_id in &top_seeds {
630 graph_ranked.push((seed_id, 1.0));
631 }
632 let mut frontier: Vec<Uuid> = top_seeds;
633 let mut decay = 0.5_f32;
634 for _hop in 0..max_hops {
635 let mut next_frontier: Vec<Uuid> = Vec::new();
636 for &fid in &frontier {
637 match engine.storage.get_relations_from(fid).await {
638 Ok(from_rels) => {
639 for rel in &from_rels {
640 if graph_seen.insert(rel.target_id) {
641 graph_ranked.push((rel.target_id, decay));
642 next_frontier.push(rel.target_id);
643 }
644 }
645 }
646 Err(e) => {
647 tracing::warn!(memory_id = %fid, error = %e, "graph expansion: failed to get outgoing relations");
648 }
649 }
650 match engine.storage.get_relations_to(fid).await {
651 Ok(to_rels) => {
652 for rel in &to_rels {
653 if graph_seen.insert(rel.source_id) {
654 graph_ranked.push((rel.source_id, decay));
655 next_frontier.push(rel.source_id);
656 }
657 }
658 }
659 Err(e) => {
660 tracing::warn!(memory_id = %fid, error = %e, "graph expansion: failed to get incoming relations");
661 }
662 }
663 }
664 frontier = next_frontier;
665 decay *= 0.5;
666 }
667 graph_ranked
668 .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
669
670 let explain = request.explain.unwrap_or(false);
674 type SignalMap = std::collections::HashMap<Uuid, f32>;
675 let (vector_map, bm25_map, recency_map, graph_map): (
676 SignalMap,
677 SignalMap,
678 SignalMap,
679 SignalMap,
680 ) = if explain {
681 (
682 v_sorted.iter().copied().collect(),
683 b_sorted.iter().copied().collect(),
684 recency_ranked.iter().copied().collect(),
685 graph_ranked.iter().copied().collect(),
686 )
687 } else {
688 Default::default()
689 };
690
691 let ranked_lists = vec![v_sorted, b_sorted, recency_ranked, graph_ranked];
692 let rrf_k = request.rrf_k.unwrap_or(60.0);
693 let fused = if let Some(ref weights) = request.hybrid_weights {
694 crate::query::retrieval::weighted_reciprocal_rank_fusion(
695 &ranked_lists,
696 rrf_k,
697 weights,
698 )
699 } else {
700 crate::query::retrieval::reciprocal_rank_fusion(&ranked_lists, rrf_k)
701 };
702
703 for (rank, (id, score)) in fused.into_iter().enumerate() {
704 if let Some(record) = get_memory_cached(engine, id).await?
705 && passes_filters(&record, &request, &agent_id, engine).await
706 {
707 scored_memories.push((record, score));
708 if explain {
709 breakdowns.insert(
710 id,
711 ScoreBreakdown {
712 vector: vector_map.get(&id).copied().unwrap_or(0.0),
713 bm25: bm25_map.get(&id).copied().unwrap_or(0.0),
714 graph: graph_map.get(&id).copied().unwrap_or(0.0),
715 recency: recency_map.get(&id).copied().unwrap_or(0.0),
716 rrf_rank: rank as u32,
717 },
718 );
719 }
720 }
721 }
722 } else {
723 for (id, score) in vector_ranked {
725 if let Some(record) = get_memory_cached(engine, id).await?
726 && passes_filters(&record, &request, &agent_id, engine).await
727 {
728 scored_memories.push((record, score));
729 }
730 }
731 }
732 }
733 }
734
735 scored_memories.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
737 scored_memories.truncate(limit);
738
739 let evidence_selection = if let Some(ref budget) = request.evidence_budget {
748 let cosine_default = crate::query::evidence::CosineScorer;
749 let scorer: &dyn crate::query::evidence::EvidenceScorer =
750 match (budget.scorer, engine.evidence_scorer.as_ref()) {
751 (crate::query::evidence::ScorerKind::Delta, Some(s)) => s.as_ref(),
752 _ => &cosine_default,
753 };
754 let q_emb: Option<&[f32]> = if query_embedding.iter().any(|v| *v != 0.0) {
758 Some(query_embedding.as_slice())
759 } else {
760 None
761 };
762 let candidates: Vec<crate::query::evidence::EvidenceCandidate<'_>> = scored_memories
763 .iter()
764 .map(|(r, score)| crate::query::evidence::EvidenceCandidate {
765 content: &r.content,
766 embedding: r.embedding.as_deref(),
767 retrieval_score: *score,
768 })
769 .collect();
770 let selection = crate::query::evidence::select_within_budget(
771 &candidates,
772 budget,
773 scorer,
774 &request.query,
775 q_emb,
776 );
777 let keep = selection.keep;
778 drop(candidates);
779 scored_memories.truncate(keep);
780 Some(selection.report)
781 } else {
782 None
783 };
784
785 let _total_pre_resolver = scored_memories.len();
786
787 for (record, _) in &scored_memories {
789 if let Err(e) = engine.storage.touch_memory(record.id).await {
790 tracing::warn!(memory_id = %record.id, error = %e, "failed to update access timestamp");
791 }
792 }
793
794 if let Some(ref enc) = engine.encryption {
796 for (record, _) in &mut scored_memories {
797 match base64::engine::general_purpose::STANDARD.decode(&record.content) {
798 Ok(encrypted_bytes) => match enc.decrypt(&encrypted_bytes) {
799 Ok(decrypted) => match String::from_utf8(decrypted) {
800 Ok(plaintext) => record.content = plaintext,
801 Err(e) => {
802 tracing::error!(memory_id = %record.id, error = %e, "decrypted content is not valid UTF-8");
803 record.content = "[content unavailable: decryption error]".to_string();
804 }
805 },
806 Err(e) => {
807 tracing::error!(memory_id = %record.id, error = %e, "failed to decrypt memory content");
808 record.content = "[content unavailable: decryption error]".to_string();
809 }
810 },
811 Err(e) => {
812 tracing::error!(memory_id = %record.id, error = %e, "failed to decode encrypted content");
813 record.content = "[content unavailable: decryption error]".to_string();
814 }
815 }
816 }
817 }
818
819 let provenance_records: Option<Vec<MemoryRecord>> =
824 if request.with_provenance == Some(true) && engine.provenance_signer.is_some() {
825 Some(scored_memories.iter().map(|(r, _)| r.clone()).collect())
826 } else {
827 None
828 };
829
830 let memories: Vec<ScoredMemory> = scored_memories
831 .into_iter()
832 .map(|(record, score)| {
833 let id = record.id;
834 let mut scored = ScoredMemory::from((record, score));
835 if let Some(breakdown) = breakdowns.remove(&id) {
836 scored.score_breakdown = Some(breakdown);
837 }
838 scored
839 })
840 .collect();
841
842 let (memories, superseded_chain) = if let Some(ref cfg) = request.current_fact_resolver {
850 let out = crate::query::current_fact_resolver::resolve(cfg, memories);
851 let chain = if cfg.include_supersession_chain && !out.superseded.is_empty() {
852 Some(out.superseded)
853 } else {
854 None
855 };
856 (out.kept, chain)
857 } else {
858 (memories, None)
859 };
860 let total = memories.len();
861
862 let reconstruction = if strategy == "reconstruct" {
868 Some(reconstruct_belief(engine, &request, &agent_id, &memories).await)
869 } else {
870 None
871 };
872
873 let orientation_rendered = match (
880 request.orientation_cache.as_ref(),
881 engine.orientation_cache_store.as_ref(),
882 ) {
883 (Some(cfg), Some(store)) => {
884 let ns = crate::query::orientation_cache::resolve_namespace(
885 cfg,
886 &agent_id,
887 request.org_id.as_deref(),
888 );
889 let rendered =
890 crate::query::orientation_cache::update_and_render(store, cfg, &ns, &memories);
891 if cfg.include_in_response {
892 Some(rendered)
893 } else {
894 None
895 }
896 }
897 _ => None,
898 };
899
900 let now = chrono::Utc::now().to_rfc3339();
902 let event_content_hash = compute_content_hash(&request.query, &agent_id, &now);
903 let prev_event_hash = match engine.storage.get_latest_event_hash(&agent_id, None).await {
904 Ok(hash) => hash,
905 Err(e) => {
906 tracing::warn!(error = %e, "failed to get latest event hash, starting new chain segment");
907 None
908 }
909 };
910 let event_prev_hash = Some(crate::hash::compute_chain_hash(
911 &event_content_hash,
912 prev_event_hash.as_deref(),
913 ));
914 let mut event = AgentEvent {
915 id: Uuid::now_v7(),
916 agent_id: agent_id.clone(),
917 thread_id: None,
918 run_id: None,
919 parent_event_id: None,
920 event_type: EventType::MemoryRead,
921 payload: serde_json::json!({
922 "query": request.query,
923 "results": total,
924 "strategy": strategy,
925 }),
926 trace_id: None,
927 span_id: None,
928 model: None,
929 tokens_input: None,
930 tokens_output: None,
931 latency_ms: None,
932 cost_usd: None,
933 timestamp: now.clone(),
934 logical_clock: 0,
935 content_hash: event_content_hash,
936 prev_hash: event_prev_hash,
937 embedding: None,
938 };
939 if engine.embed_events
941 && let Ok(emb) = engine.embedding.embed(&event.payload.to_string()).await
942 {
943 event.embedding = Some(emb);
944 }
945 if let Err(e) = engine.storage.insert_event(&event).await {
946 tracing::error!(event_id = %event.id, error = %e, "failed to insert audit event");
947 }
948
949 let provenance = if let (Some(records), Some(signer)) =
954 (provenance_records, engine.provenance_signer.as_ref())
955 {
956 match signer.sign(&agent_id, &request.query, &records) {
957 Ok(p) => Some(p),
958 Err(e) => {
959 tracing::warn!(error = %e, "failed to sign read provenance; degrading to no-provenance response");
960 None
961 }
962 }
963 } else {
964 None
965 };
966
967 let retained_evidence = request.retained_token_budget.map(|budget| {
976 let retain_now = chrono::Utc::now();
977 let candidates: Vec<crate::query::retained::RetentionCandidate<'_>> = memories
978 .iter()
979 .map(|m| {
980 let age_hours = chrono::DateTime::parse_from_rfc3339(&m.updated_at)
981 .or_else(|_| chrono::DateTime::parse_from_rfc3339(&m.created_at))
982 .map(|ts| {
983 (retain_now - ts.with_timezone(&chrono::Utc)).num_seconds() as f64 / 3600.0
984 })
985 .unwrap_or(0.0);
986 crate::query::retained::RetentionCandidate {
987 id: m.id,
988 content: &m.content,
989 access_count: m.access_count,
990 age_hours,
991 retrieval_score: m.score,
992 }
993 })
994 .collect();
995 crate::query::retained::retain_within_budget(
996 &candidates,
997 budget,
998 crate::query::retained::DEFAULT_EXCERPT_TOKENS,
999 )
1000 });
1001
1002 Ok(RecallResponse {
1003 memories,
1004 total,
1005 provenance,
1006 superseded: superseded_chain,
1007 orientation_cache: orientation_rendered,
1008 evidence_selection,
1009 retained_evidence,
1010 reconstruction,
1011 })
1012}
1013
1014async fn reconstruct_belief(
1020 engine: &MnemoEngine,
1021 request: &RecallRequest,
1022 agent_id: &str,
1023 memories: &[ScoredMemory],
1024) -> ReconstructedBelief {
1025 let cue = request.query.clone();
1026 if memories.is_empty() {
1027 return ReconstructedBelief {
1028 cue: cue.clone(),
1029 summary: format!("No memories matched the cue \"{cue}\"."),
1030 source_ids: Vec::new(),
1031 linked_context_ids: Vec::new(),
1032 confidence: 0.0,
1033 };
1034 }
1035
1036 let source_ids: Vec<Uuid> = memories.iter().map(|m| m.id).collect();
1037 let mut seen: HashSet<Uuid> = source_ids.iter().copied().collect();
1038
1039 let mut linked: Vec<(Uuid, String)> = Vec::new();
1043 for m in memories {
1044 let from_rels = engine
1045 .storage
1046 .get_relations_from(m.id)
1047 .await
1048 .unwrap_or_default();
1049 let to_rels = engine
1050 .storage
1051 .get_relations_to(m.id)
1052 .await
1053 .unwrap_or_default();
1054 for rel in from_rels.iter().chain(to_rels.iter()) {
1055 let linked_id = if rel.source_id == m.id {
1056 rel.target_id
1057 } else {
1058 rel.source_id
1059 };
1060 if seen.insert(linked_id)
1061 && let Ok(Some(mut rec)) = engine.storage.get_memory(linked_id).await
1062 && passes_filters(&rec, request, agent_id, engine).await
1063 {
1064 decrypt_record_content(engine, &mut rec);
1065 linked.push((linked_id, rec.content));
1066 }
1067 }
1068 }
1069
1070 let mut summary = format!("Reconstructed belief for cue \"{cue}\":\n\nDirect evidence:\n");
1072 for (i, m) in memories.iter().enumerate() {
1073 summary.push_str(&format!("{}. {}\n", i + 1, excerpt(&m.content, 200)));
1074 }
1075 if linked.is_empty() {
1076 summary.push_str("\n(No linked context found in the memory graph.)\n");
1077 } else {
1078 summary.push_str("\nLinked context (from graph relations):\n");
1079 for (_, content) in &linked {
1080 summary.push_str(&format!("- {}\n", excerpt(content, 160)));
1081 }
1082 }
1083
1084 let confidence = memories.iter().map(|m| m.score).sum::<f32>() / memories.len() as f32;
1085
1086 ReconstructedBelief {
1087 cue,
1088 summary,
1089 source_ids,
1090 linked_context_ids: linked.into_iter().map(|(id, _)| id).collect(),
1091 confidence,
1092 }
1093}
1094
1095fn excerpt(content: &str, max: usize) -> String {
1097 let line = content.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
1098 let trimmed = line.trim();
1099 if trimmed.chars().count() <= max {
1100 trimmed.to_string()
1101 } else {
1102 let mut out: String = trimmed.chars().take(max).collect();
1103 out.push('…');
1104 out
1105 }
1106}
1107
1108fn decrypt_record_content(engine: &MnemoEngine, record: &mut MemoryRecord) {
1113 if let Some(ref enc) = engine.encryption {
1114 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&record.content)
1115 && let Ok(plain) = enc.decrypt(&bytes)
1116 && let Ok(text) = String::from_utf8(plain)
1117 {
1118 record.content = text;
1119 } else {
1120 record.content = "[content unavailable: decryption error]".to_string();
1121 }
1122 }
1123}
1124
1125async fn passes_filters(
1126 record: &MemoryRecord,
1127 request: &RecallRequest,
1128 agent_id: &str,
1129 engine: &MnemoEngine,
1130) -> bool {
1131 if record
1136 .tags
1137 .iter()
1138 .any(|t| t == crate::query::experience::EXPERIENCE_PLAN_TAG)
1139 && !request
1140 .tags
1141 .as_ref()
1142 .map(|ts| {
1143 ts.iter()
1144 .any(|t| t == crate::query::experience::EXPERIENCE_PLAN_TAG)
1145 })
1146 .unwrap_or(false)
1147 {
1148 return false;
1149 }
1150
1151 if request.as_of.is_none() && record.is_deleted() {
1153 return false;
1154 }
1155
1156 if let Some(ref expires_at) = record.expires_at
1158 && let Ok(exp) = chrono::DateTime::parse_from_rfc3339(expires_at)
1159 && exp < chrono::Utc::now()
1160 {
1161 return false;
1162 }
1163
1164 if record.quarantined {
1166 return false;
1167 }
1168
1169 if let Some(ref policy) = request.reasoning_trust
1174 && policy.excludes_record(record)
1175 {
1176 return false;
1177 }
1178
1179 if let Some(ref s) = request.scope
1181 && record.scope != *s
1182 {
1183 return false;
1184 }
1185
1186 if let Some(ref mts) = request.memory_types {
1188 if !mts.contains(&record.memory_type) {
1189 return false;
1190 }
1191 } else if let Some(ref mt) = request.memory_type
1192 && record.memory_type != *mt
1193 {
1194 return false;
1195 }
1196
1197 if let Some(min_imp) = request.min_importance
1199 && record.importance < min_imp
1200 {
1201 return false;
1202 }
1203
1204 if let Some(ref req_tags) = request.tags
1206 && !req_tags.iter().any(|t| record.tags.contains(t))
1207 {
1208 return false;
1209 }
1210
1211 if let Some(ref tr) = request.temporal_range {
1213 if let Some(ref after) = tr.after
1214 && let (Ok(after_dt), Ok(record_dt)) = (
1215 chrono::DateTime::parse_from_rfc3339(after),
1216 chrono::DateTime::parse_from_rfc3339(&record.created_at),
1217 )
1218 && record_dt < after_dt
1219 {
1220 return false;
1221 }
1222 if let Some(ref before) = tr.before
1223 && let (Ok(before_dt), Ok(record_dt)) = (
1224 chrono::DateTime::parse_from_rfc3339(before),
1225 chrono::DateTime::parse_from_rfc3339(&record.created_at),
1226 )
1227 && record_dt > before_dt
1228 {
1229 return false;
1230 }
1231 }
1232
1233 if let Some(ref as_of) = request.as_of {
1235 if let (Ok(as_of_dt), Ok(record_dt)) = (
1236 chrono::DateTime::parse_from_rfc3339(as_of),
1237 chrono::DateTime::parse_from_rfc3339(&record.created_at),
1238 ) && record_dt > as_of_dt
1239 {
1240 return false;
1242 }
1243 if let Some(ref deleted_at) = record.deleted_at
1245 && let (Ok(del_dt), Ok(as_of_dt)) = (
1246 chrono::DateTime::parse_from_rfc3339(deleted_at),
1247 chrono::DateTime::parse_from_rfc3339(as_of),
1248 )
1249 && del_dt <= as_of_dt
1250 {
1251 return false;
1252 }
1253 }
1254
1255 match record.scope {
1257 Scope::Public | Scope::Global => true,
1258 Scope::Shared => {
1259 record.agent_id == agent_id
1260 || engine
1261 .storage
1262 .check_permission(
1263 record.id,
1264 agent_id,
1265 crate::model::acl::Permission::Read,
1266 )
1267 .await
1268 .unwrap_or_else(|e| {
1269 tracing::warn!(memory_id = %record.id, error = %e, "permission check failed, denying access");
1270 false
1271 })
1272 }
1273 Scope::Private => record.agent_id == agent_id,
1274 }
1275}