1mod algorithms;
24mod corrections;
25mod cross_session;
26mod graph;
27pub(crate) mod importance;
28pub mod persona;
29mod recall;
30mod summarization;
31pub mod trajectory;
32pub mod tree_consolidation;
33pub(crate) mod write_buffer;
34
35#[cfg(test)]
36mod tests;
37
38use std::sync::Arc;
39use std::sync::Mutex;
40use std::sync::atomic::AtomicU64;
41use std::time::Instant;
42
43use tokio::sync::RwLock;
44use zeph_llm::any::AnyProvider;
45use zeph_llm::provider::LlmProvider as _;
46
47use crate::admission::AdmissionControl;
48use crate::embedding_store::EmbeddingStore;
49use crate::error::MemoryError;
50use crate::store::SqliteStore;
51use crate::token_counter::TokenCounter;
52
53pub(crate) const SESSION_SUMMARIES_COLLECTION: &str = "zeph_session_summaries";
54pub(crate) const KEY_FACTS_COLLECTION: &str = "zeph_key_facts";
55pub(crate) const CORRECTIONS_COLLECTION: &str = "zeph_corrections";
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct BackfillProgress {
60 pub done: usize,
62 pub total: usize,
64}
65
66pub use algorithms::{apply_mmr, apply_temporal_decay};
67pub use cross_session::SessionSummaryResult;
68pub use graph::{
69 ExtractionResult, ExtractionStats, GraphExtractionConfig, LinkingStats, NoteLinkingConfig,
70 PostExtractValidator, extract_and_store, link_memory_notes,
71};
72pub use persona::{
73 PersonaExtractionConfig, contains_self_referential_language, extract_persona_facts,
74};
75pub use recall::{EmbedContext, RecalledMessage};
76pub use summarization::{StructuredSummary, Summary, build_summarization_prompt};
77pub use trajectory::{TrajectoryEntry, TrajectoryExtractionConfig, extract_trajectory_entries};
78pub use tree_consolidation::{
79 TreeConsolidationConfig, TreeConsolidationResult, run_tree_consolidation_sweep,
80 start_tree_consolidation_loop,
81};
82pub use write_buffer::{BufferedWrite, WriteBuffer};
83
84#[derive(Debug, Clone)]
89pub(crate) struct CachedCentroid {
90 pub vector: Vec<f32>,
92 pub computed_at: Instant,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub(crate) enum QueryIntent {
102 FirstPerson,
104 Other,
106}
107
108#[derive(Debug, Clone, Default)]
112pub struct HelaSpreadRuntime {
113 pub enabled: bool,
115 pub depth: u32,
117 pub max_visited: usize,
119 pub edge_types: Vec<crate::graph::EdgeType>,
121 pub step_budget: Option<std::time::Duration>,
123}
124
125#[allow(clippy::struct_excessive_bools)]
132pub struct SemanticMemory {
133 pub(crate) sqlite: SqliteStore,
134 pub(crate) qdrant: Option<Arc<EmbeddingStore>>,
135 pub(crate) provider: AnyProvider,
136 pub(crate) embed_provider: Option<AnyProvider>,
142 pub(crate) embedding_model: String,
143 pub(crate) vector_weight: f64,
144 pub(crate) keyword_weight: f64,
145 pub(crate) temporal_decay_enabled: bool,
146 pub(crate) temporal_decay_half_life_days: u32,
147 pub(crate) mmr_enabled: bool,
148 pub(crate) mmr_lambda: f32,
149 pub(crate) importance_enabled: bool,
150 pub(crate) importance_weight: f64,
151 pub(crate) tier_boost_semantic: f64,
154 pub token_counter: Arc<TokenCounter>,
155 pub graph_store: Option<Arc<crate::graph::GraphStore>>,
156 pub experience: Option<Arc<crate::graph::experience::ExperienceStore>>,
160 pub reasoning: Option<Arc<crate::reasoning::ReasoningMemory>>,
164 pub(crate) community_detection_failures: Arc<AtomicU64>,
165 pub(crate) graph_extraction_count: Arc<AtomicU64>,
166 pub(crate) graph_extraction_failures: Arc<AtomicU64>,
167 pub(crate) last_qdrant_warn: Arc<AtomicU64>,
168 pub(crate) admission_control: Option<Arc<AdmissionControl>>,
170 pub(crate) quality_gate: Option<Arc<crate::quality_gate::QualityGate>>,
173 pub(crate) key_facts_dedup_threshold: f32,
177 pub(crate) embed_tasks: Mutex<tokio::task::JoinSet<()>>,
183 pub(crate) retrieval_depth: u32,
187 pub(crate) search_prompt_template: String,
192 pub(crate) depth_below_limit_warned: Arc<std::sync::atomic::AtomicBool>,
194 pub(crate) missing_placeholder_warned: Arc<std::sync::atomic::AtomicBool>,
196 pub(crate) query_bias_correction: bool,
198 pub(crate) query_bias_profile_weight: f32,
200 pub(crate) profile_centroid: RwLock<Option<CachedCentroid>>,
205 pub(crate) profile_centroid_ttl_secs: u64,
207 pub(crate) hebbian_enabled: bool,
209 pub(crate) hebbian_lr: f32,
211 pub(crate) hebbian_spread: HelaSpreadRuntime,
213}
214
215impl SemanticMemory {
216 pub async fn new(
227 sqlite_path: &str,
228 qdrant_url: &str,
229 provider: AnyProvider,
230 embedding_model: &str,
231 ) -> Result<Self, MemoryError> {
232 Self::with_weights(sqlite_path, qdrant_url, provider, embedding_model, 0.7, 0.3).await
233 }
234
235 pub async fn with_weights(
244 sqlite_path: &str,
245 qdrant_url: &str,
246 provider: AnyProvider,
247 embedding_model: &str,
248 vector_weight: f64,
249 keyword_weight: f64,
250 ) -> Result<Self, MemoryError> {
251 Self::with_weights_and_pool_size(
252 sqlite_path,
253 qdrant_url,
254 provider,
255 embedding_model,
256 vector_weight,
257 keyword_weight,
258 5,
259 )
260 .await
261 }
262
263 pub async fn with_weights_and_pool_size(
272 sqlite_path: &str,
273 qdrant_url: &str,
274 provider: AnyProvider,
275 embedding_model: &str,
276 vector_weight: f64,
277 keyword_weight: f64,
278 pool_size: u32,
279 ) -> Result<Self, MemoryError> {
280 let sqlite = SqliteStore::with_pool_size(sqlite_path, pool_size).await?;
281 let pool = sqlite.pool().clone();
282
283 let qdrant = match EmbeddingStore::new(qdrant_url, pool) {
284 Ok(store) => Some(Arc::new(store)),
285 Err(e) => {
286 tracing::warn!("Qdrant unavailable, semantic search disabled: {e:#}");
287 None
288 }
289 };
290
291 Ok(Self {
292 sqlite,
293 qdrant,
294 provider,
295 embed_provider: None,
296 embedding_model: embedding_model.into(),
297 vector_weight,
298 keyword_weight,
299 temporal_decay_enabled: false,
300 temporal_decay_half_life_days: 30,
301 mmr_enabled: false,
302 mmr_lambda: 0.7,
303 importance_enabled: false,
304 importance_weight: 0.15,
305 tier_boost_semantic: 1.3,
306 token_counter: Arc::new(TokenCounter::new()),
307 graph_store: None,
308 experience: None,
309 reasoning: None,
310 community_detection_failures: Arc::new(AtomicU64::new(0)),
311 graph_extraction_count: Arc::new(AtomicU64::new(0)),
312 graph_extraction_failures: Arc::new(AtomicU64::new(0)),
313 last_qdrant_warn: Arc::new(AtomicU64::new(0)),
314 admission_control: None,
315 quality_gate: None,
316 key_facts_dedup_threshold: 0.95,
317 embed_tasks: std::sync::Mutex::new(tokio::task::JoinSet::new()),
318 retrieval_depth: 0,
319 search_prompt_template: String::new(),
320 depth_below_limit_warned: Arc::new(std::sync::atomic::AtomicBool::new(false)),
321 missing_placeholder_warned: Arc::new(std::sync::atomic::AtomicBool::new(false)),
322 query_bias_correction: true,
323 query_bias_profile_weight: 0.25,
324 profile_centroid: RwLock::new(None),
325 profile_centroid_ttl_secs: 300,
326 hebbian_enabled: false,
327 hebbian_lr: 0.1,
328 hebbian_spread: HelaSpreadRuntime::default(),
329 })
330 }
331
332 pub async fn with_qdrant_ops(
341 sqlite_path: &str,
342 ops: crate::QdrantOps,
343 provider: AnyProvider,
344 embedding_model: &str,
345 vector_weight: f64,
346 keyword_weight: f64,
347 pool_size: u32,
348 ) -> Result<Self, MemoryError> {
349 let sqlite = SqliteStore::with_pool_size(sqlite_path, pool_size).await?;
350 let pool = sqlite.pool().clone();
351 let store = EmbeddingStore::with_store(Box::new(ops), pool);
352
353 Ok(Self {
354 sqlite,
355 qdrant: Some(Arc::new(store)),
356 provider,
357 embed_provider: None,
358 embedding_model: embedding_model.into(),
359 vector_weight,
360 keyword_weight,
361 temporal_decay_enabled: false,
362 temporal_decay_half_life_days: 30,
363 mmr_enabled: false,
364 mmr_lambda: 0.7,
365 importance_enabled: false,
366 importance_weight: 0.15,
367 tier_boost_semantic: 1.3,
368 token_counter: Arc::new(TokenCounter::new()),
369 graph_store: None,
370 experience: None,
371 reasoning: None,
372 community_detection_failures: Arc::new(AtomicU64::new(0)),
373 graph_extraction_count: Arc::new(AtomicU64::new(0)),
374 graph_extraction_failures: Arc::new(AtomicU64::new(0)),
375 last_qdrant_warn: Arc::new(AtomicU64::new(0)),
376 admission_control: None,
377 quality_gate: None,
378 key_facts_dedup_threshold: 0.95,
379 embed_tasks: std::sync::Mutex::new(tokio::task::JoinSet::new()),
380 retrieval_depth: 0,
381 search_prompt_template: String::new(),
382 depth_below_limit_warned: Arc::new(std::sync::atomic::AtomicBool::new(false)),
383 missing_placeholder_warned: Arc::new(std::sync::atomic::AtomicBool::new(false)),
384 query_bias_correction: true,
385 query_bias_profile_weight: 0.25,
386 profile_centroid: RwLock::new(None),
387 profile_centroid_ttl_secs: 300,
388 hebbian_enabled: false,
389 hebbian_lr: 0.1,
390 hebbian_spread: HelaSpreadRuntime::default(),
391 })
392 }
393
394 #[must_use]
399 pub fn with_graph_store(mut self, store: Arc<crate::graph::GraphStore>) -> Self {
400 self.graph_store = Some(store);
401 self
402 }
403
404 #[must_use]
410 pub fn with_experience_store(
411 mut self,
412 store: Arc<crate::graph::experience::ExperienceStore>,
413 ) -> Self {
414 self.experience = Some(store);
415 self
416 }
417
418 #[must_use]
424 pub fn with_reasoning(mut self, store: Arc<crate::reasoning::ReasoningMemory>) -> Self {
425 self.reasoning = Some(store);
426 self
427 }
428
429 #[must_use]
431 pub fn community_detection_failures(&self) -> u64 {
432 use std::sync::atomic::Ordering;
433 self.community_detection_failures.load(Ordering::Relaxed)
434 }
435
436 #[must_use]
438 pub fn graph_extraction_count(&self) -> u64 {
439 use std::sync::atomic::Ordering;
440 self.graph_extraction_count.load(Ordering::Relaxed)
441 }
442
443 #[must_use]
445 pub fn graph_extraction_failures(&self) -> u64 {
446 use std::sync::atomic::Ordering;
447 self.graph_extraction_failures.load(Ordering::Relaxed)
448 }
449
450 #[must_use]
452 pub fn with_ranking_options(
453 mut self,
454 temporal_decay_enabled: bool,
455 temporal_decay_half_life_days: u32,
456 mmr_enabled: bool,
457 mmr_lambda: f32,
458 ) -> Self {
459 self.temporal_decay_enabled = temporal_decay_enabled;
460 self.temporal_decay_half_life_days = temporal_decay_half_life_days;
461 self.mmr_enabled = mmr_enabled;
462 self.mmr_lambda = mmr_lambda;
463 self
464 }
465
466 #[must_use]
468 pub fn with_importance_options(mut self, enabled: bool, weight: f64) -> Self {
469 self.importance_enabled = enabled;
470 self.importance_weight = weight;
471 self
472 }
473
474 #[must_use]
478 pub fn with_tier_boost(mut self, boost: f64) -> Self {
479 self.tier_boost_semantic = boost;
480 self
481 }
482
483 #[must_use]
488 pub fn with_admission_control(mut self, control: AdmissionControl) -> Self {
489 self.admission_control = Some(Arc::new(control));
490 self
491 }
492
493 #[must_use]
499 pub fn with_quality_gate(mut self, gate: Arc<crate::quality_gate::QualityGate>) -> Self {
500 self.quality_gate = Some(gate);
501 self
502 }
503
504 #[must_use]
509 pub fn with_key_facts_dedup_threshold(mut self, threshold: f32) -> Self {
510 self.key_facts_dedup_threshold = threshold;
511 self
512 }
513
514 #[must_use]
520 pub fn with_query_bias(
521 mut self,
522 enabled: bool,
523 profile_weight: f32,
524 centroid_ttl_secs: u64,
525 ) -> Self {
526 self.query_bias_correction = enabled;
527 self.query_bias_profile_weight = profile_weight.clamp(0.0, 1.0);
528 self.profile_centroid_ttl_secs = centroid_ttl_secs;
529 self
530 }
531
532 #[must_use]
537 pub fn with_hebbian_spread(mut self, runtime: HelaSpreadRuntime) -> Self {
538 self.hebbian_spread = runtime;
539 self
540 }
541
542 #[must_use]
547 pub fn with_hebbian(mut self, enabled: bool, lr: f32) -> Self {
548 let lr = lr.max(0.0);
549 if enabled && lr == 0.0 {
550 tracing::warn!("hebbian enabled with lr=0.0 — no reinforcement will occur");
551 }
552 self.hebbian_enabled = enabled;
553 self.hebbian_lr = lr;
554 self
555 }
556
557 pub(crate) fn classify_query_intent(query: &str) -> QueryIntent {
562 if persona::contains_self_referential_language(query) {
563 QueryIntent::FirstPerson
564 } else {
565 QueryIntent::Other
566 }
567 }
568
569 #[tracing::instrument(name = "memory.query_bias.apply", skip(self, embedding), fields(query_len = query.len()))]
575 pub(crate) async fn apply_query_bias(&self, query: &str, embedding: Vec<f32>) -> Vec<f32> {
576 if !self.query_bias_correction {
577 tracing::debug!(reason = "disabled", "query-bias: skipping");
578 return embedding;
579 }
580 if Self::classify_query_intent(query) != QueryIntent::FirstPerson {
581 tracing::debug!(reason = "not_first_person", "query-bias: skipping");
582 return embedding;
583 }
584 let Some(centroid) = self.profile_centroid_cached().await else {
585 tracing::debug!(reason = "no_centroid", "query-bias: skipping");
586 return embedding;
587 };
588 if centroid.len() != embedding.len() {
589 tracing::warn!(
590 centroid_dim = centroid.len(),
591 query_dim = embedding.len(),
592 reason = "dim_mismatch",
593 "query-bias: dimension mismatch between profile centroid and query embedding — skipping bias"
594 );
595 return embedding;
596 }
597 let w = self.query_bias_profile_weight;
598 tracing::debug!(
599 intent = "first_person",
600 centroid_dim = centroid.len(),
601 weight = w,
602 "query-bias: applying profile bias"
603 );
604 embedding
605 .iter()
606 .zip(centroid.iter())
607 .map(|(&q, &c)| (1.0 - w) * q + w * c)
608 .collect()
609 }
610
611 #[tracing::instrument(name = "memory.query_bias.centroid", skip(self))]
616 pub(crate) async fn profile_centroid_cached(&self) -> Option<Vec<f32>> {
617 {
619 let guard = self.profile_centroid.read().await;
620 if let Some(c) = &*guard
621 && c.computed_at.elapsed().as_secs() < self.profile_centroid_ttl_secs
622 {
623 let ttl_remaining = self
624 .profile_centroid_ttl_secs
625 .saturating_sub(c.computed_at.elapsed().as_secs());
626 tracing::debug!(
627 centroid_dim = c.vector.len(),
628 ttl_remaining_secs = ttl_remaining,
629 "query-bias: centroid cache hit"
630 );
631 return Some(c.vector.clone());
632 }
633 }
634 let computed = self.compute_profile_centroid().await;
636 let mut guard = self.profile_centroid.write().await;
637 match computed {
638 Some(v) => {
639 tracing::debug!(centroid_dim = v.len(), "query-bias: centroid computed");
640 *guard = Some(CachedCentroid {
641 vector: v.clone(),
642 computed_at: Instant::now(),
643 });
644 Some(v)
645 }
646 None => {
647 guard.as_ref().map(|c| c.vector.clone())
649 }
650 }
651 }
652
653 async fn compute_profile_centroid(&self) -> Option<Vec<f32>> {
658 let facts = match self.sqlite.load_persona_facts(0.0).await {
659 Ok(f) => f,
660 Err(e) => {
661 tracing::warn!(error = %e, "query-bias: failed to load persona facts");
662 return None;
663 }
664 };
665 if facts.is_empty() {
666 return None;
667 }
668 let provider = self.effective_embed_provider();
669 let texts: Vec<String> = facts.iter().map(|f| f.content.clone()).collect();
670 let mut embeddings: Vec<Vec<f32>> = Vec::with_capacity(texts.len());
671 for text in &texts {
672 match provider.embed(text).await {
673 Ok(v) => embeddings.push(v),
674 Err(e) => {
675 tracing::warn!(error = %e, "query-bias: failed to embed persona fact — skipping");
676 }
677 }
678 }
679 if embeddings.is_empty() {
680 return None;
681 }
682 let dim = embeddings[0].len();
683 let mut centroid = vec![0.0f32; dim];
684 for emb in &embeddings {
685 if emb.len() != dim {
686 tracing::warn!(
687 expected = dim,
688 got = emb.len(),
689 "query-bias: persona embedding dimension mismatch — skipping fact"
690 );
691 continue;
692 }
693 for (c, &v) in centroid.iter_mut().zip(emb.iter()) {
694 *c += v;
695 }
696 }
697 #[allow(clippy::cast_precision_loss)]
698 let n = embeddings.len() as f32;
699 for c in &mut centroid {
700 *c /= n;
701 }
702 Some(centroid)
703 }
704
705 #[must_use]
713 pub fn with_retrieval_options(
714 mut self,
715 depth: u32,
716 search_prompt_template: impl Into<String>,
717 ) -> Self {
718 self.retrieval_depth = depth;
719 self.search_prompt_template = search_prompt_template.into();
720 self
721 }
722
723 pub(crate) fn effective_depth(&self, limit: usize) -> usize {
732 use std::sync::atomic::Ordering;
733
734 let depth = self.retrieval_depth as usize;
735 if depth == 0 {
736 return limit.saturating_mul(2);
737 }
738 if depth < limit {
739 if !self.depth_below_limit_warned.swap(true, Ordering::Relaxed) {
740 tracing::warn!(
741 retrieval_depth = depth,
742 recall_limit = limit,
743 "memory.retrieval.depth < recall_limit; ANN pool cannot saturate top-k — consider raising depth"
744 );
745 }
746 } else if depth < limit.saturating_mul(2) {
747 tracing::info!(
748 retrieval_depth = depth,
749 recall_limit = limit,
750 legacy_default = limit.saturating_mul(2),
751 "memory.retrieval.depth is below legacy limit*2; ANN pool will be smaller than pre-#3340"
752 );
753 } else {
754 tracing::debug!(
755 retrieval_depth = depth,
756 recall_limit = limit,
757 "recall: using configured ANN depth"
758 );
759 }
760 depth
761 }
762
763 pub(crate) fn apply_search_prompt(&self, query: &str) -> String {
768 use std::sync::atomic::Ordering;
769
770 let template = &self.search_prompt_template;
771 if template.is_empty() {
772 return query.to_owned();
773 }
774 if !template.contains("{query}") {
775 if !self
776 .missing_placeholder_warned
777 .swap(true, Ordering::Relaxed)
778 {
779 tracing::warn!(
780 template = template.as_str(),
781 "memory.retrieval.search_prompt_template has no {{query}} placeholder — \
782 using raw query as-is"
783 );
784 }
785 return query.to_owned();
786 }
787 template.replace("{query}", query)
788 }
789
790 #[must_use]
796 pub fn with_embed_provider(mut self, embed_provider: AnyProvider) -> Self {
797 self.embed_provider = Some(embed_provider);
798 self
799 }
800
801 pub fn effective_embed_provider(&self) -> &AnyProvider {
805 self.embed_provider.as_ref().unwrap_or(&self.provider)
806 }
807
808 #[must_use]
812 pub fn from_parts(
813 sqlite: SqliteStore,
814 qdrant: Option<Arc<EmbeddingStore>>,
815 provider: AnyProvider,
816 embedding_model: impl Into<String>,
817 vector_weight: f64,
818 keyword_weight: f64,
819 token_counter: Arc<TokenCounter>,
820 ) -> Self {
821 Self {
822 sqlite,
823 qdrant,
824 provider,
825 embed_provider: None,
826 embedding_model: embedding_model.into(),
827 vector_weight,
828 keyword_weight,
829 temporal_decay_enabled: false,
830 temporal_decay_half_life_days: 30,
831 mmr_enabled: false,
832 mmr_lambda: 0.7,
833 importance_enabled: false,
834 importance_weight: 0.15,
835 tier_boost_semantic: 1.3,
836 token_counter,
837 graph_store: None,
838 experience: None,
839 reasoning: None,
840 community_detection_failures: Arc::new(AtomicU64::new(0)),
841 graph_extraction_count: Arc::new(AtomicU64::new(0)),
842 graph_extraction_failures: Arc::new(AtomicU64::new(0)),
843 last_qdrant_warn: Arc::new(AtomicU64::new(0)),
844 admission_control: None,
845 quality_gate: None,
846 key_facts_dedup_threshold: 0.95,
847 embed_tasks: std::sync::Mutex::new(tokio::task::JoinSet::new()),
848 retrieval_depth: 0,
849 search_prompt_template: String::new(),
850 depth_below_limit_warned: Arc::new(std::sync::atomic::AtomicBool::new(false)),
851 missing_placeholder_warned: Arc::new(std::sync::atomic::AtomicBool::new(false)),
852 query_bias_correction: true,
853 query_bias_profile_weight: 0.25,
854 profile_centroid: RwLock::new(None),
855 profile_centroid_ttl_secs: 300,
856 hebbian_enabled: false,
857 hebbian_lr: 0.1,
858 hebbian_spread: HelaSpreadRuntime::default(),
859 }
860 }
861
862 pub async fn with_sqlite_backend(
868 sqlite_path: &str,
869 provider: AnyProvider,
870 embedding_model: &str,
871 vector_weight: f64,
872 keyword_weight: f64,
873 ) -> Result<Self, MemoryError> {
874 Self::with_sqlite_backend_and_pool_size(
875 sqlite_path,
876 provider,
877 embedding_model,
878 vector_weight,
879 keyword_weight,
880 5,
881 )
882 .await
883 }
884
885 pub async fn with_sqlite_backend_and_pool_size(
891 sqlite_path: &str,
892 provider: AnyProvider,
893 embedding_model: &str,
894 vector_weight: f64,
895 keyword_weight: f64,
896 pool_size: u32,
897 ) -> Result<Self, MemoryError> {
898 let sqlite = SqliteStore::with_pool_size(sqlite_path, pool_size).await?;
899 let pool = sqlite.pool().clone();
900 let store = EmbeddingStore::new_sqlite(pool);
901
902 Ok(Self {
903 sqlite,
904 qdrant: Some(Arc::new(store)),
905 provider,
906 embed_provider: None,
907 embedding_model: embedding_model.into(),
908 vector_weight,
909 keyword_weight,
910 temporal_decay_enabled: false,
911 temporal_decay_half_life_days: 30,
912 mmr_enabled: false,
913 mmr_lambda: 0.7,
914 importance_enabled: false,
915 importance_weight: 0.15,
916 tier_boost_semantic: 1.3,
917 token_counter: Arc::new(TokenCounter::new()),
918 graph_store: None,
919 experience: None,
920 reasoning: None,
921 community_detection_failures: Arc::new(AtomicU64::new(0)),
922 graph_extraction_count: Arc::new(AtomicU64::new(0)),
923 graph_extraction_failures: Arc::new(AtomicU64::new(0)),
924 last_qdrant_warn: Arc::new(AtomicU64::new(0)),
925 admission_control: None,
926 quality_gate: None,
927 key_facts_dedup_threshold: 0.95,
928 embed_tasks: std::sync::Mutex::new(tokio::task::JoinSet::new()),
929 retrieval_depth: 0,
930 search_prompt_template: String::new(),
931 depth_below_limit_warned: Arc::new(std::sync::atomic::AtomicBool::new(false)),
932 missing_placeholder_warned: Arc::new(std::sync::atomic::AtomicBool::new(false)),
933 query_bias_correction: true,
934 query_bias_profile_weight: 0.25,
935 profile_centroid: RwLock::new(None),
936 profile_centroid_ttl_secs: 300,
937 hebbian_enabled: false,
938 hebbian_lr: 0.1,
939 hebbian_spread: HelaSpreadRuntime::default(),
940 })
941 }
942
943 #[must_use]
945 pub fn sqlite(&self) -> &SqliteStore {
946 &self.sqlite
947 }
948
949 pub async fn is_vector_store_connected(&self) -> bool {
954 match self.qdrant.as_ref() {
955 Some(store) => store.health_check().await,
956 None => false,
957 }
958 }
959
960 #[must_use]
962 pub fn has_vector_store(&self) -> bool {
963 self.qdrant.is_some()
964 }
965
966 #[must_use]
968 pub fn embedding_store(&self) -> Option<&Arc<EmbeddingStore>> {
969 self.qdrant.as_ref()
970 }
971
972 pub fn provider(&self) -> &AnyProvider {
974 &self.provider
975 }
976
977 pub async fn message_count(
983 &self,
984 conversation_id: crate::types::ConversationId,
985 ) -> Result<i64, MemoryError> {
986 self.sqlite.count_messages(conversation_id).await
987 }
988
989 pub async fn unsummarized_message_count(
995 &self,
996 conversation_id: crate::types::ConversationId,
997 ) -> Result<i64, MemoryError> {
998 let after_id = self
999 .sqlite
1000 .latest_summary_last_message_id(conversation_id)
1001 .await?
1002 .unwrap_or(crate::types::MessageId(0));
1003 self.sqlite
1004 .count_messages_after(conversation_id, after_id)
1005 .await
1006 }
1007
1008 pub async fn load_promotion_window(
1023 &self,
1024 max_items: usize,
1025 ) -> Result<Vec<crate::compression::promotion::PromotionInput>, MemoryError> {
1026 use zeph_db::sql;
1027
1028 let limit = i64::try_from(max_items).unwrap_or(i64::MAX);
1029 let rows: Vec<(
1030 crate::types::MessageId,
1031 crate::types::ConversationId,
1032 String,
1033 )> = zeph_db::query_as(sql!(
1034 "SELECT id, conversation_id, content \
1035 FROM messages \
1036 WHERE deleted_at IS NULL \
1037 ORDER BY id DESC \
1038 LIMIT ?"
1039 ))
1040 .bind(limit)
1041 .fetch_all(self.sqlite.pool())
1042 .await?;
1043
1044 Ok(rows
1045 .into_iter()
1046 .map(|(message_id, conversation_id, content)| {
1047 crate::compression::promotion::PromotionInput {
1048 message_id,
1049 conversation_id,
1050 content,
1051 embedding: None,
1053 }
1054 })
1055 .collect())
1056 }
1057
1058 pub async fn retrieve_reasoning_strategies(
1071 &self,
1072 query: &str,
1073 limit: usize,
1074 ) -> Result<Vec<crate::reasoning::ReasoningStrategy>, MemoryError> {
1075 let Some(reasoning) = &self.reasoning else {
1076 return Ok(Vec::new());
1077 };
1078 if !self.effective_embed_provider().supports_embeddings() {
1079 return Ok(Vec::new());
1080 }
1081 let embedding = self.effective_embed_provider().embed(query).await?;
1082 reasoning
1083 .retrieve_by_embedding(&embedding, limit as u64)
1084 .await
1085 }
1086}