1use mentedb_cognitive::stream::StreamAlert;
23use mentedb_cognitive::trajectory::{DecisionState, TrajectoryNode};
24use mentedb_consolidation::FactExtractor;
25use mentedb_context::{DeltaTracker, ScoredMemory};
26use mentedb_core::edge::EdgeType;
27use mentedb_core::memory::MemoryType;
28use mentedb_core::types::{AgentId, MemoryId, Timestamp, UserId};
29use mentedb_core::{MemoryEdge, MemoryNode};
30use tracing::{debug, info, warn};
31use uuid::Uuid;
32
33use crate::MenteDb;
34
35#[derive(Debug, Clone)]
37pub struct ProcessTurnInput {
38 pub user_message: String,
40 pub assistant_response: Option<String>,
42 pub turn_id: u64,
44 pub project_context: Option<String>,
46 pub agent_id: Option<Uuid>,
48 pub user_id: Option<Uuid>,
53 pub session_id: Option<String>,
56}
57
58#[derive(Debug, Clone)]
60pub struct DetectedAction {
61 pub action_type: String,
62 pub detail: String,
63}
64
65#[derive(Debug, Clone)]
67pub struct PainWarning {
68 pub signal_id: MemoryId,
69 pub intensity: f32,
70 pub description: String,
71}
72
73#[derive(Debug, Clone)]
75pub struct ProactiveRecall {
76 pub memory_id: MemoryId,
77 pub content: String,
78 pub relevance: f32,
79 pub action_type: String,
80}
81
82#[derive(Debug, Clone)]
84pub struct ProcessTurnResult {
85 pub context: Vec<ScoredMemory>,
87 pub stored_ids: Vec<MemoryId>,
89 pub episodic_id: Option<MemoryId>,
91 pub pain_warnings: Vec<PainWarning>,
93 pub cache_hit: bool,
95 pub inference_actions: u32,
97 pub detected_actions: Vec<DetectedAction>,
99 pub proactive_recalls: Vec<ProactiveRecall>,
101 pub correction_id: Option<MemoryId>,
103 pub sentiment: f32,
105 pub phantom_count: usize,
107 pub contradiction_count: usize,
109 pub predicted_topics: Vec<String>,
111 pub facts_extracted: usize,
113 pub edges_created: u32,
115 pub enrichment_pending: bool,
117 pub delta_added: Vec<MemoryId>,
119 pub delta_removed: Vec<MemoryId>,
121}
122
123#[derive(Debug, Clone)]
127pub struct RecallContextResult {
128 pub context: Vec<ScoredMemory>,
130 pub pain_warnings: Vec<PainWarning>,
132 pub cache_hit: bool,
134 pub delta_added: Vec<MemoryId>,
136 pub delta_removed: Vec<MemoryId>,
138}
139
140#[derive(Debug, Clone)]
143pub struct IngestTurnResult {
144 pub stored_ids: Vec<MemoryId>,
146 pub episodic_id: Option<MemoryId>,
148 pub inference_actions: u32,
150 pub detected_actions: Vec<DetectedAction>,
152 pub proactive_recalls: Vec<ProactiveRecall>,
154 pub correction_id: Option<MemoryId>,
156 pub sentiment: f32,
158 pub phantom_count: usize,
160 pub contradiction_count: usize,
162 pub predicted_topics: Vec<String>,
164 pub facts_extracted: usize,
166 pub edges_created: u32,
168 pub enrichment_pending: bool,
170}
171
172fn compose_turn_result(recall: RecallContextResult, ingest: IngestTurnResult) -> ProcessTurnResult {
175 ProcessTurnResult {
176 context: recall.context,
177 stored_ids: ingest.stored_ids,
178 episodic_id: ingest.episodic_id,
179 pain_warnings: recall.pain_warnings,
180 cache_hit: recall.cache_hit,
181 inference_actions: ingest.inference_actions,
182 detected_actions: ingest.detected_actions,
183 proactive_recalls: ingest.proactive_recalls,
184 correction_id: ingest.correction_id,
185 sentiment: ingest.sentiment,
186 phantom_count: ingest.phantom_count,
187 contradiction_count: ingest.contradiction_count,
188 predicted_topics: ingest.predicted_topics,
189 facts_extracted: ingest.facts_extracted,
190 edges_created: ingest.edges_created,
191 enrichment_pending: ingest.enrichment_pending,
192 delta_added: recall.delta_added,
193 delta_removed: recall.delta_removed,
194 }
195}
196
197const ACTION_KEYWORDS: &[(&str, &str)] = &[
200 ("deploy", "deployment"),
201 ("release", "release"),
202 ("migrate", "migration"),
203 ("refactor", "refactoring"),
204 ("debug", "debugging"),
205 ("install", "installation"),
206 ("configure", "configuration"),
207 ("test", "testing"),
208 ("build", "building"),
209 ("commit", "version_control"),
210 ("merge", "version_control"),
211 ("review", "code_review"),
212];
213
214const CORRECTION_INDICATORS: &[&str] = &[
217 "actually",
218 "correction",
219 "i was wrong",
220 "that's not right",
221 "let me correct",
222 "i meant",
223 "not quite",
224 "to clarify",
225 "i misspoke",
226 "update:",
227 "scratch that",
228];
229
230const SPECULATION_INDICATORS: &[&str] = &[
233 "might be",
234 "probably",
235 "seems like",
236 "i think",
237 "looks like",
238 "considering",
239 "planning to",
240 "thinking about",
241 "maybe",
242];
243
244const POSITIVE_WORDS: &[&str] = &[
247 "great",
248 "excellent",
249 "perfect",
250 "thanks",
251 "awesome",
252 "love",
253 "good",
254 "nice",
255 "wonderful",
256 "fantastic",
257 "helpful",
258 "amazing",
259 "brilliant",
260 "works",
261 "solved",
262];
263
264const NEGATIVE_WORDS: &[&str] = &[
265 "bad",
266 "terrible",
267 "awful",
268 "hate",
269 "wrong",
270 "broken",
271 "fail",
272 "error",
273 "bug",
274 "frustrating",
275 "annoying",
276 "useless",
277 "horrible",
278 "worst",
279 "disappointed",
280];
281
282impl MenteDb {
283 pub fn process_turn(
300 &self,
301 input: &ProcessTurnInput,
302 delta_tracker: &mut DeltaTracker,
303 ) -> crate::MenteResult<ProcessTurnResult> {
304 let recall = self.recall_turn_context(input, delta_tracker)?;
305 let ingest = self.ingest_turn(input, &recall.context)?;
306 Ok(compose_turn_result(recall, ingest))
307 }
308
309 pub fn recall_turn_context(
316 &self,
317 input: &ProcessTurnInput,
318 delta_tracker: &mut DeltaTracker,
319 ) -> crate::MenteResult<RecallContextResult> {
320 let query_embedding = self.embed_or_empty(&input.user_message)?;
322
323 let (context, current_ids, cache_hit) = self.retrieve_context(
325 &input.user_message,
326 &query_embedding,
327 input.agent_id.map(AgentId),
328 input.user_id.map(UserId),
329 delta_tracker,
330 )?;
331
332 let pain_warnings = self.check_pain_signals(&input.user_message);
334
335 let delta = delta_tracker.compute_delta(¤t_ids, &delta_tracker.last_served.clone());
337 delta_tracker.update(¤t_ids);
338
339 Ok(RecallContextResult {
340 context,
341 pain_warnings,
342 cache_hit,
343 delta_added: delta.added,
344 delta_removed: delta.removed,
345 })
346 }
347
348 pub fn ingest_turn(
355 &self,
356 input: &ProcessTurnInput,
357 context: &[ScoredMemory],
358 ) -> crate::MenteResult<IngestTurnResult> {
359 let agent_id = AgentId(input.agent_id.unwrap_or(Uuid::nil()));
360 let user_id = UserId(input.user_id.unwrap_or(Uuid::nil()));
361 let assistant_resp = input.assistant_response.as_deref().unwrap_or("");
362 let conversation = format!(
363 "User: {}\nAssistant: {}",
364 input.user_message, assistant_resp
365 );
366
367 let (stored_ids, episodic_id) = self.store_episodic(
369 &conversation,
370 agent_id,
371 user_id,
372 &input.project_context,
373 &input.session_id,
374 )?;
375
376 let scope_agent = input.agent_id.map(AgentId);
381 let scope_user = input.user_id.map(UserId);
382
383 let inference_actions = if let Some(eid) = episodic_id {
385 self.run_explicit_write_inference(eid, scope_agent, scope_user)?
386 } else {
387 0
388 };
389
390 let (facts_extracted, edges_created) = if let Some(eid) = episodic_id {
392 self.extract_and_link_facts(eid, scope_agent, scope_user)?
393 } else {
394 (0, 0)
395 };
396
397 let combined_text = format!("{} {}", input.user_message, assistant_resp);
399 let detected_actions = detect_actions(&combined_text);
400
401 let proactive_recalls =
403 self.proactive_recall(&detected_actions, scope_agent, scope_user)?;
404
405 let correction_id = self.auto_detect_correction(&input.user_message, episodic_id)?;
407
408 let sentiment = analyze_sentiment(&input.user_message);
410
411 let (phantom_count, contradiction_count) = self.detect_phantoms_and_check_stream(
413 &conversation,
414 assistant_resp,
415 context,
416 input.turn_id,
417 );
418
419 let predicted_topics = self.update_trajectory(
421 input,
422 agent_id,
423 user_id,
424 &stored_ids,
425 &detected_actions,
426 &combined_text,
427 )?;
428
429 self.update_speculative_cache_from_predictions(&predicted_topics);
431
432 self.maybe_run_maintenance(input.turn_id);
434
435 Ok(IngestTurnResult {
436 stored_ids,
437 episodic_id,
438 inference_actions,
439 detected_actions,
440 proactive_recalls,
441 correction_id,
442 sentiment,
443 phantom_count,
444 contradiction_count,
445 predicted_topics,
446 facts_extracted,
447 edges_created,
448 enrichment_pending: self.needs_enrichment(),
449 })
450 }
451
452 fn retrieve_context(
455 &self,
456 user_message: &str,
457 query_embedding: &[f32],
458 agent: Option<AgentId>,
459 user: Option<UserId>,
460 _delta_tracker: &DeltaTracker,
461 ) -> crate::MenteResult<(Vec<ScoredMemory>, Vec<MemoryId>, bool)> {
462 if let Some(entry) = self.try_speculative_hit(user_message, Some(query_embedding)) {
464 let matched: Vec<ScoredMemory> = entry
465 .memory_ids
466 .iter()
467 .filter_map(|id| self.get_memory(*id).ok())
468 .filter(|m| {
469 crate::agent_visible(m.agent_id, agent) && crate::user_visible(m.user_id, user)
470 })
471 .map(|m| ScoredMemory {
472 memory: m,
473 score: 0.9,
474 })
475 .collect();
476 if matched.len() >= entry.memory_ids.len() / 2 {
477 let ids: Vec<MemoryId> = matched.iter().map(|sm| sm.memory.id).collect();
478 return Ok((matched, ids, true));
479 }
480 }
481
482 let now = now_us();
484 let results = self.recall_hybrid_scoped_at_mode(
485 query_embedding,
486 Some(user_message),
487 10,
488 now,
489 None,
490 false,
491 None,
492 agent,
493 user,
494 None,
495 )?;
496 let mut scored: Vec<ScoredMemory> = results
497 .iter()
498 .filter_map(|(mid, score)| {
499 self.get_memory(*mid).ok().map(|m| ScoredMemory {
500 memory: m,
501 score: *score,
502 })
503 })
504 .collect();
505
506 let hybrid_ids: std::collections::HashSet<MemoryId> =
512 scored.iter().map(|sm| sm.memory.id).collect();
513 for mid in self.index.bitmap.query_tag("scope:always") {
514 if hybrid_ids.contains(&mid) {
515 continue;
516 }
517 if let Ok(mem) = self.get_memory(mid)
518 && crate::agent_visible(mem.agent_id, agent)
519 && crate::user_visible(mem.user_id, user)
520 {
521 scored.push(ScoredMemory {
522 memory: mem,
523 score: 0.85,
524 });
525 }
526 }
527
528 let ids: Vec<MemoryId> = scored.iter().map(|sm| sm.memory.id).collect();
529 Ok((scored, ids, false))
530 }
531
532 fn check_pain_signals(&self, user_message: &str) -> Vec<PainWarning> {
533 let context_words: Vec<String> = user_message
534 .split_whitespace()
535 .map(|w| w.to_lowercase())
536 .collect();
537 let all_signals = self.all_pain_signals();
538 let mut scored: Vec<_> = all_signals
539 .iter()
540 .filter_map(|s| {
541 let matched = s
542 .trigger_keywords
543 .iter()
544 .filter(|trigger| {
545 context_words
546 .iter()
547 .any(|ctx| ctx.contains(&trigger.to_lowercase()))
548 })
549 .count();
550 if matched > 0 {
551 let relevance = matched as f32 / s.trigger_keywords.len().max(1) as f32;
552 let score = s.intensity * relevance;
553 Some((s, score))
554 } else {
555 None
556 }
557 })
558 .collect();
559 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
560 scored
561 .iter()
562 .map(|(s, _)| PainWarning {
563 signal_id: s.id,
564 intensity: s.intensity,
565 description: s.description.clone(),
566 })
567 .collect()
568 }
569
570 fn embed_or_empty(&self, text: &str) -> crate::MenteResult<Vec<f32>> {
577 match self.embed_text(text)? {
578 Some(embedding) => Ok(embedding),
579 None => {
580 static NO_EMBEDDER_WARN: std::sync::Once = std::sync::Once::new();
581 NO_EMBEDDER_WARN.call_once(|| {
582 warn!(
583 "no embedding provider configured: semantic search, auto-linking, and \
584 contradiction detection are DISABLED; retrieval falls back to keyword \
585 (BM25) matching only. Configure a provider via open_with_embedder()."
586 );
587 });
588 Ok(Vec::new())
589 }
590 }
591 }
592
593 fn store_episodic(
594 &self,
595 conversation: &str,
596 agent_id: AgentId,
597 user_id: UserId,
598 project_context: &Option<String>,
599 session_id: &Option<String>,
600 ) -> crate::MenteResult<(Vec<MemoryId>, Option<MemoryId>)> {
601 let embedding = self.embed_or_empty(conversation)?;
602 let mut node = MemoryNode::new(
603 agent_id,
604 MemoryType::Episodic,
605 conversation.to_string(),
606 embedding,
607 )
608 .with_user_id(user_id);
609 node.tags.push("turn".to_string());
610 if let Some(ctx) = project_context {
611 node.tags.push(format!("scope:project:{}", ctx));
612 }
613 if let Some(session) = session_id {
614 node.tags.push(format!("session:{}", session));
615 }
616 let id = node.id;
617 self.store(node)?;
618 Ok((vec![id], Some(id)))
619 }
620
621 fn run_explicit_write_inference(
622 &self,
623 id: MemoryId,
624 agent: Option<AgentId>,
625 user: Option<UserId>,
626 ) -> crate::MenteResult<u32> {
627 let Ok(target) = self.get_memory(id) else {
632 return Ok(0);
633 };
634 let similar_ids = self
638 .recall_hybrid_scoped_at_mode(
639 &target.embedding,
640 None,
641 50,
642 now_us(),
643 None,
644 false,
645 None,
646 agent,
647 user,
648 None,
649 )
650 .unwrap_or_default();
651 let existing: Vec<MemoryNode> = similar_ids
652 .iter()
653 .filter_map(|(mid, _)| {
654 if *mid != id {
655 self.get_memory(*mid).ok()
656 } else {
657 None
658 }
659 })
660 .collect();
661
662 let engine = crate::WriteInferenceEngine::new();
663 let actions = engine.infer_on_write(&target, &existing, &[]);
664 let now = now_us();
665 let mut applied = 0u32;
666
667 for action in &actions {
668 match action {
669 crate::InferredAction::FlagContradiction {
670 existing: ex, new, ..
671 } => {
672 let edge = MemoryEdge {
673 source: *new,
674 target: *ex,
675 edge_type: EdgeType::Contradicts,
676 weight: 1.0,
677 created_at: now,
678 valid_from: None,
679 valid_until: None,
680 label: None,
681 };
682 if let Err(e) = self.relate(edge) {
683 tracing::warn!("failed to create inferred edge: {e}");
684 }
685 applied += 1;
686 }
687 crate::InferredAction::MarkObsolete {
688 memory,
689 superseded_by,
690 } => {
691 let edge = MemoryEdge {
692 source: *superseded_by,
693 target: *memory,
694 edge_type: EdgeType::Supersedes,
695 weight: 1.0,
696 created_at: now,
697 valid_from: None,
698 valid_until: None,
699 label: None,
700 };
701 if let Err(e) = self.relate(edge) {
702 tracing::warn!("failed to create inferred edge: {e}");
703 }
704 applied += 1;
705 }
706 crate::InferredAction::CreateEdge {
707 source,
708 target,
709 edge_type,
710 weight,
711 } => {
712 let edge = MemoryEdge {
713 source: *source,
714 target: *target,
715 edge_type: *edge_type,
716 weight: *weight,
717 created_at: now,
718 valid_from: None,
719 valid_until: None,
720 label: None,
721 };
722 if let Err(e) = self.relate(edge) {
723 tracing::warn!("failed to create inferred edge: {e}");
724 }
725 applied += 1;
726 }
727 crate::InferredAction::UpdateConfidence {
728 memory,
729 new_confidence,
730 } => {
731 if let Ok(mut mem) = self.get_memory(*memory) {
732 mem.confidence = *new_confidence;
733 let _ = self.store(mem);
734 applied += 1;
735 }
736 }
737 crate::InferredAction::InvalidateMemory {
738 memory,
739 valid_until,
740 ..
741 } => {
742 if let Ok(mut mem) = self.get_memory(*memory) {
743 mem.valid_until = Some(*valid_until);
744 let _ = self.store(mem);
745 applied += 1;
746 }
747 }
748 crate::InferredAction::UpdateContent {
749 memory,
750 new_content,
751 ..
752 } => {
753 if let Ok(mut mem) = self.get_memory(*memory) {
754 mem.content = new_content.clone();
755 let _ = self.store(mem);
756 applied += 1;
757 }
758 }
759 crate::InferredAction::PropagateBeliefChange { .. } => {
760 applied += 1;
761 }
762 crate::InferredAction::DeduplicateExact { duplicate, keeper } => {
763 if let Ok(mut mem) = self.get_memory(*duplicate) {
767 mem.valid_until = Some(now);
768 let _ = self.store(mem);
769 }
770 let edge = MemoryEdge {
771 source: *keeper,
772 target: *duplicate,
773 edge_type: EdgeType::Derived,
774 weight: 1.0,
775 created_at: now,
776 valid_from: None,
777 valid_until: None,
778 label: None,
779 };
780 if let Err(e) = self.relate(edge) {
781 tracing::warn!("failed to create dedup edge: {e}");
782 }
783 applied += 1;
784 }
785 }
786 }
787 Ok(applied)
788 }
789
790 fn extract_and_link_facts(
791 &self,
792 id: MemoryId,
793 agent: Option<AgentId>,
794 user: Option<UserId>,
795 ) -> crate::MenteResult<(usize, u32)> {
796 let Ok(target) = self.get_memory(id) else {
800 return Ok((0, 0));
801 };
802 let extractor = FactExtractor::new();
803 let facts = extractor.extract_facts(&target);
804 if facts.is_empty() {
805 return Ok((0, 0));
806 }
807
808 let similar_ids = self
812 .recall_hybrid_scoped_at_mode(
813 &target.embedding,
814 None,
815 50,
816 now_us(),
817 None,
818 false,
819 None,
820 agent,
821 user,
822 None,
823 )
824 .unwrap_or_default();
825 let nearby: Vec<MemoryNode> = similar_ids
826 .iter()
827 .filter_map(|(mid, _)| {
828 if *mid != id {
829 self.get_memory(*mid).ok()
830 } else {
831 None
832 }
833 })
834 .collect();
835
836 let now = now_us();
837 let mut edges_created = 0u32;
838 for fact in &facts {
839 for other in &nearby {
840 if other.content.contains(&fact.subject) || other.content.contains(&fact.object) {
841 let edge = MemoryEdge {
842 source: id,
843 target: other.id,
844 edge_type: EdgeType::Related,
845 weight: 0.5,
846 created_at: now,
847 valid_from: None,
848 valid_until: None,
849 label: None,
850 };
851 if let Err(e) = self.relate(edge) {
852 tracing::warn!("failed to create inferred edge: {e}");
853 }
854 edges_created += 1;
855 }
856 }
857 }
858 Ok((facts.len(), edges_created))
859 }
860
861 fn proactive_recall(
862 &self,
863 actions: &[DetectedAction],
864 agent: Option<AgentId>,
865 user: Option<UserId>,
866 ) -> crate::MenteResult<Vec<ProactiveRecall>> {
867 let mut recalls = Vec::new();
868 for action in actions {
869 let search_query = format!("{} {}", action.action_type, action.detail);
870 let Some(emb) = self.embed_text(&search_query)? else {
871 continue;
872 };
873 let results = self
877 .recall_hybrid_scoped_at_mode(
878 &emb,
879 None,
880 3,
881 now_us(),
882 None,
883 false,
884 None,
885 agent,
886 user,
887 None,
888 )
889 .unwrap_or_default();
890 for (mid, score) in &results {
891 if let Ok(mem) = self.get_memory(*mid) {
892 recalls.push(ProactiveRecall {
893 memory_id: *mid,
894 content: mem.content.clone(),
895 relevance: *score,
896 action_type: action.action_type.clone(),
897 });
898 }
899 }
900 }
901 Ok(recalls)
902 }
903
904 fn auto_detect_correction(
914 &self,
915 user_message: &str,
916 episodic_id: Option<MemoryId>,
917 ) -> crate::MenteResult<Option<MemoryId>> {
918 let lowered = user_message.to_lowercase();
919 let is_correction = CORRECTION_INDICATORS
920 .iter()
921 .any(|ind| lowered.contains(ind));
922 if !is_correction {
923 return Ok(None);
924 }
925 let Some(eid) = episodic_id else {
926 return Ok(None);
927 };
928 let pid = {
929 let pm = self.page_map.read();
930 match pm.get(&eid) {
931 Some(p) => *p,
932 None => return Ok(None),
933 }
934 };
935 let Ok(mut node) = self.storage.load_memory(pid) else {
936 return Ok(None);
937 };
938 if !node.tags.iter().any(|t| t == "auto-correction") {
939 node.tags.push("auto-correction".to_string());
940 self.update_node(pid, &node)?;
941 }
942 Ok(Some(eid))
943 }
944
945 fn detect_phantoms_and_check_stream(
946 &self,
947 conversation: &str,
948 assistant_resp: &str,
949 context: &[ScoredMemory],
950 turn_id: u64,
951 ) -> (usize, usize) {
952 let known: Vec<String> = context
954 .iter()
955 .flat_map(|sm| {
956 sm.memory
957 .content
958 .split_whitespace()
959 .map(|w| w.to_lowercase())
960 })
961 .collect();
962 let phantoms = self.detect_phantoms(conversation, &known, turn_id);
963 let phantom_count = phantoms.len();
964
965 let known_facts: Vec<(MemoryId, String)> = context
967 .iter()
968 .map(|sm| (sm.memory.id, sm.memory.content.clone()))
969 .collect();
970 let contradiction_count = if !known_facts.is_empty() {
971 self.feed_stream_token(assistant_resp);
972 let alerts = self.check_stream_alerts(&known_facts);
973 alerts
974 .iter()
975 .filter(|a| matches!(a, StreamAlert::Contradiction { .. }))
976 .count()
977 } else {
978 0
979 };
980
981 (phantom_count, contradiction_count)
982 }
983
984 fn update_trajectory(
985 &self,
986 input: &ProcessTurnInput,
987 agent_id: AgentId,
988 user_id: UserId,
989 stored_ids: &[MemoryId],
990 detected_actions: &[DetectedAction],
991 combined_text: &str,
992 ) -> crate::MenteResult<Vec<String>> {
993 let decision_state = if stored_ids.is_empty() {
994 DecisionState::Investigating
995 } else {
996 DecisionState::Completed
997 };
998
999 let raw_topic = if input.user_message.chars().count() > 100 {
1000 format!(
1001 "{}...",
1002 input.user_message.chars().take(100).collect::<String>()
1003 )
1004 } else {
1005 input.user_message.clone()
1006 };
1007
1008 let topic_embedding = self.embed_or_empty(&raw_topic)?;
1009
1010 let node = TrajectoryNode {
1011 turn_id: input.turn_id,
1012 topic_summary: raw_topic,
1013 topic_embedding,
1014 decision_state,
1015 open_questions: Vec::new(),
1016 timestamp: now_us(),
1017 };
1018 self.record_trajectory_turn(node);
1019 let predictions = self.predict_next_topics();
1020
1021 let has_speculation = SPECULATION_INDICATORS
1023 .iter()
1024 .any(|s| combined_text.contains(s));
1025 if has_speculation && !detected_actions.is_empty() {
1026 let ghost_content = format!(
1027 "Unconfirmed: {}",
1028 input.user_message.chars().take(300).collect::<String>()
1029 );
1030 if let Ok(Some(ghost_emb)) = self.embed_text(&ghost_content) {
1031 let mut ghost_node =
1032 MemoryNode::new(agent_id, MemoryType::Semantic, ghost_content, ghost_emb)
1033 .with_user_id(user_id);
1034 ghost_node.confidence = 0.3;
1035 ghost_node.tags = vec!["ghost-memory".to_string(), "unconfirmed".to_string()];
1036 if let Some(ctx) = &input.project_context {
1037 ghost_node.tags.push(format!("scope:project:{}", ctx));
1038 }
1039 let _ = self.store(ghost_node);
1040 }
1041 }
1042
1043 Ok(predictions)
1044 }
1045
1046 fn update_speculative_cache_from_predictions(&self, predictions: &[String]) {
1047 if predictions.is_empty() {
1048 debug!("speculative cache: no predictions this turn");
1049 return;
1050 }
1051 #[derive(Default)]
1055 struct Outcome {
1056 built: usize,
1057 no_embedder: usize,
1058 embed_failed: usize,
1059 recall_failed: usize,
1060 nothing_similar: usize,
1061 unloadable: usize,
1062 }
1063 let outcome = std::cell::RefCell::new(Outcome::default());
1064 let predictions_owned = predictions.to_vec();
1065 self.pre_assemble_speculative(predictions_owned, |topic| {
1066 let topic_emb = match self.embed_text(topic) {
1067 Ok(Some(e)) => e,
1068 Ok(None) => {
1069 outcome.borrow_mut().no_embedder += 1;
1070 return None;
1071 }
1072 Err(e) => {
1073 warn!(error = %e, "speculative cache: topic embed failed");
1074 outcome.borrow_mut().embed_failed += 1;
1075 return None;
1076 }
1077 };
1078 let similar_ids = match self.recall_similar(&topic_emb, 5) {
1079 Ok(ids) => ids,
1080 Err(e) => {
1081 warn!(error = %e, "speculative cache: recall failed");
1082 outcome.borrow_mut().recall_failed += 1;
1083 return None;
1084 }
1085 };
1086 if similar_ids.is_empty() {
1087 outcome.borrow_mut().nothing_similar += 1;
1088 return None;
1089 }
1090 let mut context_parts = Vec::new();
1091 let mut memory_ids = Vec::new();
1092 for (mid, _) in &similar_ids {
1093 if let Ok(mem) = self.get_memory(*mid) {
1094 context_parts.push(mem.content.clone());
1095 memory_ids.push(*mid);
1096 }
1097 }
1098 if memory_ids.is_empty() {
1099 outcome.borrow_mut().unloadable += 1;
1100 return None;
1101 }
1102 outcome.borrow_mut().built += 1;
1103 Some((context_parts.join("\n---\n"), memory_ids, Some(topic_emb)))
1106 });
1107 let o = outcome.into_inner();
1108 info!(
1109 predictions = predictions.len(),
1110 built = o.built,
1111 no_embedder = o.no_embedder,
1112 embed_failed = o.embed_failed,
1113 recall_failed = o.recall_failed,
1114 nothing_similar = o.nothing_similar,
1115 unloadable = o.unloadable,
1116 enabled = self.cognitive_config.speculative_cache,
1117 cache_size = self.speculative_cache_stats().cache_size,
1118 "speculative cache pre-assembly"
1119 );
1120 }
1121
1122 fn maybe_run_maintenance(&self, turn_id: u64) {
1123 if turn_id == 0 {
1124 return;
1125 }
1126
1127 if turn_id.is_multiple_of(50) {
1129 match self.apply_decay_global() {
1130 Ok(updated) => {
1131 debug!(turn_id, updated, "auto-maintenance: decay applied");
1132 }
1133 Err(e) => {
1134 warn!(turn_id, error = %e, "auto-maintenance: decay failed");
1135 }
1136 }
1137 }
1138
1139 if turn_id.is_multiple_of(100) {
1141 match self.evaluate_archival_global() {
1142 Ok(decisions) => {
1143 let mut archived = 0u64;
1144 for (id, decision) in &decisions {
1145 if matches!(
1146 decision,
1147 crate::ArchivalDecision::Delete | crate::ArchivalDecision::Archive
1148 ) {
1149 let _ = self.forget(*id);
1150 archived += 1;
1151 }
1152 }
1153 debug!(turn_id, archived, "auto-maintenance: archival evaluated");
1154 }
1155 Err(e) => {
1156 warn!(turn_id, error = %e, "auto-maintenance: archival failed");
1157 }
1158 }
1159 }
1160
1161 if turn_id.is_multiple_of(200) {
1163 match self.find_consolidation_candidates(2, 0.85) {
1164 Ok(candidates) => {
1165 for candidate in &candidates {
1166 let _ = self.consolidate_cluster(&candidate.memories);
1167 }
1168 debug!(
1169 turn_id,
1170 clusters = candidates.len(),
1171 "auto-maintenance: consolidation"
1172 );
1173 }
1174 Err(e) => {
1175 warn!(turn_id, error = %e, "auto-maintenance: consolidation failed");
1176 }
1177 }
1178 }
1179
1180 let enrichment = &self.cognitive_config.enrichment_config;
1182 if enrichment.enabled && enrichment.trigger_interval > 0 {
1183 let last = self.last_enrichment_turn();
1184 let turns_since = turn_id.saturating_sub(last);
1185 if turns_since >= enrichment.trigger_interval && !self.needs_enrichment() {
1186 *self.enrichment_pending.write() = true;
1187 debug!(
1188 turn_id,
1189 last_enrichment = last,
1190 turns_since,
1191 "enrichment trigger: marked pending"
1192 );
1193 }
1194 }
1195 }
1196}
1197
1198fn detect_actions(combined_text: &str) -> Vec<DetectedAction> {
1201 let lower = combined_text.to_lowercase();
1202 ACTION_KEYWORDS
1203 .iter()
1204 .filter(|(kw, _)| lower.contains(kw))
1205 .map(|(kw, action_type)| {
1206 let detail = extract_sentence_containing(&lower, kw);
1207 DetectedAction {
1208 action_type: action_type.to_string(),
1209 detail,
1210 }
1211 })
1212 .collect()
1213}
1214
1215fn analyze_sentiment(text: &str) -> f32 {
1216 let lower = text.to_lowercase();
1217 let words: Vec<&str> = lower.split_whitespace().collect();
1218 let total = words.len().max(1) as f32;
1219
1220 let positive = words
1221 .iter()
1222 .filter(|w| POSITIVE_WORDS.iter().any(|p| w.contains(p)))
1223 .count() as f32;
1224 let negative = words
1225 .iter()
1226 .filter(|w| NEGATIVE_WORDS.iter().any(|n| w.contains(n)))
1227 .count() as f32;
1228
1229 ((positive - negative) / total).clamp(-1.0, 1.0)
1230}
1231
1232fn extract_sentence_containing(text: &str, keyword: &str) -> String {
1233 text.split('.')
1234 .find(|s| s.contains(keyword))
1235 .unwrap_or(keyword)
1236 .trim()
1237 .chars()
1238 .take(200)
1239 .collect()
1240}
1241
1242fn now_us() -> Timestamp {
1243 std::time::SystemTime::now()
1244 .duration_since(std::time::UNIX_EPOCH)
1245 .unwrap_or_default()
1246 .as_micros() as u64
1247}