Skip to main content

brainos_cortex/
context.rs

1//! Context assembly — builds LLM prompts with token budget management.
2//!
3//! Manages the token budget for LLM context windows:
4//! - System prompt (~500 tokens)
5//! - User model snapshot (~300 tokens)
6//! - Conversation history (~2000 tokens)
7//! - Retrieved memories (remaining budget)
8//! - Response buffer (~400 tokens)
9
10use crate::llm::{Message, Role};
11use hippocampus::search::Memory;
12
13/// Default token budgets.
14pub const TOKEN_BUDGETS: TokenBudget = TokenBudget {
15    system_prompt: 500,
16    user_model: 300,
17    conversation_history: 2000,
18    response_buffer: 400,
19    total_context: 8192, // Default for most models
20};
21
22/// Hardcoded greeting for first-ever chat session (0 facts).
23/// Printed directly — no LLM call needed.
24pub const ONBOARDING_GREETING: &str = "Hey! I'm Brain \u{2014} your personal memory engine. \
25I run locally on your machine and I'm here to remember what matters to you. \
26I don't know anything about you yet, so let's fix that. What's your name?";
27
28/// System-prompt addendum injected while the user has fewer than 5 facts.
29/// Makes the LLM naturally curious and question-asking during onboarding.
30pub const ONBOARDING_ADDENDUM: &str = r#"
31
32[ONBOARDING MODE — the user is new and you know very little about them]
33- After every user message, end your response with ONE short, focused follow-up question to learn about the user (name, role, projects, interests).
34- Keep responses to 1-3 sentences plus the question.
35- Sound warm, curious, and conversational — not like an intake form.
36- NEVER say "I don't have that in my memory yet" — instead, be proactive about learning.
37- Once you learn something, acknowledge it naturally and ask about the next thing."#;
38
39/// Token budget allocation.
40#[derive(Debug, Clone, Copy)]
41pub struct TokenBudget {
42    pub system_prompt: usize,
43    pub user_model: usize,
44    pub conversation_history: usize,
45    pub response_buffer: usize,
46    pub total_context: usize,
47}
48
49impl TokenBudget {
50    /// Calculate remaining budget for memories.
51    pub fn memory_budget(&self) -> usize {
52        self.total_context
53            .saturating_sub(self.system_prompt)
54            .saturating_sub(self.user_model)
55            .saturating_sub(self.conversation_history)
56            .saturating_sub(self.response_buffer)
57    }
58
59    /// Create budget for a specific model context size.
60    pub fn for_context_size(total_tokens: usize) -> Self {
61        let mut budget = TOKEN_BUDGETS;
62        budget.total_context = total_tokens;
63        budget
64    }
65}
66
67impl Default for TokenBudget {
68    fn default() -> Self {
69        TOKEN_BUDGETS
70    }
71}
72
73/// User profile data for context injection.
74#[derive(Debug, Clone, Default)]
75pub struct UserProfile {
76    pub name: Option<String>,
77    pub preferences: Vec<String>,
78    pub goals: Vec<String>,
79    pub facts: Vec<String>,
80}
81
82impl UserProfile {
83    /// Format as a context string.
84    pub fn to_context_string(&self) -> String {
85        let mut parts = Vec::new();
86
87        if let Some(name) = &self.name {
88            parts.push(format!("The user's name is {}.", name));
89        }
90
91        if !self.preferences.is_empty() {
92            parts.push(format!("User preferences: {}", self.preferences.join(", ")));
93        }
94
95        if !self.goals.is_empty() {
96            parts.push(format!("User goals: {}", self.goals.join(", ")));
97        }
98
99        if !self.facts.is_empty() {
100            parts.push(format!("Key facts: {}", self.facts.join("; ")));
101        }
102
103        parts.join(" ")
104    }
105
106    /// Estimate token count (rough approximation: ~4 chars per token).
107    pub fn estimate_tokens(&self) -> usize {
108        self.to_context_string().len() / 4
109    }
110}
111
112/// Context assembler — builds prompts respecting token budgets.
113pub struct ContextAssembler {
114    budget: TokenBudget,
115    system_prompt: String,
116    user_profile: UserProfile,
117}
118
119impl ContextAssembler {
120    /// Create a new context assembler.
121    pub fn new(budget: TokenBudget) -> Self {
122        Self {
123            budget,
124            system_prompt: Self::default_system_prompt(),
125            user_profile: UserProfile::default(),
126        }
127    }
128
129    /// Create with default budget.
130    pub fn with_defaults() -> Self {
131        Self::new(TOKEN_BUDGETS)
132    }
133
134    /// Set custom system prompt.
135    pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
136        self.system_prompt = prompt.into();
137        self
138    }
139
140    /// Set user profile.
141    pub fn with_user_profile(mut self, profile: UserProfile) -> Self {
142        self.user_profile = profile;
143        self
144    }
145
146    /// Get the default system prompt.
147    fn default_system_prompt() -> String {
148        r#"You are the SOUL of Brain OS — a biologically-inspired, proactive cognitive engine. You are not just an assistant; you are the user's digital hippocampus and prefrontal cortex, operating with deep context and long-term memory.
149
150Your Identity:
151- You are "Brain", the central intelligence of a local-first memory system.
152- You are private, secure, and run entirely on the user's machine.
153- Your purpose is to eliminate "context amnesia" by bridging the gap between siloed tools and the user's life.
154
155Your Capabilities:
156- Episodic Memory: You recall past experiences and conversations provided as context.
157- Semantic Memory: You maintain a web of facts about the user's world, projects, and habits.
158- Proactivity: You don't just react; you anticipate needs based on established patterns (provided in context).
159
160Operating Principles:
1611. TRUTH OVER HALLUCINATION: Answer based ONLY on the provided memories and general knowledge. If information is missing from memory, state: "I don't have that in my memory yet."
1622. SEAMLESS RECALL: Reference memories naturally ("You mentioned earlier...", "Based on what we discussed...").
1633. COGNITIVE CLARITY: Be concise, direct, and insightful. Avoid corporate fluff.
1644. CONTEXTUAL AWARENESS: Use the provided User Profile to tailor your tone and relevance.
1655. CURIOSITY: When you lack context about the user, ask one focused follow-up question. Learning about the user is part of your job — don't wait to be told.
166
167You are the user's partner in thought. Your goal is to make their digital life feel like a continuous, coherent stream of intelligence."#
168            .to_string()
169    }
170
171    /// Assemble context into messages.
172    ///
173    /// Takes retrieved memories and conversation history, returns
174    /// messages ready for the LLM.
175    pub fn assemble(
176        &self,
177        user_message: &str,
178        memories: &[Memory],
179        conversation_history: &[Message],
180    ) -> Vec<Message> {
181        let mut messages = Vec::new();
182        let memory_budget = self.budget.memory_budget();
183
184        // 1. System prompt with user profile
185        let system_content = if self.user_profile.estimate_tokens() > 0 {
186            format!(
187                "{}\n\nUser Profile: {}",
188                self.system_prompt,
189                self.user_profile.to_context_string()
190            )
191        } else {
192            self.system_prompt.clone()
193        };
194        messages.push(Message {
195            role: Role::System,
196            content: system_content,
197        });
198
199        // 2. Add memories as system context (if within budget)
200        let mut current_tokens = messages[0].content.len() / 4;
201        let mut memory_context = String::new();
202
203        for memory in memories {
204            let memory_text = if let Some(ref agent) = memory.agent {
205                format!(
206                    "- [{:?}, agent: {}] {}\n",
207                    memory.source, agent, memory.content
208                )
209            } else {
210                format!("- [{:?}] {}\n", memory.source, memory.content)
211            };
212            let memory_tokens = memory_text.len() / 4;
213
214            if current_tokens + memory_tokens > memory_budget {
215                break;
216            }
217
218            memory_context.push_str(&memory_text);
219            current_tokens += memory_tokens;
220        }
221
222        if !memory_context.is_empty() {
223            messages.push(Message {
224                role: Role::System,
225                content: format!("Relevant memories:\n{}", memory_context),
226            });
227        }
228
229        // 3. Add conversation history (respecting budget)
230        let mut history_tokens: usize = 0;
231        let mut included_history: Vec<Message> = Vec::new();
232
233        // Start from most recent and work backwards
234        for msg in conversation_history.iter().rev() {
235            let msg_tokens = msg.content.len() / 4;
236            if history_tokens + msg_tokens > self.budget.conversation_history {
237                break;
238            }
239            included_history.push(msg.clone());
240            history_tokens += msg_tokens;
241        }
242
243        // Reverse to maintain chronological order
244        included_history.reverse();
245        messages.extend(included_history);
246
247        // 4. Add current user message
248        messages.push(Message {
249            role: Role::User,
250            content: user_message.to_string(),
251        });
252
253        messages
254    }
255
256    /// Quick estimate of total tokens in messages.
257    pub fn estimate_tokens(messages: &[Message]) -> usize {
258        messages.iter().map(|m| m.content.len() / 4).sum()
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn test_token_budget_memory_allocation() {
268        let budget = TokenBudget::default();
269        let memory_budget = budget.memory_budget();
270
271        // 8192 - 500 - 300 - 2000 - 400 = 4992
272        assert_eq!(memory_budget, 4992);
273    }
274
275    #[test]
276    fn test_token_budget_for_context_size() {
277        let budget = TokenBudget::for_context_size(128000);
278        assert_eq!(budget.total_context, 128000);
279        assert_eq!(budget.memory_budget(), 128000 - 500 - 300 - 2000 - 400);
280    }
281
282    #[test]
283    fn test_user_profile_to_context() {
284        let profile = UserProfile {
285            name: Some("Alice".to_string()),
286            preferences: vec!["coffee".to_string(), "quiet mornings".to_string()],
287            goals: vec!["learn Rust".to_string()],
288            facts: vec!["works remotely".to_string()],
289        };
290
291        let context = profile.to_context_string();
292        assert!(context.contains("Alice"));
293        assert!(context.contains("coffee"));
294        assert!(context.contains("learn Rust"));
295    }
296
297    #[test]
298    fn test_context_assembler_basic() {
299        use hippocampus::search::MemorySource;
300
301        let assembler = ContextAssembler::with_defaults();
302
303        let memories = vec![Memory {
304            id: "1".to_string(),
305            content: "User likes Rust programming".to_string(),
306            source: MemorySource::Semantic,
307            score: 0.9,
308            importance: 0.8,
309            timestamp: "2026-01-01".to_string(),
310            agent: None,
311        }];
312
313        let history = vec![];
314        let messages = assembler.assemble("What language should I learn?", &memories, &history);
315
316        // Should have: system prompt, memory context, user message
317        assert!(messages.len() >= 2);
318        assert_eq!(
319            messages.last().unwrap().content,
320            "What language should I learn?"
321        );
322        assert_eq!(messages.last().unwrap().role, Role::User);
323    }
324
325    #[test]
326    fn test_context_assembler_agent_attribution() {
327        use hippocampus::search::MemorySource;
328
329        let assembler = ContextAssembler::with_defaults();
330
331        let memories = vec![
332            Memory {
333                id: "1".to_string(),
334                content: "User likes coffee".to_string(),
335                source: MemorySource::Episodic,
336                score: 0.9,
337                importance: 0.8,
338                timestamp: "2026-01-01".to_string(),
339                agent: Some("slack-bot".to_string()),
340            },
341            Memory {
342                id: "2".to_string(),
343                content: "User works remotely".to_string(),
344                source: MemorySource::Semantic,
345                score: 0.85,
346                importance: 0.7,
347                timestamp: "2026-01-02".to_string(),
348                agent: None,
349            },
350        ];
351
352        let messages = assembler.assemble("Tell me about the user", &memories, &[]);
353
354        let memory_msg = messages
355            .iter()
356            .find(|m| m.content.contains("Relevant memories"))
357            .expect("should have memory context message");
358
359        assert!(
360            memory_msg.content.contains("agent: slack-bot"),
361            "memory with agent should include attribution"
362        );
363        assert!(
364            !memory_msg.content.contains("agent: ")
365                || memory_msg.content.matches("agent: ").count() == 1,
366            "memory without agent should NOT include agent label"
367        );
368    }
369
370    #[test]
371    fn test_context_assembler_with_history() {
372        let assembler = ContextAssembler::with_defaults();
373
374        let history = vec![
375            Message {
376                role: Role::User,
377                content: "Hello".to_string(),
378            },
379            Message {
380                role: Role::Assistant,
381                content: "Hi there!".to_string(),
382            },
383        ];
384
385        let messages = assembler.assemble("How are you?", &[], &history);
386
387        // Should include system + history + current message
388        assert!(messages.len() >= 3);
389        assert_eq!(messages.last().unwrap().content, "How are you?");
390    }
391
392    #[test]
393    fn test_default_prompt_core_instructions() {
394        let assembler = ContextAssembler::with_defaults();
395        let messages = assembler.assemble("How do I connect OpenClaw?", &[], &[]);
396        let system = &messages[0].content;
397
398        assert!(system.contains("Brain"));
399        assert!(system.contains("SOUL"));
400        assert!(system.contains("biologically-inspired"));
401        assert!(system.contains("Episodic Memory"));
402        assert!(system.contains("Semantic Memory"));
403        assert!(system.contains("Proactivity"));
404        assert!(system.contains("TRUTH OVER HALLUCINATION"));
405        assert!(
406            system.contains("CURIOSITY"),
407            "SOUL prompt must include CURIOSITY operating principle"
408        );
409    }
410
411    #[test]
412    fn test_onboarding_greeting_exists() {
413        assert!(
414            ONBOARDING_GREETING.contains("Brain"),
415            "greeting must mention Brain"
416        );
417        assert!(
418            ONBOARDING_GREETING.contains("name"),
419            "greeting must ask for the user's name"
420        );
421    }
422
423    #[test]
424    fn test_onboarding_addendum_exists() {
425        assert!(
426            ONBOARDING_ADDENDUM.contains("ONBOARDING MODE"),
427            "addendum must contain ONBOARDING MODE marker"
428        );
429        assert!(
430            ONBOARDING_ADDENDUM.contains("follow-up question"),
431            "addendum must instruct follow-up questions"
432        );
433    }
434
435    #[test]
436    fn test_estimate_tokens() {
437        let messages = vec![Message {
438            role: Role::User,
439            content: "Hello world".to_string(),
440        }];
441
442        let tokens = ContextAssembler::estimate_tokens(&messages);
443        assert!(tokens > 0);
444        assert_eq!(tokens, 11 / 4); // "Hello world" is 11 chars
445    }
446}