Skip to main content

engram/intelligence/
auto_capture.rs

1//! Auto-Capture Mode for Proactive Memory (RML-903)
2//!
3//! Automatically detects and captures valuable information from conversations:
4//! - Key decisions and their rationale
5//! - Action items and todos
6//! - Important facts and context
7//! - User preferences and patterns
8//! - Technical learnings and insights
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::collections::HashSet;
13
14use crate::types::{Memory, MemoryType};
15
16/// Configuration for auto-capture behavior
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct AutoCaptureConfig {
19    /// Enable auto-capture mode
20    pub enabled: bool,
21    /// Minimum confidence threshold for capture (0.0 - 1.0)
22    pub min_confidence: f32,
23    /// Types of content to capture
24    pub capture_types: HashSet<CaptureType>,
25    /// Maximum captures per conversation turn
26    pub max_per_turn: usize,
27    /// Require user confirmation before saving
28    pub require_confirmation: bool,
29    /// Keywords that trigger capture consideration
30    pub trigger_keywords: Vec<String>,
31    /// Patterns to ignore (e.g., greetings, small talk)
32    pub ignore_patterns: Vec<String>,
33}
34
35impl Default for AutoCaptureConfig {
36    fn default() -> Self {
37        Self {
38            enabled: true,
39            min_confidence: 0.6,
40            capture_types: vec![
41                CaptureType::Decision,
42                CaptureType::ActionItem,
43                CaptureType::KeyFact,
44                CaptureType::Preference,
45                CaptureType::Learning,
46            ]
47            .into_iter()
48            .collect(),
49            max_per_turn: 3,
50            require_confirmation: true,
51            trigger_keywords: vec![
52                "decide".to_string(),
53                "decided".to_string(),
54                "decision".to_string(),
55                "todo".to_string(),
56                "remember".to_string(),
57                "important".to_string(),
58                "always".to_string(),
59                "never".to_string(),
60                "prefer".to_string(),
61                "learned".to_string(),
62                "note".to_string(),
63                "key".to_string(),
64                "critical".to_string(),
65                "must".to_string(),
66                "should".to_string(),
67            ],
68            ignore_patterns: vec![
69                "hello".to_string(),
70                "hi".to_string(),
71                "thanks".to_string(),
72                "thank you".to_string(),
73                "bye".to_string(),
74                "goodbye".to_string(),
75                "ok".to_string(),
76                "okay".to_string(),
77                "sure".to_string(),
78                "yes".to_string(),
79                "no".to_string(),
80            ],
81        }
82    }
83}
84
85/// Types of content that can be auto-captured
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
87pub enum CaptureType {
88    /// A decision made during conversation
89    Decision,
90    /// An action item or task to do
91    ActionItem,
92    /// An important fact or piece of context
93    KeyFact,
94    /// A user preference or pattern
95    Preference,
96    /// A technical learning or insight
97    Learning,
98    /// A question for follow-up
99    Question,
100    /// An issue or problem identified
101    Issue,
102    /// A code snippet or technical artifact
103    CodeSnippet,
104}
105
106impl CaptureType {
107    /// Convert to MemoryType for storage
108    pub fn to_memory_type(&self) -> MemoryType {
109        match self {
110            CaptureType::Decision => MemoryType::Decision,
111            CaptureType::ActionItem => MemoryType::Todo,
112            CaptureType::KeyFact => MemoryType::Note,
113            CaptureType::Preference => MemoryType::Preference,
114            CaptureType::Learning => MemoryType::Learning,
115            CaptureType::Question => MemoryType::Note,
116            CaptureType::Issue => MemoryType::Issue,
117            CaptureType::CodeSnippet => MemoryType::Note,
118        }
119    }
120
121    /// Get detection patterns for this type
122    fn patterns(&self) -> Vec<&'static str> {
123        match self {
124            CaptureType::Decision => vec![
125                "decided to",
126                "decision is",
127                "we'll go with",
128                "let's use",
129                "the approach is",
130                "we chose",
131                "going forward",
132                "from now on",
133            ],
134            CaptureType::ActionItem => vec![
135                "todo:",
136                "action item:",
137                "need to",
138                "should do",
139                "will do",
140                "must do",
141                "task:",
142                "follow up",
143                "remember to",
144            ],
145            CaptureType::KeyFact => vec![
146                "important:",
147                "note:",
148                "key point",
149                "the fact is",
150                "actually,",
151                "turns out",
152                "discovered that",
153                "found that",
154            ],
155            CaptureType::Preference => vec![
156                "prefer",
157                "like to",
158                "always use",
159                "never use",
160                "my style",
161                "i want",
162                "i don't want",
163                "please always",
164                "please never",
165            ],
166            CaptureType::Learning => vec![
167                "learned that",
168                "til:",
169                "today i learned",
170                "insight:",
171                "realization:",
172                "now i understand",
173                "turns out that",
174            ],
175            CaptureType::Question => vec![
176                "question:",
177                "need to find out",
178                "investigate",
179                "look into",
180                "figure out",
181                "unclear about",
182            ],
183            CaptureType::Issue => vec![
184                "bug:",
185                "issue:",
186                "problem:",
187                "error:",
188                "broken:",
189                "doesn't work",
190                "failing",
191            ],
192            CaptureType::CodeSnippet => vec![
193                "```", "code:", "snippet:", "function", "class", "const", "let", "fn ",
194            ],
195        }
196    }
197}
198
199/// A candidate for auto-capture
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct CaptureCandidate {
202    /// The content to potentially capture
203    pub content: String,
204    /// Detected type
205    pub capture_type: CaptureType,
206    /// Confidence score (0.0 - 1.0)
207    pub confidence: f32,
208    /// Source context (where it came from)
209    pub source: String,
210    /// Suggested tags
211    pub suggested_tags: Vec<String>,
212    /// Suggested importance (0.0 - 1.0)
213    pub suggested_importance: f32,
214    /// Detection timestamp
215    pub detected_at: DateTime<Utc>,
216    /// Reason for capture
217    pub reason: String,
218}
219
220impl CaptureCandidate {
221    /// Convert to a Memory for storage
222    pub fn to_memory(&self) -> Memory {
223        Memory {
224            id: 0, // Will be assigned by storage
225            content: self.content.clone(),
226            memory_type: self.capture_type.to_memory_type(),
227            tags: self.suggested_tags.clone(),
228            metadata: std::collections::HashMap::new(),
229            importance: self.suggested_importance,
230            access_count: 0,
231            created_at: chrono::Utc::now(),
232            updated_at: chrono::Utc::now(),
233            last_accessed_at: None,
234            owner_id: None,
235            visibility: crate::types::Visibility::Private,
236            scope: crate::types::MemoryScope::Global,
237            workspace: "default".to_string(),
238            tier: crate::types::MemoryTier::Permanent,
239            version: 1,
240            has_embedding: false,
241            expires_at: None,
242            content_hash: None, // Will be computed on storage
243            event_time: None,
244            event_duration_seconds: None,
245            trigger_pattern: None,
246            procedure_success_count: 0,
247            procedure_failure_count: 0,
248            summary_of_id: None,
249            lifecycle_state: crate::types::LifecycleState::Active,
250            media_url: None,
251        }
252    }
253}
254
255/// Auto-capture engine
256pub struct AutoCaptureEngine {
257    config: AutoCaptureConfig,
258}
259
260impl AutoCaptureEngine {
261    pub fn new(config: AutoCaptureConfig) -> Self {
262        Self { config }
263    }
264
265    pub fn with_default_config() -> Self {
266        Self::new(AutoCaptureConfig::default())
267    }
268
269    /// Analyze text and detect potential captures
270    pub fn analyze(&self, text: &str, source: &str) -> Vec<CaptureCandidate> {
271        if !self.config.enabled {
272            return Vec::new();
273        }
274
275        // Skip if matches ignore patterns
276        let text_lower = text.to_lowercase();
277        if self.should_ignore(&text_lower) {
278            return Vec::new();
279        }
280
281        let mut candidates = Vec::new();
282
283        // Check each capture type
284        for capture_type in &self.config.capture_types {
285            if let Some(candidate) = self.detect_type(text, &text_lower, *capture_type, source) {
286                if candidate.confidence >= self.config.min_confidence {
287                    candidates.push(candidate);
288                }
289            }
290        }
291
292        // Sort by confidence and limit
293        candidates.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
294        candidates.truncate(self.config.max_per_turn);
295
296        candidates
297    }
298
299    /// Check if text should be ignored
300    fn should_ignore(&self, text_lower: &str) -> bool {
301        // Too short
302        if text_lower.len() < 10 {
303            return true;
304        }
305
306        // Matches ignore patterns
307        for pattern in &self.config.ignore_patterns {
308            if text_lower.trim() == pattern.as_str() {
309                return true;
310            }
311        }
312
313        false
314    }
315
316    /// Detect a specific capture type
317    fn detect_type(
318        &self,
319        text: &str,
320        text_lower: &str,
321        capture_type: CaptureType,
322        source: &str,
323    ) -> Option<CaptureCandidate> {
324        let patterns = capture_type.patterns();
325        let mut confidence: f32 = 0.0;
326        let mut matched_pattern = "";
327
328        // Check patterns
329        for pattern in patterns {
330            if text_lower.contains(pattern) {
331                confidence = 0.7;
332                matched_pattern = pattern;
333                break;
334            }
335        }
336
337        // Boost confidence for trigger keywords
338        let trigger_count = self
339            .config
340            .trigger_keywords
341            .iter()
342            .filter(|kw| text_lower.contains(kw.as_str()))
343            .count();
344        confidence += (trigger_count as f32 * 0.05).min(0.2);
345
346        // Boost for explicit markers
347        if text_lower.contains("remember:") || text_lower.contains("important:") {
348            confidence += 0.15;
349        }
350
351        // Minimum threshold check
352        if confidence < 0.3 {
353            return None;
354        }
355
356        // Extract the relevant content
357        let content = self.extract_content(text, capture_type);
358        if content.is_empty() {
359            return None;
360        }
361
362        // Suggest tags based on content
363        let suggested_tags = self.suggest_tags(&content, capture_type);
364
365        // Calculate importance
366        let suggested_importance = self.calculate_importance(&content, capture_type, confidence);
367
368        Some(CaptureCandidate {
369            content,
370            capture_type,
371            confidence: confidence.min(1.0),
372            source: source.to_string(),
373            suggested_tags,
374            suggested_importance,
375            detected_at: Utc::now(),
376            reason: format!("Matched pattern: '{}'", matched_pattern),
377        })
378    }
379
380    /// Extract the relevant content for capture
381    fn extract_content(&self, text: &str, capture_type: CaptureType) -> String {
382        let text_lower = text.to_lowercase();
383
384        // Try to extract after common markers
385        let markers = match capture_type {
386            CaptureType::Decision => vec!["decided to", "decision:", "we'll"],
387            CaptureType::ActionItem => vec!["todo:", "action:", "need to"],
388            CaptureType::KeyFact => vec!["important:", "note:", "key:"],
389            CaptureType::Preference => vec!["prefer", "always", "never"],
390            CaptureType::Learning => vec!["learned", "til:", "insight:"],
391            CaptureType::Question => vec!["question:", "investigate"],
392            CaptureType::Issue => vec!["bug:", "issue:", "problem:"],
393            CaptureType::CodeSnippet => vec!["```", "code:"],
394        };
395
396        for marker in markers {
397            if let Some(pos) = text_lower.find(marker) {
398                let start = pos + marker.len();
399                let extracted = text[start..].trim();
400                // Take until end of sentence or paragraph
401                let end = extracted
402                    .find(|c: char| c == '\n' || c == '.' && extracted.len() > 10)
403                    .unwrap_or(extracted.len().min(500));
404                return extracted[..end].trim().to_string();
405            }
406        }
407
408        // If no marker found, use the whole text (truncated)
409        let max_len = 500;
410        if text.len() <= max_len {
411            text.trim().to_string()
412        } else {
413            format!("{}...", &text[..max_len].trim())
414        }
415    }
416
417    /// Suggest tags based on content
418    fn suggest_tags(&self, content: &str, capture_type: CaptureType) -> Vec<String> {
419        let mut tags = Vec::new();
420        let content_lower = content.to_lowercase();
421
422        // Add type-based tag
423        tags.push(format!("auto-{:?}", capture_type).to_lowercase());
424
425        // Common technology tags
426        let tech_tags = [
427            ("rust", "rust"),
428            ("python", "python"),
429            ("javascript", "javascript"),
430            ("typescript", "typescript"),
431            ("react", "react"),
432            ("sql", "sql"),
433            ("api", "api"),
434            ("database", "database"),
435            ("frontend", "frontend"),
436            ("backend", "backend"),
437        ];
438
439        for (keyword, tag) in tech_tags {
440            if content_lower.contains(keyword) {
441                tags.push(tag.to_string());
442            }
443        }
444
445        // Domain tags
446        let domain_tags = [
447            ("auth", "authentication"),
448            ("login", "authentication"),
449            ("security", "security"),
450            ("performance", "performance"),
451            ("test", "testing"),
452            ("deploy", "deployment"),
453            ("config", "configuration"),
454            ("error", "error-handling"),
455        ];
456
457        for (keyword, tag) in domain_tags {
458            if content_lower.contains(keyword) {
459                tags.push(tag.to_string());
460            }
461        }
462
463        // Deduplicate
464        tags.sort();
465        tags.dedup();
466        tags.truncate(5);
467
468        tags
469    }
470
471    /// Calculate suggested importance
472    fn calculate_importance(
473        &self,
474        content: &str,
475        capture_type: CaptureType,
476        confidence: f32,
477    ) -> f32 {
478        let content_lower = content.to_lowercase();
479        let mut importance: f32 = 0.5;
480
481        // Base importance by type
482        importance += match capture_type {
483            CaptureType::Decision => 0.2,
484            CaptureType::ActionItem => 0.15,
485            CaptureType::Issue => 0.15,
486            CaptureType::Preference => 0.1,
487            CaptureType::Learning => 0.1,
488            CaptureType::KeyFact => 0.1,
489            CaptureType::Question => 0.05,
490            CaptureType::CodeSnippet => 0.05,
491        };
492
493        // Boost for urgency indicators
494        let urgency_words = ["critical", "urgent", "asap", "immediately", "blocker"];
495        for word in urgency_words {
496            if content_lower.contains(word) {
497                importance += 0.1;
498            }
499        }
500
501        // Boost based on confidence
502        importance += confidence * 0.1;
503
504        importance.min(1.0)
505    }
506
507    /// Update configuration
508    pub fn set_config(&mut self, config: AutoCaptureConfig) {
509        self.config = config;
510    }
511
512    /// Enable/disable auto-capture
513    pub fn set_enabled(&mut self, enabled: bool) {
514        self.config.enabled = enabled;
515    }
516
517    /// Get current config
518    pub fn config(&self) -> &AutoCaptureConfig {
519        &self.config
520    }
521}
522
523/// Conversation context for multi-turn capture
524#[derive(Debug, Default)]
525pub struct ConversationTracker {
526    /// Recent messages in the conversation
527    messages: Vec<TrackedMessage>,
528    /// Candidates detected but not yet confirmed
529    pending_captures: Vec<CaptureCandidate>,
530    /// Maximum messages to track
531    max_messages: usize,
532}
533
534#[derive(Debug, Clone)]
535struct TrackedMessage {
536    content: String,
537    role: String,
538    #[allow(dead_code)]
539    timestamp: DateTime<Utc>,
540}
541
542impl ConversationTracker {
543    pub fn new(max_messages: usize) -> Self {
544        Self {
545            messages: Vec::new(),
546            pending_captures: Vec::new(),
547            max_messages,
548        }
549    }
550
551    /// Add a message to the tracker
552    pub fn add_message(&mut self, content: &str, role: &str) {
553        self.messages.push(TrackedMessage {
554            content: content.to_string(),
555            role: role.to_string(),
556            timestamp: Utc::now(),
557        });
558
559        // Trim old messages
560        if self.messages.len() > self.max_messages {
561            self.messages.remove(0);
562        }
563    }
564
565    /// Get recent context as a string
566    pub fn recent_context(&self, num_messages: usize) -> String {
567        self.messages
568            .iter()
569            .rev()
570            .take(num_messages)
571            .rev()
572            .map(|m| format!("[{}]: {}", m.role, m.content))
573            .collect::<Vec<_>>()
574            .join("\n")
575    }
576
577    /// Add pending capture
578    pub fn add_pending(&mut self, candidate: CaptureCandidate) {
579        self.pending_captures.push(candidate);
580    }
581
582    /// Get pending captures
583    pub fn pending(&self) -> &[CaptureCandidate] {
584        &self.pending_captures
585    }
586
587    /// Clear pending captures
588    pub fn clear_pending(&mut self) {
589        self.pending_captures.clear();
590    }
591
592    /// Confirm and remove a pending capture
593    pub fn confirm_pending(&mut self, index: usize) -> Option<CaptureCandidate> {
594        if index < self.pending_captures.len() {
595            Some(self.pending_captures.remove(index))
596        } else {
597            None
598        }
599    }
600
601    /// Reject a pending capture
602    pub fn reject_pending(&mut self, index: usize) {
603        if index < self.pending_captures.len() {
604            self.pending_captures.remove(index);
605        }
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    #[test]
614    fn test_auto_capture_decision() {
615        let engine = AutoCaptureEngine::with_default_config();
616        let candidates = engine.analyze(
617            "We decided to use Rust for the backend because of performance",
618            "conversation",
619        );
620
621        assert!(!candidates.is_empty());
622        assert_eq!(candidates[0].capture_type, CaptureType::Decision);
623        assert!(candidates[0].confidence >= 0.6);
624    }
625
626    #[test]
627    fn test_auto_capture_action_item() {
628        let engine = AutoCaptureEngine::with_default_config();
629        let candidates = engine.analyze(
630            "TODO: implement the authentication module before Friday",
631            "conversation",
632        );
633
634        assert!(!candidates.is_empty());
635        assert_eq!(candidates[0].capture_type, CaptureType::ActionItem);
636    }
637
638    #[test]
639    fn test_auto_capture_preference() {
640        let engine = AutoCaptureEngine::with_default_config();
641        let candidates = engine.analyze(
642            "I always prefer using TypeScript over JavaScript for better type safety",
643            "conversation",
644        );
645
646        assert!(!candidates.is_empty());
647        assert_eq!(candidates[0].capture_type, CaptureType::Preference);
648    }
649
650    #[test]
651    fn test_auto_capture_learning() {
652        let engine = AutoCaptureEngine::with_default_config();
653        let candidates = engine.analyze(
654            "TIL: Rust's ownership system prevents data races at compile time",
655            "conversation",
656        );
657
658        assert!(!candidates.is_empty());
659        assert_eq!(candidates[0].capture_type, CaptureType::Learning);
660    }
661
662    #[test]
663    fn test_ignore_short_text() {
664        let engine = AutoCaptureEngine::with_default_config();
665        let candidates = engine.analyze("ok", "conversation");
666        assert!(candidates.is_empty());
667    }
668
669    #[test]
670    fn test_ignore_greetings() {
671        let engine = AutoCaptureEngine::with_default_config();
672        let candidates = engine.analyze("hello", "conversation");
673        assert!(candidates.is_empty());
674    }
675
676    #[test]
677    fn test_suggest_tags() {
678        let engine = AutoCaptureEngine::with_default_config();
679        let tags = engine.suggest_tags(
680            "implement rust api for authentication",
681            CaptureType::ActionItem,
682        );
683
684        assert!(tags.contains(&"rust".to_string()));
685        assert!(tags.contains(&"api".to_string()));
686        assert!(tags.contains(&"authentication".to_string()));
687    }
688
689    #[test]
690    fn test_conversation_tracker() {
691        let mut tracker = ConversationTracker::new(10);
692
693        tracker.add_message("Hello", "user");
694        tracker.add_message("Hi there!", "assistant");
695        tracker.add_message("I need help with Rust", "user");
696
697        let context = tracker.recent_context(2);
698        assert!(context.contains("Hi there!"));
699        assert!(context.contains("I need help with Rust"));
700    }
701
702    #[test]
703    fn test_pending_captures() {
704        let mut tracker = ConversationTracker::new(10);
705
706        let candidate = CaptureCandidate {
707            content: "Use async/await".to_string(),
708            capture_type: CaptureType::Decision,
709            confidence: 0.8,
710            source: "test".to_string(),
711            suggested_tags: vec!["rust".to_string()],
712            suggested_importance: 0.7,
713            detected_at: Utc::now(),
714            reason: "test".to_string(),
715        };
716
717        tracker.add_pending(candidate);
718        assert_eq!(tracker.pending().len(), 1);
719
720        let confirmed = tracker.confirm_pending(0);
721        assert!(confirmed.is_some());
722        assert_eq!(tracker.pending().len(), 0);
723    }
724
725    #[test]
726    fn test_capture_to_memory() {
727        let candidate = CaptureCandidate {
728            content: "Always use Rust for performance-critical code".to_string(),
729            capture_type: CaptureType::Preference,
730            confidence: 0.85,
731            source: "conversation".to_string(),
732            suggested_tags: vec!["rust".to_string(), "performance".to_string()],
733            suggested_importance: 0.8,
734            detected_at: Utc::now(),
735            reason: "Matched pattern".to_string(),
736        };
737
738        let memory = candidate.to_memory();
739        assert_eq!(memory.content, candidate.content);
740        assert_eq!(memory.memory_type, MemoryType::Preference);
741        assert_eq!(memory.tags, candidate.suggested_tags);
742    }
743
744    #[test]
745    fn test_disabled_capture() {
746        let config = AutoCaptureConfig {
747            enabled: false,
748            ..Default::default()
749        };
750
751        let engine = AutoCaptureEngine::new(config);
752        let candidates = engine.analyze("We decided to use Rust for everything", "conversation");
753
754        assert!(candidates.is_empty());
755    }
756}