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 (conservative: ~2 chars per token to handle non-ASCII safely).
107    pub fn estimate_tokens(&self) -> usize {
108        self.to_context_string().chars().count() / 2
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: Ground answers in (a) the provided memories, (b) the live conversation history above this message, and (c) general knowledge. If a *fact about the user* is genuinely absent from memory AND not present in the conversation, state: "I don't have that in my memory yet." Do NOT say this when the user is asking about things discussed earlier in the current conversation — answer from the message thread itself.
1622. SEAMLESS RECALL: Reference memories and prior turns naturally ("You mentioned earlier...", "Based on what we discussed...").
1633. COGNITIVE CLARITY: Be concise, direct, and insightful. Avoid corporate fluff. Match response length to the question — simple greetings get one or two sentences, not tables.
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.
1666. FORMATTING: The user's terminal renders markdown. Use it lightly when it helps (lists for multi-item answers, **bold** for emphasis, `code` for identifiers). Skip headings and tables for short replies.
167
168You are the user's partner in thought. Your goal is to make their digital life feel like a continuous, coherent stream of intelligence."#
169            .to_string()
170    }
171
172    /// Assemble context into messages.
173    ///
174    /// Takes retrieved memories and conversation history, returns
175    /// messages ready for the LLM.
176    pub fn assemble(
177        &self,
178        user_message: &str,
179        memories: &[Memory],
180        conversation_history: &[Message],
181    ) -> Vec<Message> {
182        self.assemble_with_addendum(user_message, memories, conversation_history, None)
183    }
184
185    /// Like [`assemble`], but appends `addendum` to the system prompt if provided.
186    /// Used to switch prompt modes per-turn (e.g. onboarding) without mutating
187    /// the shared assembler.
188    pub fn assemble_with_addendum(
189        &self,
190        user_message: &str,
191        memories: &[Memory],
192        conversation_history: &[Message],
193        addendum: Option<&str>,
194    ) -> Vec<Message> {
195        let mut messages = Vec::new();
196        let memory_budget = self.budget.memory_budget();
197
198        // 1. System prompt with optional addendum and user profile
199        let base_prompt = match addendum {
200            Some(extra) if !extra.is_empty() => {
201                format!("{}{}", self.system_prompt, extra)
202            }
203            _ => self.system_prompt.clone(),
204        };
205        let system_content = if self.user_profile.estimate_tokens() > 0 {
206            format!(
207                "{}\n\nUser Profile: {}",
208                base_prompt,
209                self.user_profile.to_context_string()
210            )
211        } else {
212            base_prompt
213        };
214        messages.push(Message {
215            role: Role::System,
216            content: system_content,
217        });
218
219        // 2. Add memories as system context (if within budget)
220        let mut current_tokens = messages[0].content.chars().count() / 2;
221        let mut memory_context = String::new();
222
223        for memory in memories {
224            let memory_text = if let Some(ref agent) = memory.agent {
225                format!(
226                    "- [{:?}, agent: {}] {}\n",
227                    memory.source, agent, memory.content
228                )
229            } else {
230                format!("- [{:?}] {}\n", memory.source, memory.content)
231            };
232            let memory_tokens = memory_text.chars().count() / 2;
233
234            if current_tokens + memory_tokens > memory_budget {
235                break;
236            }
237
238            memory_context.push_str(&memory_text);
239            current_tokens += memory_tokens;
240        }
241
242        if !memory_context.is_empty() {
243            messages.push(Message {
244                role: Role::System,
245                content: format!("Relevant memories:\n{}", memory_context),
246            });
247        }
248
249        // 3. Add conversation history (respecting budget)
250        let mut history_tokens: usize = 0;
251        let mut included_history: Vec<Message> = Vec::new();
252
253        // Start from most recent and work backwards
254        for msg in conversation_history.iter().rev() {
255            let msg_tokens = msg.content.chars().count() / 2;
256            if history_tokens + msg_tokens > self.budget.conversation_history {
257                break;
258            }
259            included_history.push(msg.clone());
260            history_tokens += msg_tokens;
261        }
262
263        // Reverse to maintain chronological order
264        included_history.reverse();
265        messages.extend(included_history);
266
267        // 4. Add current user message
268        messages.push(Message {
269            role: Role::User,
270            content: user_message.to_string(),
271        });
272
273        messages
274    }
275
276    /// Quick estimate of total tokens in messages.
277    pub fn estimate_tokens(messages: &[Message]) -> usize {
278        messages.iter().map(|m| m.content.chars().count() / 2).sum()
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn test_token_budget_memory_allocation() {
288        let budget = TokenBudget::default();
289        let memory_budget = budget.memory_budget();
290
291        // 8192 - 500 - 300 - 2000 - 400 = 4992
292        assert_eq!(memory_budget, 4992);
293    }
294
295    #[test]
296    fn test_token_budget_for_context_size() {
297        let budget = TokenBudget::for_context_size(128000);
298        assert_eq!(budget.total_context, 128000);
299        assert_eq!(budget.memory_budget(), 128000 - 500 - 300 - 2000 - 400);
300    }
301
302    #[test]
303    fn test_user_profile_to_context() {
304        let profile = UserProfile {
305            name: Some("Alice".to_string()),
306            preferences: vec!["coffee".to_string(), "quiet mornings".to_string()],
307            goals: vec!["learn Rust".to_string()],
308            facts: vec!["works remotely".to_string()],
309        };
310
311        let context = profile.to_context_string();
312        assert!(context.contains("Alice"));
313        assert!(context.contains("coffee"));
314        assert!(context.contains("learn Rust"));
315    }
316
317    #[test]
318    fn test_assemble_with_addendum_injects_into_system_prompt() {
319        let assembler = ContextAssembler::with_defaults();
320        let messages = assembler.assemble_with_addendum("hi", &[], &[], Some(ONBOARDING_ADDENDUM));
321
322        let system = messages
323            .iter()
324            .find(|m| matches!(m.role, Role::System))
325            .expect("system message");
326        assert!(
327            system.content.contains("[ONBOARDING MODE"),
328            "onboarding addendum should be present in system prompt"
329        );
330    }
331
332    #[test]
333    fn test_assemble_without_addendum_matches_plain_assemble() {
334        let assembler = ContextAssembler::with_defaults();
335        let a = assembler.assemble("hi", &[], &[]);
336        let b = assembler.assemble_with_addendum("hi", &[], &[], None);
337        assert_eq!(a.len(), b.len());
338        assert_eq!(a[0].content, b[0].content);
339    }
340
341    #[test]
342    fn test_context_assembler_basic() {
343        use hippocampus::search::MemorySource;
344
345        let assembler = ContextAssembler::with_defaults();
346
347        let memories = vec![Memory {
348            id: "1".to_string(),
349            content: "User likes Rust programming".to_string(),
350            source: MemorySource::Semantic,
351            score: 0.9,
352            importance: 0.8,
353            timestamp: "2026-01-01".to_string(),
354            agent: None,
355        }];
356
357        let history = vec![];
358        let messages = assembler.assemble("What language should I learn?", &memories, &history);
359
360        // Should have: system prompt, memory context, user message
361        assert!(messages.len() >= 2);
362        assert_eq!(
363            messages.last().unwrap().content,
364            "What language should I learn?"
365        );
366        assert_eq!(messages.last().unwrap().role, Role::User);
367    }
368
369    #[test]
370    fn test_context_assembler_agent_attribution() {
371        use hippocampus::search::MemorySource;
372
373        let assembler = ContextAssembler::with_defaults();
374
375        let memories = vec![
376            Memory {
377                id: "1".to_string(),
378                content: "User likes coffee".to_string(),
379                source: MemorySource::Episodic,
380                score: 0.9,
381                importance: 0.8,
382                timestamp: "2026-01-01".to_string(),
383                agent: Some("chat-bot".to_string()),
384            },
385            Memory {
386                id: "2".to_string(),
387                content: "User works remotely".to_string(),
388                source: MemorySource::Semantic,
389                score: 0.85,
390                importance: 0.7,
391                timestamp: "2026-01-02".to_string(),
392                agent: None,
393            },
394        ];
395
396        let messages = assembler.assemble("Tell me about the user", &memories, &[]);
397
398        let memory_msg = messages
399            .iter()
400            .find(|m| m.content.contains("Relevant memories"))
401            .expect("should have memory context message");
402
403        assert!(
404            memory_msg.content.contains("agent: chat-bot"),
405            "memory with agent should include attribution"
406        );
407        assert!(
408            !memory_msg.content.contains("agent: ")
409                || memory_msg.content.matches("agent: ").count() == 1,
410            "memory without agent should NOT include agent label"
411        );
412    }
413
414    #[test]
415    fn test_context_assembler_with_history() {
416        let assembler = ContextAssembler::with_defaults();
417
418        let history = vec![
419            Message {
420                role: Role::User,
421                content: "Hello".to_string(),
422            },
423            Message {
424                role: Role::Assistant,
425                content: "Hi there!".to_string(),
426            },
427        ];
428
429        let messages = assembler.assemble("How are you?", &[], &history);
430
431        // Should include system + history + current message
432        assert!(messages.len() >= 3);
433        assert_eq!(messages.last().unwrap().content, "How are you?");
434    }
435
436    #[test]
437    fn test_default_prompt_core_instructions() {
438        let assembler = ContextAssembler::with_defaults();
439        let messages = assembler.assemble("How do I connect OpenClaw?", &[], &[]);
440        let system = &messages[0].content;
441
442        assert!(system.contains("Brain"));
443        assert!(system.contains("SOUL"));
444        assert!(system.contains("biologically-inspired"));
445        assert!(system.contains("Episodic Memory"));
446        assert!(system.contains("Semantic Memory"));
447        assert!(system.contains("Proactivity"));
448        assert!(system.contains("TRUTH OVER HALLUCINATION"));
449        assert!(
450            system.contains("CURIOSITY"),
451            "SOUL prompt must include CURIOSITY operating principle"
452        );
453    }
454
455    #[test]
456    fn test_onboarding_greeting_exists() {
457        assert!(
458            ONBOARDING_GREETING.contains("Brain"),
459            "greeting must mention Brain"
460        );
461        assert!(
462            ONBOARDING_GREETING.contains("name"),
463            "greeting must ask for the user's name"
464        );
465    }
466
467    #[test]
468    fn test_onboarding_addendum_exists() {
469        assert!(
470            ONBOARDING_ADDENDUM.contains("ONBOARDING MODE"),
471            "addendum must contain ONBOARDING MODE marker"
472        );
473        assert!(
474            ONBOARDING_ADDENDUM.contains("follow-up question"),
475            "addendum must instruct follow-up questions"
476        );
477    }
478
479    #[test]
480    fn test_estimate_tokens() {
481        let messages = vec![Message {
482            role: Role::User,
483            content: "Hello world".to_string(),
484        }];
485
486        let tokens = ContextAssembler::estimate_tokens(&messages);
487        assert!(tokens > 0);
488        assert_eq!(tokens, 11 / 2); // "Hello world" is 11 chars, ~2 chars/token
489    }
490}