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};
29use mentedb_core::{MemoryEdge, MemoryNode};
30use tracing::{debug, 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    /// Originating session, tagged onto stored turns so injection recall
49    /// can exclude memories already present in that session's context.
50    pub session_id: Option<String>,
51}
52
53/// A detected action from the conversation.
54#[derive(Debug, Clone)]
55pub struct DetectedAction {
56    pub action_type: String,
57    pub detail: String,
58}
59
60/// A pain warning matched against the current context.
61#[derive(Debug, Clone)]
62pub struct PainWarning {
63    pub signal_id: MemoryId,
64    pub intensity: f32,
65    pub description: String,
66}
67
68/// A proactively recalled memory relevant to detected actions.
69#[derive(Debug, Clone)]
70pub struct ProactiveRecall {
71    pub memory_id: MemoryId,
72    pub content: String,
73    pub relevance: f32,
74    pub action_type: String,
75}
76
77/// Result of processing a conversation turn.
78#[derive(Debug, Clone)]
79pub struct ProcessTurnResult {
80    /// Retrieved context memories (scored and ranked).
81    pub context: Vec<ScoredMemory>,
82    /// IDs of newly stored memories this turn.
83    pub stored_ids: Vec<MemoryId>,
84    /// The episodic memory ID for this turn.
85    pub episodic_id: Option<MemoryId>,
86    /// Pain warnings triggered by the user message.
87    pub pain_warnings: Vec<PainWarning>,
88    /// Whether context came from the speculative cache.
89    pub cache_hit: bool,
90    /// Number of write-inference actions applied.
91    pub inference_actions: u32,
92    /// Detected actions in the conversation.
93    pub detected_actions: Vec<DetectedAction>,
94    /// Proactively recalled memories.
95    pub proactive_recalls: Vec<ProactiveRecall>,
96    /// Auto-correction memory ID if a correction was detected.
97    pub correction_id: Option<MemoryId>,
98    /// Sentiment score (-1.0 to 1.0).
99    pub sentiment: f32,
100    /// Number of phantom (knowledge gap) entities detected.
101    pub phantom_count: usize,
102    /// Number of stream contradictions detected.
103    pub contradiction_count: usize,
104    /// Predicted next topics from trajectory analysis.
105    pub predicted_topics: Vec<String>,
106    /// Number of facts extracted and linked.
107    pub facts_extracted: usize,
108    /// Number of edges created from fact extraction.
109    pub edges_created: u32,
110    /// Whether enrichment is pending (caller should invoke enrichment pipeline).
111    pub enrichment_pending: bool,
112    /// Delta: memory IDs added since last turn.
113    pub delta_added: Vec<MemoryId>,
114    /// Delta: memory IDs removed since last turn.
115    pub delta_removed: Vec<MemoryId>,
116}
117
118// ── Action detection keywords ──
119
120const ACTION_KEYWORDS: &[(&str, &str)] = &[
121    ("deploy", "deployment"),
122    ("release", "release"),
123    ("migrate", "migration"),
124    ("refactor", "refactoring"),
125    ("debug", "debugging"),
126    ("install", "installation"),
127    ("configure", "configuration"),
128    ("test", "testing"),
129    ("build", "building"),
130    ("commit", "version_control"),
131    ("merge", "version_control"),
132    ("review", "code_review"),
133];
134
135// ── Correction indicators ──
136
137const CORRECTION_INDICATORS: &[&str] = &[
138    "actually",
139    "correction",
140    "i was wrong",
141    "that's not right",
142    "let me correct",
143    "i meant",
144    "not quite",
145    "to clarify",
146    "i misspoke",
147    "update:",
148    "scratch that",
149];
150
151// ── Speculation indicators (for ghost memories) ──
152
153const SPECULATION_INDICATORS: &[&str] = &[
154    "might be",
155    "probably",
156    "seems like",
157    "i think",
158    "looks like",
159    "considering",
160    "planning to",
161    "thinking about",
162    "maybe",
163];
164
165// ── Sentiment keywords ──
166
167const POSITIVE_WORDS: &[&str] = &[
168    "great",
169    "excellent",
170    "perfect",
171    "thanks",
172    "awesome",
173    "love",
174    "good",
175    "nice",
176    "wonderful",
177    "fantastic",
178    "helpful",
179    "amazing",
180    "brilliant",
181    "works",
182    "solved",
183];
184
185const NEGATIVE_WORDS: &[&str] = &[
186    "bad",
187    "terrible",
188    "awful",
189    "hate",
190    "wrong",
191    "broken",
192    "fail",
193    "error",
194    "bug",
195    "frustrating",
196    "annoying",
197    "useless",
198    "horrible",
199    "worst",
200    "disappointed",
201];
202
203impl MenteDb {
204    /// Process a single conversation turn through the full cognitive pipeline.
205    ///
206    /// This is the unified entry point that replaces the duplicated orchestration
207    /// in MCP and platform. It handles:
208    /// - Context retrieval (speculative cache → hybrid search)
209    /// - Pain signal checking
210    /// - Episodic memory storage (with automatic write inference)
211    /// - Action detection, proactive recall, corrections, sentiment
212    /// - Phantom detection + stream contradiction checking
213    /// - Trajectory tracking + ghost memory creation
214    /// - Speculative cache pre-assembly
215    /// - Fact extraction + edge linking
216    /// - Auto-maintenance (decay / archival / consolidation)
217    ///
218    /// LLM-powered features (entity resolution, topic canonicalization,
219    /// Bedrock extraction, etc.) should be layered on top by the caller.
220    pub fn process_turn(
221        &self,
222        input: &ProcessTurnInput,
223        delta_tracker: &mut DeltaTracker,
224    ) -> crate::MenteResult<ProcessTurnResult> {
225        let agent_id = AgentId(input.agent_id.unwrap_or(Uuid::nil()));
226        let assistant_resp = input.assistant_response.as_deref().unwrap_or("");
227        let conversation = format!(
228            "User: {}\nAssistant: {}",
229            input.user_message, assistant_resp
230        );
231
232        // §1: Embed query
233        let query_embedding = self.embed_or_empty(&input.user_message)?;
234
235        // §1b: Try speculative cache, fall back to hybrid search
236        let (context, current_ids, cache_hit) = self.retrieve_context(
237            &input.user_message,
238            &query_embedding,
239            input.agent_id.map(AgentId),
240            delta_tracker,
241        )?;
242
243        // §2: Pain signals
244        let pain_warnings = self.check_pain_signals(&input.user_message);
245
246        // §2b: Delta tracking
247        let delta = delta_tracker.compute_delta(&current_ids, &delta_tracker.last_served.clone());
248        delta_tracker.update(&current_ids);
249
250        // §3: Store episodic turn (write inference runs automatically inside store)
251        let (stored_ids, episodic_id) = self.store_episodic(
252            &conversation,
253            agent_id,
254            &input.project_context,
255            &input.session_id,
256        )?;
257
258        // §4: Write inference on the episodic memory
259        let inference_actions = if let Some(eid) = episodic_id {
260            self.run_explicit_write_inference(eid)?
261        } else {
262            0
263        };
264
265        // §5: Extract facts + link edges
266        let (facts_extracted, edges_created) = if let Some(eid) = episodic_id {
267            self.extract_and_link_facts(eid)?
268        } else {
269            (0, 0)
270        };
271
272        // §6: Detect actions
273        let combined_text = format!("{} {}", input.user_message, assistant_resp);
274        let detected_actions = detect_actions(&combined_text);
275
276        // §7: Proactive recall
277        let proactive_recalls = self.proactive_recall(&detected_actions)?;
278
279        // §8: Auto-detect corrections (tags the episodic turn, never mints a node)
280        let correction_id = self.auto_detect_correction(&input.user_message, episodic_id)?;
281
282        // §9: Sentiment analysis
283        let sentiment = analyze_sentiment(&input.user_message);
284
285        // §10: Phantom detection + stream contradiction check
286        let (phantom_count, contradiction_count) = self.detect_phantoms_and_check_stream(
287            &conversation,
288            assistant_resp,
289            &context,
290            input.turn_id,
291        );
292
293        // §11: Trajectory + ghost memories
294        let predicted_topics = self.update_trajectory(
295            input,
296            agent_id,
297            &stored_ids,
298            &detected_actions,
299            &combined_text,
300        )?;
301
302        // §12: Speculative cache pre-assembly
303        self.update_speculative_cache_from_predictions(&predicted_topics);
304
305        // §13: Auto-maintenance
306        self.maybe_run_maintenance(input.turn_id);
307
308        Ok(ProcessTurnResult {
309            context,
310            stored_ids: stored_ids.clone(),
311            episodic_id,
312            pain_warnings,
313            cache_hit,
314            inference_actions,
315            detected_actions,
316            proactive_recalls,
317            correction_id,
318            sentiment,
319            phantom_count,
320            contradiction_count,
321            predicted_topics,
322            facts_extracted,
323            edges_created,
324            enrichment_pending: self.needs_enrichment(),
325            delta_added: delta.added,
326            delta_removed: delta.removed,
327        })
328    }
329
330    // ── Private helpers ──
331
332    fn retrieve_context(
333        &self,
334        user_message: &str,
335        query_embedding: &[f32],
336        agent: Option<AgentId>,
337        _delta_tracker: &DeltaTracker,
338    ) -> crate::MenteResult<(Vec<ScoredMemory>, Vec<MemoryId>, bool)> {
339        // Try speculative cache first
340        if let Some(entry) = self.try_speculative_hit(user_message, Some(query_embedding)) {
341            let matched: Vec<ScoredMemory> = entry
342                .memory_ids
343                .iter()
344                .filter_map(|id| self.get_memory(*id).ok())
345                .filter(|m| crate::agent_visible(m.agent_id, agent))
346                .map(|m| ScoredMemory {
347                    memory: m,
348                    score: 0.9,
349                })
350                .collect();
351            if matched.len() >= entry.memory_ids.len() / 2 {
352                let ids: Vec<MemoryId> = matched.iter().map(|sm| sm.memory.id).collect();
353                return Ok((matched, ids, true));
354            }
355        }
356
357        // Hybrid search fallback
358        let now = now_us();
359        let results = self.recall_hybrid_scoped_at_mode(
360            query_embedding,
361            Some(user_message),
362            10,
363            now,
364            None,
365            false,
366            None,
367            agent,
368        )?;
369        let mut scored: Vec<ScoredMemory> = results
370            .iter()
371            .filter_map(|(mid, score)| {
372                self.get_memory(*mid).ok().map(|m| ScoredMemory {
373                    memory: m,
374                    score: *score,
375                })
376            })
377            .collect();
378
379        // Append always-scoped memories (not already in results)
380        let hybrid_ids: std::collections::HashSet<MemoryId> =
381            scored.iter().map(|sm| sm.memory.id).collect();
382        let always_scope_tag = "scope:always";
383        let pm = self.page_map.read();
384        for pid in pm.values() {
385            if let Ok(mem) = self.storage.load_memory(*pid)
386                && mem.tags.iter().any(|t| t == always_scope_tag)
387                && !hybrid_ids.contains(&mem.id)
388                && crate::agent_visible(mem.agent_id, agent)
389            {
390                scored.push(ScoredMemory {
391                    memory: mem,
392                    score: 0.85,
393                });
394            }
395        }
396        drop(pm);
397
398        let ids: Vec<MemoryId> = scored.iter().map(|sm| sm.memory.id).collect();
399        Ok((scored, ids, false))
400    }
401
402    fn check_pain_signals(&self, user_message: &str) -> Vec<PainWarning> {
403        let context_words: Vec<String> = user_message
404            .split_whitespace()
405            .map(|w| w.to_lowercase())
406            .collect();
407        let all_signals = self.all_pain_signals();
408        let mut scored: Vec<_> = all_signals
409            .iter()
410            .filter_map(|s| {
411                let matched = s
412                    .trigger_keywords
413                    .iter()
414                    .filter(|trigger| {
415                        context_words
416                            .iter()
417                            .any(|ctx| ctx.contains(&trigger.to_lowercase()))
418                    })
419                    .count();
420                if matched > 0 {
421                    let relevance = matched as f32 / s.trigger_keywords.len().max(1) as f32;
422                    let score = s.intensity * relevance;
423                    Some((s, score))
424                } else {
425                    None
426                }
427            })
428            .collect();
429        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
430        scored
431            .iter()
432            .map(|(s, _)| PainWarning {
433                signal_id: s.id,
434                intensity: s.intensity,
435                description: s.description.clone(),
436            })
437            .collect()
438    }
439
440    /// Embed text, or return an empty embedding when no provider is configured.
441    ///
442    /// An empty embedding degrades honestly: store() skips the vector index,
443    /// write inference skips similarity checks, and retrieval falls back to
444    /// keyword (BM25) matching. A zero vector would instead pollute the HNSW
445    /// index and silently make every cosine similarity zero.
446    fn embed_or_empty(&self, text: &str) -> crate::MenteResult<Vec<f32>> {
447        match self.embed_text(text)? {
448            Some(embedding) => Ok(embedding),
449            None => {
450                static NO_EMBEDDER_WARN: std::sync::Once = std::sync::Once::new();
451                NO_EMBEDDER_WARN.call_once(|| {
452                    warn!(
453                        "no embedding provider configured: semantic search, auto-linking, and \
454                         contradiction detection are DISABLED; retrieval falls back to keyword \
455                         (BM25) matching only. Configure a provider via open_with_embedder()."
456                    );
457                });
458                Ok(Vec::new())
459            }
460        }
461    }
462
463    fn store_episodic(
464        &self,
465        conversation: &str,
466        agent_id: AgentId,
467        project_context: &Option<String>,
468        session_id: &Option<String>,
469    ) -> crate::MenteResult<(Vec<MemoryId>, Option<MemoryId>)> {
470        let embedding = self.embed_or_empty(conversation)?;
471        let mut node = MemoryNode::new(
472            agent_id,
473            MemoryType::Episodic,
474            conversation.to_string(),
475            embedding,
476        );
477        node.tags.push("turn".to_string());
478        if let Some(ctx) = project_context {
479            node.tags.push(format!("scope:project:{}", ctx));
480        }
481        if let Some(session) = session_id {
482            node.tags.push(format!("session:{}", session));
483        }
484        let id = node.id;
485        self.store(node)?;
486        Ok((vec![id], Some(id)))
487    }
488
489    fn run_explicit_write_inference(&self, id: MemoryId) -> crate::MenteResult<u32> {
490        let target = self.get_memory(id)?;
491        let similar_ids = self
492            .recall_similar(&target.embedding, 50)
493            .unwrap_or_default();
494        let existing: Vec<MemoryNode> = similar_ids
495            .iter()
496            .filter_map(|(mid, _)| {
497                if *mid != id {
498                    self.get_memory(*mid).ok()
499                } else {
500                    None
501                }
502            })
503            .collect();
504
505        let engine = crate::WriteInferenceEngine::new();
506        let actions = engine.infer_on_write(&target, &existing, &[]);
507        let now = now_us();
508        let mut applied = 0u32;
509
510        for action in &actions {
511            match action {
512                crate::InferredAction::FlagContradiction {
513                    existing: ex, new, ..
514                } => {
515                    let edge = MemoryEdge {
516                        source: *new,
517                        target: *ex,
518                        edge_type: EdgeType::Contradicts,
519                        weight: 1.0,
520                        created_at: now,
521                        valid_from: None,
522                        valid_until: None,
523                        label: None,
524                    };
525                    if let Err(e) = self.relate(edge) {
526                        tracing::warn!("failed to create inferred edge: {e}");
527                    }
528                    applied += 1;
529                }
530                crate::InferredAction::MarkObsolete {
531                    memory,
532                    superseded_by,
533                } => {
534                    let edge = MemoryEdge {
535                        source: *superseded_by,
536                        target: *memory,
537                        edge_type: EdgeType::Supersedes,
538                        weight: 1.0,
539                        created_at: now,
540                        valid_from: None,
541                        valid_until: None,
542                        label: None,
543                    };
544                    if let Err(e) = self.relate(edge) {
545                        tracing::warn!("failed to create inferred edge: {e}");
546                    }
547                    applied += 1;
548                }
549                crate::InferredAction::CreateEdge {
550                    source,
551                    target,
552                    edge_type,
553                    weight,
554                } => {
555                    let edge = MemoryEdge {
556                        source: *source,
557                        target: *target,
558                        edge_type: *edge_type,
559                        weight: *weight,
560                        created_at: now,
561                        valid_from: None,
562                        valid_until: None,
563                        label: None,
564                    };
565                    if let Err(e) = self.relate(edge) {
566                        tracing::warn!("failed to create inferred edge: {e}");
567                    }
568                    applied += 1;
569                }
570                crate::InferredAction::UpdateConfidence {
571                    memory,
572                    new_confidence,
573                } => {
574                    if let Ok(mut mem) = self.get_memory(*memory) {
575                        mem.confidence = *new_confidence;
576                        let _ = self.store(mem);
577                        applied += 1;
578                    }
579                }
580                crate::InferredAction::InvalidateMemory {
581                    memory,
582                    valid_until,
583                    ..
584                } => {
585                    if let Ok(mut mem) = self.get_memory(*memory) {
586                        mem.valid_until = Some(*valid_until);
587                        let _ = self.store(mem);
588                        applied += 1;
589                    }
590                }
591                crate::InferredAction::UpdateContent {
592                    memory,
593                    new_content,
594                    ..
595                } => {
596                    if let Ok(mut mem) = self.get_memory(*memory) {
597                        mem.content = new_content.clone();
598                        let _ = self.store(mem);
599                        applied += 1;
600                    }
601                }
602                crate::InferredAction::PropagateBeliefChange { .. } => {
603                    applied += 1;
604                }
605            }
606        }
607        Ok(applied)
608    }
609
610    fn extract_and_link_facts(&self, id: MemoryId) -> crate::MenteResult<(usize, u32)> {
611        let target = self.get_memory(id)?;
612        let extractor = FactExtractor::new();
613        let facts = extractor.extract_facts(&target);
614        if facts.is_empty() {
615            return Ok((0, 0));
616        }
617
618        let similar_ids = self
619            .recall_similar(&target.embedding, 50)
620            .unwrap_or_default();
621        let nearby: Vec<MemoryNode> = similar_ids
622            .iter()
623            .filter_map(|(mid, _)| {
624                if *mid != id {
625                    self.get_memory(*mid).ok()
626                } else {
627                    None
628                }
629            })
630            .collect();
631
632        let now = now_us();
633        let mut edges_created = 0u32;
634        for fact in &facts {
635            for other in &nearby {
636                if other.content.contains(&fact.subject) || other.content.contains(&fact.object) {
637                    let edge = MemoryEdge {
638                        source: id,
639                        target: other.id,
640                        edge_type: EdgeType::Related,
641                        weight: 0.5,
642                        created_at: now,
643                        valid_from: None,
644                        valid_until: None,
645                        label: None,
646                    };
647                    if let Err(e) = self.relate(edge) {
648                        tracing::warn!("failed to create inferred edge: {e}");
649                    }
650                    edges_created += 1;
651                }
652            }
653        }
654        Ok((facts.len(), edges_created))
655    }
656
657    fn proactive_recall(
658        &self,
659        actions: &[DetectedAction],
660    ) -> crate::MenteResult<Vec<ProactiveRecall>> {
661        let mut recalls = Vec::new();
662        for action in actions {
663            let search_query = format!("{} {}", action.action_type, action.detail);
664            let Some(emb) = self.embed_text(&search_query)? else {
665                continue;
666            };
667            let results = self.recall_similar(&emb, 3).unwrap_or_default();
668            for (mid, score) in &results {
669                if let Ok(mem) = self.get_memory(*mid) {
670                    recalls.push(ProactiveRecall {
671                        memory_id: *mid,
672                        content: mem.content.clone(),
673                        relevance: *score,
674                        action_type: action.action_type.clone(),
675                    });
676                }
677            }
678        }
679        Ok(recalls)
680    }
681
682    /// Detect a correction signal and tag the stored episodic turn with it.
683    ///
684    /// Only the user message is scanned: correction indicators in assistant
685    /// output ("actually", "to clarify", ...) are ordinary prose, and scanning
686    /// them caused unrelated user messages to be recorded as corrections.
687    /// The signal is preserved as a tag on the episodic turn rather than a
688    /// fabricated semantic node, because a verbatim copy of the user message
689    /// is working material, not distilled knowledge; distilled correction
690    /// memories come from fact extraction.
691    fn auto_detect_correction(
692        &self,
693        user_message: &str,
694        episodic_id: Option<MemoryId>,
695    ) -> crate::MenteResult<Option<MemoryId>> {
696        let lowered = user_message.to_lowercase();
697        let is_correction = CORRECTION_INDICATORS
698            .iter()
699            .any(|ind| lowered.contains(ind));
700        if !is_correction {
701            return Ok(None);
702        }
703        let Some(eid) = episodic_id else {
704            return Ok(None);
705        };
706        let pid = {
707            let pm = self.page_map.read();
708            match pm.get(&eid) {
709                Some(p) => *p,
710                None => return Ok(None),
711            }
712        };
713        let Ok(mut node) = self.storage.load_memory(pid) else {
714            return Ok(None);
715        };
716        if !node.tags.iter().any(|t| t == "auto-correction") {
717            node.tags.push("auto-correction".to_string());
718            self.storage.update_memory(pid, &node)?;
719        }
720        Ok(Some(eid))
721    }
722
723    fn detect_phantoms_and_check_stream(
724        &self,
725        conversation: &str,
726        assistant_resp: &str,
727        context: &[ScoredMemory],
728        turn_id: u64,
729    ) -> (usize, usize) {
730        // Phantom detection
731        let known: Vec<String> = context
732            .iter()
733            .flat_map(|sm| {
734                sm.memory
735                    .content
736                    .split_whitespace()
737                    .map(|w| w.to_lowercase())
738            })
739            .collect();
740        let phantoms = self.detect_phantoms(conversation, &known, turn_id);
741        let phantom_count = phantoms.len();
742
743        // Stream contradiction check
744        let known_facts: Vec<(MemoryId, String)> = context
745            .iter()
746            .map(|sm| (sm.memory.id, sm.memory.content.clone()))
747            .collect();
748        let contradiction_count = if !known_facts.is_empty() {
749            self.feed_stream_token(assistant_resp);
750            let alerts = self.check_stream_alerts(&known_facts);
751            alerts
752                .iter()
753                .filter(|a| matches!(a, StreamAlert::Contradiction { .. }))
754                .count()
755        } else {
756            0
757        };
758
759        (phantom_count, contradiction_count)
760    }
761
762    fn update_trajectory(
763        &self,
764        input: &ProcessTurnInput,
765        agent_id: AgentId,
766        stored_ids: &[MemoryId],
767        detected_actions: &[DetectedAction],
768        combined_text: &str,
769    ) -> crate::MenteResult<Vec<String>> {
770        let decision_state = if stored_ids.is_empty() {
771            DecisionState::Investigating
772        } else {
773            DecisionState::Completed
774        };
775
776        let raw_topic = if input.user_message.chars().count() > 100 {
777            format!(
778                "{}...",
779                input.user_message.chars().take(100).collect::<String>()
780            )
781        } else {
782            input.user_message.clone()
783        };
784
785        let topic_embedding = self.embed_or_empty(&raw_topic)?;
786
787        let node = TrajectoryNode {
788            turn_id: input.turn_id,
789            topic_summary: raw_topic,
790            topic_embedding,
791            decision_state,
792            open_questions: Vec::new(),
793            timestamp: now_us(),
794        };
795        self.record_trajectory_turn(node);
796        let predictions = self.predict_next_topics();
797
798        // Ghost memory: store speculative content at low confidence
799        let has_speculation = SPECULATION_INDICATORS
800            .iter()
801            .any(|s| combined_text.contains(s));
802        if has_speculation && !detected_actions.is_empty() {
803            let ghost_content = format!(
804                "Unconfirmed: {}",
805                input.user_message.chars().take(300).collect::<String>()
806            );
807            if let Ok(Some(ghost_emb)) = self.embed_text(&ghost_content) {
808                let mut ghost_node =
809                    MemoryNode::new(agent_id, MemoryType::Semantic, ghost_content, ghost_emb);
810                ghost_node.confidence = 0.3;
811                ghost_node.tags = vec!["ghost-memory".to_string(), "unconfirmed".to_string()];
812                if let Some(ctx) = &input.project_context {
813                    ghost_node.tags.push(format!("scope:project:{}", ctx));
814                }
815                let _ = self.store(ghost_node);
816            }
817        }
818
819        Ok(predictions)
820    }
821
822    fn update_speculative_cache_from_predictions(&self, predictions: &[String]) {
823        if predictions.is_empty() {
824            return;
825        }
826        // We need to capture `self` for use in the closure, but pre_assemble_speculative
827        // takes &self already. We build the closure to search via self.
828        let predictions_owned = predictions.to_vec();
829        self.pre_assemble_speculative(predictions_owned, |topic| {
830            let topic_emb = self.embed_text(topic).ok()??;
831            let similar_ids = self.recall_similar(&topic_emb, 5).ok()?;
832            if similar_ids.is_empty() {
833                return None;
834            }
835            let mut context_parts = Vec::new();
836            let mut memory_ids = Vec::new();
837            for (mid, _) in &similar_ids {
838                if let Ok(mem) = self.get_memory(*mid) {
839                    context_parts.push(mem.content.clone());
840                    memory_ids.push(*mid);
841                }
842            }
843            if memory_ids.is_empty() {
844                return None;
845            }
846            Some((context_parts.join("\n---\n"), memory_ids, None))
847        });
848    }
849
850    fn maybe_run_maintenance(&self, turn_id: u64) {
851        if turn_id == 0 {
852            return;
853        }
854
855        // Every 50 turns: salience decay
856        if turn_id.is_multiple_of(50) {
857            match self.apply_decay_global() {
858                Ok(updated) => {
859                    debug!(turn_id, updated, "auto-maintenance: decay applied");
860                }
861                Err(e) => {
862                    warn!(turn_id, error = %e, "auto-maintenance: decay failed");
863                }
864            }
865        }
866
867        // Every 100 turns: archival evaluation
868        if turn_id.is_multiple_of(100) {
869            match self.evaluate_archival_global() {
870                Ok(decisions) => {
871                    let mut archived = 0u64;
872                    for (id, decision) in &decisions {
873                        if matches!(
874                            decision,
875                            crate::ArchivalDecision::Delete | crate::ArchivalDecision::Archive
876                        ) {
877                            let _ = self.forget(*id);
878                            archived += 1;
879                        }
880                    }
881                    debug!(turn_id, archived, "auto-maintenance: archival evaluated");
882                }
883                Err(e) => {
884                    warn!(turn_id, error = %e, "auto-maintenance: archival failed");
885                }
886            }
887        }
888
889        // Every 200 turns: consolidation
890        if turn_id.is_multiple_of(200) {
891            match self.find_consolidation_candidates(2, 0.85) {
892                Ok(candidates) => {
893                    for candidate in &candidates {
894                        let _ = self.consolidate_cluster(&candidate.memories);
895                    }
896                    debug!(
897                        turn_id,
898                        clusters = candidates.len(),
899                        "auto-maintenance: consolidation"
900                    );
901                }
902                Err(e) => {
903                    warn!(turn_id, error = %e, "auto-maintenance: consolidation failed");
904                }
905            }
906        }
907
908        // Enrichment trigger: mark pending when interval is reached
909        let enrichment = &self.cognitive_config.enrichment_config;
910        if enrichment.enabled && enrichment.trigger_interval > 0 {
911            let last = self.last_enrichment_turn();
912            let turns_since = turn_id.saturating_sub(last);
913            if turns_since >= enrichment.trigger_interval && !self.needs_enrichment() {
914                *self.enrichment_pending.write() = true;
915                debug!(
916                    turn_id,
917                    last_enrichment = last,
918                    turns_since,
919                    "enrichment trigger: marked pending"
920                );
921            }
922        }
923    }
924}
925
926// ── Free functions ──
927
928fn detect_actions(combined_text: &str) -> Vec<DetectedAction> {
929    let lower = combined_text.to_lowercase();
930    ACTION_KEYWORDS
931        .iter()
932        .filter(|(kw, _)| lower.contains(kw))
933        .map(|(kw, action_type)| {
934            let detail = extract_sentence_containing(&lower, kw);
935            DetectedAction {
936                action_type: action_type.to_string(),
937                detail,
938            }
939        })
940        .collect()
941}
942
943fn analyze_sentiment(text: &str) -> f32 {
944    let lower = text.to_lowercase();
945    let words: Vec<&str> = lower.split_whitespace().collect();
946    let total = words.len().max(1) as f32;
947
948    let positive = words
949        .iter()
950        .filter(|w| POSITIVE_WORDS.iter().any(|p| w.contains(p)))
951        .count() as f32;
952    let negative = words
953        .iter()
954        .filter(|w| NEGATIVE_WORDS.iter().any(|n| w.contains(n)))
955        .count() as f32;
956
957    ((positive - negative) / total).clamp(-1.0, 1.0)
958}
959
960fn extract_sentence_containing(text: &str, keyword: &str) -> String {
961    text.split('.')
962        .find(|s| s.contains(keyword))
963        .unwrap_or(keyword)
964        .trim()
965        .chars()
966        .take(200)
967        .collect()
968}
969
970fn now_us() -> Timestamp {
971    std::time::SystemTime::now()
972        .duration_since(std::time::UNIX_EPOCH)
973        .unwrap_or_default()
974        .as_micros() as u64
975}