Skip to main content

mentedb/
process_turn.rs

1//! Unified `process_turn` orchestration.
2//!
3//! This module provides a single `MenteDb::process_turn()` method that
4//! implements the full conversation-turn pipeline used by MCP servers,
5//! cloud APIs, and any other client. The steps are:
6//!
7//! 1. Embed query + retrieve context (speculative cache → hybrid search)
8//! 2. Check pain signals
9//! 3. Store episodic memory (with write inference)
10//! 4. Detect actions, proactive recall, corrections, sentiment
11//! 5. Detect phantoms + stream contradiction check
12//! 6. Update trajectory + ghost memories
13//! 7. Update speculative cache
14//! 8. Extract facts + link edges
15//! 9. Auto-maintenance (decay / archival / consolidation on intervals)
16//!
17//! LLM-powered features (entity resolution, topic canonicalization,
18//! contradiction verification, memory extraction) are **not** included
19//! here — they are client-specific and should be layered on top of the
20//! `ProcessTurnResult` by the caller.
21
22use 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/// Input for a single conversation turn.
36#[derive(Debug, Clone)]
37pub struct ProcessTurnInput {
38    /// The user's message text.
39    pub user_message: String,
40    /// The assistant's response (if available).
41    pub assistant_response: Option<String>,
42    /// Monotonically increasing turn number.
43    pub turn_id: u64,
44    /// Optional project/workspace scope for memory tagging.
45    pub project_context: Option<String>,
46    /// Agent UUID (defaults to nil if not provided).
47    pub agent_id: Option<Uuid>,
48    /// End-user UUID, orthogonal to `agent_id` (defaults to nil if not
49    /// provided). The stored turn is tagged with both owners, and internal
50    /// recalls are scoped to this user AND the agent so another user's private
51    /// memories can never surface or be linked.
52    pub user_id: Option<Uuid>,
53    /// Originating session, tagged onto stored turns so injection recall
54    /// can exclude memories already present in that session's context.
55    pub session_id: Option<String>,
56}
57
58/// A detected action from the conversation.
59#[derive(Debug, Clone)]
60pub struct DetectedAction {
61    pub action_type: String,
62    pub detail: String,
63}
64
65/// A pain warning matched against the current context.
66#[derive(Debug, Clone)]
67pub struct PainWarning {
68    pub signal_id: MemoryId,
69    pub intensity: f32,
70    pub description: String,
71}
72
73/// A proactively recalled memory relevant to detected actions.
74#[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/// Result of processing a conversation turn.
83#[derive(Debug, Clone)]
84pub struct ProcessTurnResult {
85    /// Retrieved context memories (scored and ranked).
86    pub context: Vec<ScoredMemory>,
87    /// IDs of newly stored memories this turn.
88    pub stored_ids: Vec<MemoryId>,
89    /// The episodic memory ID for this turn.
90    pub episodic_id: Option<MemoryId>,
91    /// Pain warnings triggered by the user message.
92    pub pain_warnings: Vec<PainWarning>,
93    /// Whether context came from the speculative cache.
94    pub cache_hit: bool,
95    /// Number of write-inference actions applied.
96    pub inference_actions: u32,
97    /// Detected actions in the conversation.
98    pub detected_actions: Vec<DetectedAction>,
99    /// Proactively recalled memories.
100    pub proactive_recalls: Vec<ProactiveRecall>,
101    /// Auto-correction memory ID if a correction was detected.
102    pub correction_id: Option<MemoryId>,
103    /// Sentiment score (-1.0 to 1.0).
104    pub sentiment: f32,
105    /// Number of phantom (knowledge gap) entities detected.
106    pub phantom_count: usize,
107    /// Number of stream contradictions detected.
108    pub contradiction_count: usize,
109    /// Predicted next topics from trajectory analysis.
110    pub predicted_topics: Vec<String>,
111    /// Number of facts extracted and linked.
112    pub facts_extracted: usize,
113    /// Number of edges created from fact extraction.
114    pub edges_created: u32,
115    /// Whether enrichment is pending (caller should invoke enrichment pipeline).
116    pub enrichment_pending: bool,
117    /// Delta: memory IDs added since last turn.
118    pub delta_added: Vec<MemoryId>,
119    /// Delta: memory IDs removed since last turn.
120    pub delta_removed: Vec<MemoryId>,
121}
122
123/// The read half of a turn: what the caller needs to build its next prompt.
124/// Produced by [`MenteDb::recall_turn_context`]; feed `context` to
125/// [`MenteDb::ingest_turn`] so analysis sees what the caller saw.
126#[derive(Debug, Clone)]
127pub struct RecallContextResult {
128    /// Retrieved context memories (scored and ranked).
129    pub context: Vec<ScoredMemory>,
130    /// Pain warnings triggered by the user message.
131    pub pain_warnings: Vec<PainWarning>,
132    /// Whether context came from the speculative cache.
133    pub cache_hit: bool,
134    /// Delta: memory IDs added since last turn.
135    pub delta_added: Vec<MemoryId>,
136    /// Delta: memory IDs removed since last turn.
137    pub delta_removed: Vec<MemoryId>,
138}
139
140/// The write half of a turn: everything [`MenteDb::ingest_turn`] produced
141/// after the caller's response no longer depends on it.
142#[derive(Debug, Clone)]
143pub struct IngestTurnResult {
144    /// IDs of newly stored memories this turn.
145    pub stored_ids: Vec<MemoryId>,
146    /// The episodic memory ID for this turn.
147    pub episodic_id: Option<MemoryId>,
148    /// Number of write-inference actions applied.
149    pub inference_actions: u32,
150    /// Detected actions in the conversation.
151    pub detected_actions: Vec<DetectedAction>,
152    /// Proactively recalled memories.
153    pub proactive_recalls: Vec<ProactiveRecall>,
154    /// Auto-correction memory ID if a correction was detected.
155    pub correction_id: Option<MemoryId>,
156    /// Sentiment score (-1.0 to 1.0).
157    pub sentiment: f32,
158    /// Number of phantom (knowledge gap) entities detected.
159    pub phantom_count: usize,
160    /// Number of stream contradictions detected.
161    pub contradiction_count: usize,
162    /// Predicted next topics from trajectory analysis.
163    pub predicted_topics: Vec<String>,
164    /// Number of facts extracted and linked.
165    pub facts_extracted: usize,
166    /// Number of edges created from fact extraction.
167    pub edges_created: u32,
168    /// Whether enrichment is pending (caller should invoke enrichment pipeline).
169    pub enrichment_pending: bool,
170}
171
172/// Recompose the split halves into the classic single-call result, so
173/// `process_turn` keeps its exact shape for existing callers.
174fn 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
197// ── Action detection keywords ──
198
199const 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
214// ── Correction indicators ──
215
216const 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
230// ── Speculation indicators (for ghost memories) ──
231
232const 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
244// ── Sentiment keywords ──
245
246const 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    /// Process a single conversation turn through the full cognitive pipeline.
284    ///
285    /// This is the unified entry point that replaces the duplicated orchestration
286    /// in MCP and platform. It handles:
287    /// - Context retrieval (speculative cache → hybrid search)
288    /// - Pain signal checking
289    /// - Episodic memory storage (with automatic write inference)
290    /// - Action detection, proactive recall, corrections, sentiment
291    /// - Phantom detection + stream contradiction checking
292    /// - Trajectory tracking + ghost memory creation
293    /// - Speculative cache pre-assembly
294    /// - Fact extraction + edge linking
295    /// - Auto-maintenance (decay / archival / consolidation)
296    ///
297    /// LLM-powered features (entity resolution, topic canonicalization,
298    /// Bedrock extraction, etc.) should be layered on top by the caller.
299    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    /// The read half of a turn: everything the caller needs to build its next
310    /// prompt (recalled context, pain warnings, delta), and nothing else. The
311    /// write half is [`MenteDb::ingest_turn`]; callers on a latency budget
312    /// respond after this returns and run ingestion behind the response.
313    /// `process_turn` composes the two halves and is behavior-identical to the
314    /// original single-call pipeline.
315    pub fn recall_turn_context(
316        &self,
317        input: &ProcessTurnInput,
318        delta_tracker: &mut DeltaTracker,
319    ) -> crate::MenteResult<RecallContextResult> {
320        // §1: Embed query
321        let query_embedding = self.embed_or_empty(&input.user_message)?;
322
323        // §1b: Try speculative cache, fall back to hybrid search
324        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        // §2: Pain signals
333        let pain_warnings = self.check_pain_signals(&input.user_message);
334
335        // §2b: Delta tracking
336        let delta = delta_tracker.compute_delta(&current_ids, &delta_tracker.last_served.clone());
337        delta_tracker.update(&current_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    /// The write half of a turn: store, reconcile, analyze, maintain. Takes
349    /// the context recalled by [`MenteDb::recall_turn_context`] so phantom and
350    /// stream analysis see exactly what the caller saw, without re-running
351    /// recall. Safe to run after the caller's response has already been sent;
352    /// the single-writer discipline is the caller's (hold the account write
353    /// lock while this runs, as with any write).
354    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        // §3: Store episodic turn (write inference runs automatically inside store)
368        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        // Scope internal recalls to the turn's owner on BOTH axes so cross-owner
377        // inference, fact linking, and proactive recall cannot surface or link
378        // another user's or agent's private memories. None on an axis preserves
379        // global behavior for that axis.
380        let scope_agent = input.agent_id.map(AgentId);
381        let scope_user = input.user_id.map(UserId);
382
383        // §4: Write inference on the episodic memory
384        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        // §5: Extract facts + link edges
391        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        // §6: Detect actions
398        let combined_text = format!("{} {}", input.user_message, assistant_resp);
399        let detected_actions = detect_actions(&combined_text);
400
401        // §7: Proactive recall
402        let proactive_recalls =
403            self.proactive_recall(&detected_actions, scope_agent, scope_user)?;
404
405        // §8: Auto-detect corrections (tags the episodic turn, never mints a node)
406        let correction_id = self.auto_detect_correction(&input.user_message, episodic_id)?;
407
408        // §9: Sentiment analysis
409        let sentiment = analyze_sentiment(&input.user_message);
410
411        // §10: Phantom detection + stream contradiction check
412        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        // §11: Trajectory + ghost memories
420        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        // §12: Speculative cache pre-assembly
430        self.update_speculative_cache_from_predictions(&predicted_topics);
431
432        // §13: Auto-maintenance
433        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    // ── Private helpers ──
453
454    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        // Try speculative cache first
463        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        // Hybrid search fallback
483        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        // Append always-scoped memories not already recalled. Fetch them from the
507        // tag index (scope:always is capped near-zero), loading only the handful
508        // of matches. The old path scanned every page and called load_memory for
509        // every memory on each turn, which thrashed the buffer pool against disk
510        // on large accounts and dominated turn latency.
511        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    /// Embed text, or return an empty embedding when no provider is configured.
571    ///
572    /// An empty embedding degrades honestly: store() skips the vector index,
573    /// write inference skips similarity checks, and retrieval falls back to
574    /// keyword (BM25) matching. A zero vector would instead pollute the HNSW
575    /// index and silently make every cosine similarity zero.
576    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        // The turn's memory can legitimately be gone by now: paraphrase dedup
628        // can skip the store, and write inference inside store can supersede
629        // or forget it (a correction resolving against an earlier fact). A
630        // missing target ends this step, never the whole turn.
631        let Ok(target) = self.get_memory(id) else {
632            return Ok(0);
633        };
634        // Scope to the turn's owner on BOTH axes: contradiction and dedup edges
635        // must not form across users or agents. Nil owned (shared/global)
636        // memories remain in scope.
637        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                    // Byte-identical re-save: invalidate the duplicate and link it
764                    // to the survivor with a Derived edge (dedup lineage), not a
765                    // Supersedes edge that would read as a conflict.
766                    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        // Same tolerance as run_explicit_write_inference: the turn's memory
797        // can have been deduped, superseded, or forgotten between store and
798        // this step; a missing target ends this step, never the whole turn.
799        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        // Scope to the turn's owner on BOTH axes: "related" edges must not link
809        // one user's or agent's memory to another's. Nil owned (shared/global)
810        // memories remain in scope.
811        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            // Scope to the turn's owner on BOTH axes so proactive recall never
874            // surfaces another user's or agent's private memories (nil
875            // owned/global still pass).
876            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    /// Detect a correction signal and tag the stored episodic turn with it.
905    ///
906    /// Only the user message is scanned: correction indicators in assistant
907    /// output ("actually", "to clarify", ...) are ordinary prose, and scanning
908    /// them caused unrelated user messages to be recorded as corrections.
909    /// The signal is preserved as a tag on the episodic turn rather than a
910    /// fabricated semantic node, because a verbatim copy of the user message
911    /// is working material, not distilled knowledge; distilled correction
912    /// memories come from fact extraction.
913    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        // Phantom detection
953        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        // Stream contradiction check
966        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        // Ghost memory: store speculative content at low confidence
1022        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        // Each predicted topic either becomes an entry or is dropped at one
1052        // step; count which, so an empty cache in production says why instead
1053        // of failing silently.
1054        #[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            // Keep the topic embedding so a later query matches the entry by
1104            // meaning; without it the cache falls back to keyword overlap.
1105            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        // Every 50 turns: salience decay
1128        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        // Every 100 turns: archival evaluation
1140        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        // Every 200 turns: consolidation
1162        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        // Enrichment trigger: mark pending when interval is reached
1181        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
1198// ── Free functions ──
1199
1200fn 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}