Skip to main content

subcog/services/
context.rs

1//! Context builder service.
2//!
3//! Builds context for Claude Code hooks, selecting the most relevant memories.
4
5use crate::Result;
6use crate::models::{Memory, Namespace, SearchFilter, SearchMode};
7use crate::services::RecallService;
8use std::borrow::Cow;
9use std::collections::HashMap;
10
11// Context building limits - tunable parameters for memory selection
12/// Maximum memories to fetch for decisions (high priority).
13const CONTEXT_DECISIONS_LIMIT: usize = 5;
14/// Maximum memories to fetch for patterns.
15const CONTEXT_PATTERNS_LIMIT: usize = 3;
16/// Maximum memories to fetch for project context.
17const CONTEXT_PROJECT_LIMIT: usize = 3;
18/// Maximum memories to fetch for tech debt.
19const CONTEXT_TECH_DEBT_LIMIT: usize = 2;
20/// Default search result limit.
21const SEARCH_RESULT_LIMIT: usize = 10;
22/// Maximum recent memories to fetch for statistics.
23const RECENT_MEMORIES_LIMIT: usize = 100;
24/// Maximum top tags to return.
25const TOP_TAGS_LIMIT: usize = 10;
26/// Maximum topics to track.
27const MAX_TOPICS: usize = 10;
28/// Tokens per character approximation (for context truncation).
29const TOKENS_PER_CHAR: usize = 4;
30/// Maximum length for memory content preview in formatted output.
31const MEMORY_CONTENT_PREVIEW_LENGTH: usize = 200;
32/// Maximum words to extract for topic summary.
33const TOPIC_WORDS_LIMIT: usize = 5;
34/// Maximum length for topic display.
35const MAX_TOPIC_DISPLAY_LENGTH: usize = 50;
36
37/// Statistics about memories in the system.
38#[derive(Debug, Clone, Default)]
39pub struct MemoryStatistics {
40    /// Total memory count.
41    pub total_count: usize,
42    /// Count per namespace.
43    pub namespace_counts: HashMap<String, usize>,
44    /// Most common tags (top 10).
45    pub top_tags: Vec<(String, usize)>,
46    /// Recent topics extracted from memories.
47    pub recent_topics: Vec<String>,
48}
49
50/// Service for building context for AI assistants.
51pub struct ContextBuilderService {
52    /// Recall service for searching memories.
53    recall: Option<RecallService>,
54}
55
56impl ContextBuilderService {
57    /// Creates a new context builder service.
58    #[must_use]
59    pub const fn new() -> Self {
60        Self { recall: None }
61    }
62
63    /// Creates a context builder with a recall service.
64    #[must_use]
65    pub const fn with_recall(recall: RecallService) -> Self {
66        Self {
67            recall: Some(recall),
68        }
69    }
70
71    /// Builds context for the current session.
72    ///
73    /// # Errors
74    ///
75    /// Returns an error if context building fails.
76    pub fn build_context(&self, max_tokens: usize) -> Result<String> {
77        // Estimate tokens per character (rough approximation)
78        let max_chars = max_tokens * TOKENS_PER_CHAR;
79
80        let mut context_parts = Vec::new();
81
82        // Add recent decisions (high priority)
83        if let Some(decisions) =
84            self.get_relevant_memories(Namespace::Decisions, CONTEXT_DECISIONS_LIMIT)?
85            && !decisions.is_empty()
86        {
87            context_parts.push(format_section("Recent Decisions", &decisions));
88        }
89
90        // Add active patterns
91        if let Some(patterns) =
92            self.get_relevant_memories(Namespace::Patterns, CONTEXT_PATTERNS_LIMIT)?
93            && !patterns.is_empty()
94        {
95            context_parts.push(format_section("Active Patterns", &patterns));
96        }
97
98        // Add relevant context
99        if let Some(ctx) = self.get_relevant_memories(Namespace::Context, CONTEXT_PROJECT_LIMIT)?
100            && !ctx.is_empty()
101        {
102            context_parts.push(format_section("Project Context", &ctx));
103        }
104
105        // Add known tech debt
106        if let Some(debt) =
107            self.get_relevant_memories(Namespace::TechDebt, CONTEXT_TECH_DEBT_LIMIT)?
108            && !debt.is_empty()
109        {
110            context_parts.push(format_section("Known Tech Debt", &debt));
111        }
112
113        // Combine and truncate to fit token budget
114        let full_context = context_parts.join("\n\n");
115
116        if full_context.len() > max_chars {
117            Ok(truncate_context(&full_context, max_chars))
118        } else {
119            Ok(full_context)
120        }
121    }
122
123    /// Builds context for a specific query.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error if context building fails.
128    pub fn build_query_context(&self, query: &str, max_tokens: usize) -> Result<String> {
129        let max_chars = max_tokens * TOKENS_PER_CHAR;
130
131        let recall = self
132            .recall
133            .as_ref()
134            .ok_or_else(|| crate::Error::OperationFailed {
135                operation: "build_query_context".to_string(),
136                cause: "No recall service configured".to_string(),
137            })?;
138
139        // Search for relevant memories
140        let result = recall.search(
141            query,
142            SearchMode::Hybrid,
143            &SearchFilter::new(),
144            SEARCH_RESULT_LIMIT,
145        )?;
146
147        if result.memories.is_empty() {
148            return Ok(String::new());
149        }
150
151        let mut context_parts = Vec::new();
152        context_parts.push("# Relevant Memories".to_string());
153
154        for hit in &result.memories {
155            let memory = &hit.memory;
156            context_parts.push(format!(
157                "## {} ({})\n{}\n_Score: {:.2}_",
158                memory.namespace,
159                memory.id.as_str(),
160                memory.content,
161                hit.score
162            ));
163        }
164
165        let full_context = context_parts.join("\n\n");
166
167        if full_context.len() > max_chars {
168            Ok(truncate_context(&full_context, max_chars))
169        } else {
170            Ok(full_context)
171        }
172    }
173
174    /// Gets relevant memories for a namespace.
175    ///
176    /// Returns `None` if recall service is not configured, otherwise returns
177    /// an empty vector (placeholder for full storage integration).
178    #[allow(clippy::unnecessary_wraps)] // Returns Result for API consistency with other methods
179    const fn get_relevant_memories(
180        &self,
181        _namespace: Namespace,
182        _limit: usize,
183    ) -> Result<Option<Vec<Memory>>> {
184        // Without a recall service, return None
185        if self.recall.is_none() {
186            return Ok(None);
187        }
188
189        // For now, return empty since we'd need full storage integration
190        Ok(Some(Vec::new()))
191    }
192
193    /// Estimates the token count for a string.
194    #[must_use]
195    pub const fn estimate_tokens(text: &str) -> usize {
196        // Rough estimation: uses TOKENS_PER_CHAR for English text
197        text.len() / TOKENS_PER_CHAR
198    }
199
200    /// Gets memory statistics for session context.
201    ///
202    /// Uses [`RecallService::list_all_with_content`] to fetch memories with full content
203    /// for topic extraction. This is intentionally more expensive than the lightweight
204    /// [`RecallService::list_all`] used by MCP tools.
205    ///
206    /// # Errors
207    ///
208    /// Returns an error if statistics gathering fails.
209    pub fn get_statistics(&self) -> Result<MemoryStatistics> {
210        let Some(recall) = &self.recall else {
211            return Ok(MemoryStatistics::default());
212        };
213
214        // Fetch all memories with content for topic extraction
215        let result = recall.list_all_with_content(&SearchFilter::new(), RECENT_MEMORIES_LIMIT)?;
216
217        let mut namespace_counts: HashMap<String, usize> = HashMap::new();
218        let mut tag_counts: HashMap<String, usize> = HashMap::new();
219        let mut topics: Vec<String> = Vec::new();
220
221        for hit in &result.memories {
222            let memory = &hit.memory;
223
224            // Count namespaces
225            *namespace_counts
226                .entry(memory.namespace.as_str().to_string())
227                .or_insert(0) += 1;
228
229            // Count tags
230            for tag in &memory.tags {
231                *tag_counts.entry(tag.clone()).or_insert(0) += 1;
232            }
233
234            // Extract topics (first few words of content)
235            if let Some(topic) = extract_topic(&memory.content) {
236                add_topic_if_unique(&mut topics, topic);
237            }
238        }
239
240        // Sort tags by count
241        let mut top_tags: Vec<(String, usize)> = tag_counts.into_iter().collect();
242        top_tags.sort_by(|a, b| b.1.cmp(&a.1));
243        top_tags.truncate(TOP_TAGS_LIMIT);
244
245        Ok(MemoryStatistics {
246            total_count: result.memories.len(),
247            namespace_counts,
248            top_tags,
249            recent_topics: topics,
250        })
251    }
252}
253
254impl Default for ContextBuilderService {
255    fn default() -> Self {
256        Self::new()
257    }
258}
259
260/// Formats a section with a title and memories.
261fn format_section(title: &str, memories: &[Memory]) -> String {
262    let mut parts = vec![format!("## {title}")];
263
264    for memory in memories {
265        parts.push(format!(
266            "- **{}** ({}): {}",
267            memory.namespace,
268            memory.id.as_str(),
269            truncate_content(&memory.content, MEMORY_CONTENT_PREVIEW_LENGTH)
270        ));
271    }
272
273    parts.join("\n")
274}
275
276/// Truncates content to a maximum length.
277///
278/// # Performance
279///
280/// Returns `Cow::Borrowed` when no truncation is needed (zero allocation).
281/// Only allocates when truncation is required.
282fn truncate_content(content: &str, max_len: usize) -> Cow<'_, str> {
283    if content.len() <= max_len {
284        Cow::Borrowed(content)
285    } else {
286        Cow::Owned(format!("{}...", &content[..max_len - 3]))
287    }
288}
289
290/// Adds a topic to the list if it's unique and list has space.
291fn add_topic_if_unique(topics: &mut Vec<String>, topic: String) {
292    if !topics.contains(&topic) && topics.len() < MAX_TOPICS {
293        topics.push(topic);
294    }
295}
296
297/// Extracts a topic summary from memory content.
298fn extract_topic(content: &str) -> Option<String> {
299    // Get first meaningful words (skip common prefixes)
300    let words: Vec<&str> = content
301        .split_whitespace()
302        .filter(|w| w.len() > 2)
303        .take(TOPIC_WORDS_LIMIT)
304        .collect();
305
306    if words.is_empty() {
307        return None;
308    }
309
310    let topic = words.join(" ");
311    if topic.len() > MAX_TOPIC_DISPLAY_LENGTH {
312        Some(format!("{}...", &topic[..MAX_TOPIC_DISPLAY_LENGTH - 3]))
313    } else {
314        Some(topic)
315    }
316}
317
318/// Truncates context to fit within a character limit.
319#[allow(clippy::option_if_let_else)] // if-let chain is clearer than nested map_or_else
320fn truncate_context(context: &str, max_chars: usize) -> String {
321    if context.len() <= max_chars {
322        return context.to_string();
323    }
324
325    // Try to truncate at a section boundary
326    let truncated = &context[..max_chars];
327    if let Some(last_section) = truncated.rfind("\n##") {
328        format!(
329            "{}\n\n_[Context truncated due to token limit]_",
330            &context[..last_section]
331        )
332    } else if let Some(last_newline) = truncated.rfind('\n') {
333        format!(
334            "{}\n\n_[Context truncated due to token limit]_",
335            &context[..last_newline]
336        )
337    } else {
338        format!("{}...", &context[..max_chars - 3])
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn test_context_builder_creation() {
348        let service = ContextBuilderService::default();
349        let result = service.build_context(1000);
350        assert!(result.is_ok());
351    }
352
353    #[test]
354    fn test_estimate_tokens() {
355        let text = "This is a test string with about 40 characters.";
356        let tokens = ContextBuilderService::estimate_tokens(text);
357        assert!(tokens > 0);
358        assert!(tokens < text.len());
359    }
360
361    #[test]
362    fn test_truncate_content() {
363        let short = "Short text";
364        assert_eq!(truncate_content(short, 100), short);
365
366        let long =
367            "This is a longer text that should be truncated because it exceeds the maximum length";
368        let truncated = truncate_content(long, 30);
369        assert!(truncated.ends_with("..."));
370        assert!(truncated.len() <= 30);
371    }
372
373    #[test]
374    fn test_truncate_context() {
375        let context =
376            "## Section 1\nContent 1\n\n## Section 2\nContent 2\n\n## Section 3\nContent 3";
377
378        // Should fit without truncation
379        let result = truncate_context(context, 1000);
380        assert_eq!(result, context);
381
382        // Should truncate at section boundary
383        let result = truncate_context(context, 40);
384        assert!(result.contains("truncated"));
385    }
386
387    #[test]
388    fn test_format_section() {
389        use crate::models::{Domain, MemoryId, MemoryStatus};
390
391        let memories = vec![Memory {
392            id: MemoryId::new("test_id"),
393            content: "Test content".to_string(),
394            namespace: Namespace::Decisions,
395            domain: Domain::new(),
396            project_id: None,
397            branch: None,
398            file_path: None,
399            status: MemoryStatus::Active,
400            created_at: 0,
401            updated_at: 0,
402            tombstoned_at: None,
403            expires_at: None,
404            embedding: None,
405            tags: vec![],
406            #[cfg(feature = "group-scope")]
407            group_id: None,
408            source: None,
409            is_summary: false,
410            source_memory_ids: None,
411            consolidation_timestamp: None,
412        }];
413
414        let section = format_section("Test Section", &memories);
415        assert!(section.contains("## Test Section"));
416        assert!(section.contains("Test content"));
417    }
418
419    #[test]
420    fn test_build_query_context_no_recall() {
421        let service = ContextBuilderService::default();
422        let result = service.build_query_context("test", 1000);
423        assert!(result.is_err());
424    }
425}