Skip to main content

kernex_memory/store/
context.rs

1//! Context building and user profile formatting.
2//!
3//! Helper functions for onboarding, system prompt composition, language
4//! detection, and relative time formatting live in `context_helpers`.
5
6use super::Store;
7use kernex_core::{
8    config::SYSTEM_FACT_KEYS,
9    context::{Context, ContextEntry, ContextNeeds},
10    error::KernexError,
11    message::Request,
12};
13
14// Re-export helpers so existing `super::context::*` paths in tests keep working.
15pub use super::context_helpers::detect_language;
16#[cfg(test)]
17pub(super) use super::context_helpers::onboarding_hint_text;
18pub(super) use super::context_helpers::{
19    build_system_prompt, compute_onboarding_stage, SystemPromptContext,
20};
21
22/// Identity fact keys — shown first in the user profile.
23const IDENTITY_KEYS: &[&str] = &["name", "preferred_name", "pronouns"];
24
25/// Context fact keys — shown second in the user profile.
26const CONTEXT_KEYS: &[&str] = &["timezone", "location", "occupation"];
27
28impl Store {
29    /// Build a conversation context from memory for the provider.
30    ///
31    /// The `channel` parameter identifies the communication channel since
32    /// `Request` is channel-agnostic.
33    pub async fn build_context(
34        &self,
35        channel: &str,
36        incoming: &Request,
37        base_system_prompt: &str,
38        needs: &ContextNeeds,
39        active_project: Option<&str>,
40    ) -> Result<Context, KernexError> {
41        let project_key = active_project.unwrap_or("");
42        let conv_id = self
43            .get_or_create_conversation(channel, &incoming.sender_id, project_key)
44            .await?;
45
46        let history_fut = async {
47            let rows: Vec<(String, String)> = sqlx::query_as(
48                "SELECT role, content FROM (\
49                     SELECT role, content, timestamp FROM messages \
50                     WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?\
51                 ) ORDER BY timestamp ASC",
52            )
53            .bind(&conv_id)
54            .bind(self.max_context_messages as i64)
55            .fetch_all(&self.pool)
56            .await
57            .map_err(|e| KernexError::Store(format!("query failed: {e}")))?;
58
59            Ok::<Vec<ContextEntry>, KernexError>(
60                rows.into_iter()
61                    .map(|(role, content)| ContextEntry { role, content })
62                    .collect(),
63            )
64        };
65
66        let facts_fut = async {
67            self.get_facts(&incoming.sender_id)
68                .await
69                .unwrap_or_default()
70        };
71
72        let summaries_fut = async {
73            if needs.summaries {
74                self.get_recent_summaries(channel, &incoming.sender_id, 3)
75                    .await
76                    .unwrap_or_default()
77            } else {
78                vec![]
79            }
80        };
81
82        let recall_fut = async {
83            if needs.recall {
84                self.search_messages(&incoming.text, &conv_id, &incoming.sender_id, 5)
85                    .await
86                    .unwrap_or_default()
87            } else {
88                vec![]
89            }
90        };
91
92        let tasks_fut = async {
93            if needs.pending_tasks {
94                self.get_tasks_for_sender(&incoming.sender_id)
95                    .await
96                    .unwrap_or_default()
97            } else {
98                vec![]
99            }
100        };
101
102        let outcomes_fut = async {
103            if needs.outcomes {
104                self.get_recent_outcomes(&incoming.sender_id, 15, active_project)
105                    .await
106                    .unwrap_or_default()
107            } else {
108                vec![]
109            }
110        };
111
112        let lessons_fut = async {
113            self.get_lessons(&incoming.sender_id, active_project)
114                .await
115                .unwrap_or_default()
116        };
117
118        let (history_res, facts, summaries, recall, pending_tasks, outcomes, lessons) = tokio::join!(
119            history_fut,
120            facts_fut,
121            summaries_fut,
122            recall_fut,
123            tasks_fut,
124            outcomes_fut,
125            lessons_fut,
126        );
127
128        let history = history_res?;
129
130        // Resolve language: stored preference > auto-detect > English.
131        let language =
132            if let Some((_, lang)) = facts.iter().find(|(k, _)| k == "preferred_language") {
133                lang.clone()
134            } else {
135                let detected = detect_language(&incoming.text).to_string();
136                let _ = self
137                    .store_fact(&incoming.sender_id, "preferred_language", &detected)
138                    .await;
139                detected
140            };
141
142        // Progressive onboarding: compute stage and inject hint on transitions.
143        let real_fact_count = facts
144            .iter()
145            .filter(|(k, _)| !SYSTEM_FACT_KEYS.contains(&k.as_str()))
146            .count();
147        let has_tasks = !pending_tasks.is_empty();
148
149        let current_stage: u8 = facts
150            .iter()
151            .find(|(k, _)| k == "onboarding_stage")
152            .and_then(|(_, v)| v.parse().ok())
153            .unwrap_or(0);
154
155        let new_stage = compute_onboarding_stage(current_stage, real_fact_count, has_tasks);
156
157        let onboarding_hint = if new_stage != current_stage {
158            let _ = self
159                .store_fact(
160                    &incoming.sender_id,
161                    "onboarding_stage",
162                    &new_stage.to_string(),
163                )
164                .await;
165            Some(new_stage)
166        } else if current_stage == 0 && real_fact_count == 0 {
167            Some(0u8)
168        } else {
169            if facts.iter().all(|(k, _)| k != "onboarding_stage") && current_stage == 0 {
170                let bootstrapped = compute_onboarding_stage(0, real_fact_count, has_tasks);
171                let final_stage = (0..=4).fold(0u8, |s, _| {
172                    compute_onboarding_stage(s, real_fact_count, has_tasks)
173                });
174                if final_stage > 0 {
175                    let _ = self
176                        .store_fact(
177                            &incoming.sender_id,
178                            "onboarding_stage",
179                            &final_stage.to_string(),
180                        )
181                        .await;
182                }
183                let _ = bootstrapped;
184                None
185            } else {
186                None
187            }
188        };
189
190        let facts_for_prompt: &[(String, String)] = if needs.profile { &facts } else { &[] };
191        let system_prompt = build_system_prompt(&SystemPromptContext {
192            base_rules: base_system_prompt,
193            facts: facts_for_prompt,
194            summaries: &summaries,
195            recall: &recall,
196            pending_tasks: &pending_tasks,
197            outcomes: &outcomes,
198            lessons: &lessons,
199            language: &language,
200            onboarding_hint,
201        });
202
203        Ok(Context {
204            system_prompt,
205            history,
206            current_message: incoming.text.clone(),
207            mcp_servers: Vec::new(),
208            max_turns: None,
209            allowed_tools: None,
210            model: None,
211            session_id: None,
212            agent_name: None,
213        })
214    }
215}
216
217/// Format user facts into a structured profile, filtering system keys
218/// and grouping identity facts first, then context, then the rest.
219pub fn format_user_profile(facts: &[(String, String)]) -> String {
220    let user_facts: Vec<&(String, String)> = facts
221        .iter()
222        .filter(|(k, _)| !SYSTEM_FACT_KEYS.contains(&k.as_str()))
223        .collect();
224
225    if user_facts.is_empty() {
226        return String::new();
227    }
228
229    let mut lines = vec!["User profile:".to_string()];
230
231    for key in IDENTITY_KEYS {
232        if let Some((_, v)) = user_facts.iter().find(|(k, _)| k == key) {
233            lines.push(format!("- {key}: {v}"));
234        }
235    }
236
237    for key in CONTEXT_KEYS {
238        if let Some((_, v)) = user_facts.iter().find(|(k, _)| k == key) {
239            lines.push(format!("- {key}: {v}"));
240        }
241    }
242
243    let known_keys: Vec<&str> = IDENTITY_KEYS
244        .iter()
245        .chain(CONTEXT_KEYS.iter())
246        .copied()
247        .collect();
248    for (k, v) in &user_facts {
249        if !known_keys.contains(&k.as_str()) {
250            lines.push(format!("- {k}: {v}"));
251        }
252    }
253
254    lines.join("\n")
255}