1use super::Store;
7use kernex_core::{
8 config::SYSTEM_FACT_KEYS,
9 context::{Context, ContextEntry, ContextNeeds},
10 error::KernexError,
11 message::Request,
12};
13
14pub 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
22const IDENTITY_KEYS: &[&str] = &["name", "preferred_name", "pronouns"];
24
25const CONTEXT_KEYS: &[&str] = &["timezone", "location", "occupation"];
27
28impl Store {
29 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 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 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
217pub 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}