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        }
251    }
252}
253
254/// Auto-capture engine
255pub struct AutoCaptureEngine {
256    config: AutoCaptureConfig,
257}
258
259impl AutoCaptureEngine {
260    pub fn new(config: AutoCaptureConfig) -> Self {
261        Self { config }
262    }
263
264    pub fn with_default_config() -> Self {
265        Self::new(AutoCaptureConfig::default())
266    }
267
268    /// Analyze text and detect potential captures
269    pub fn analyze(&self, text: &str, source: &str) -> Vec<CaptureCandidate> {
270        if !self.config.enabled {
271            return Vec::new();
272        }
273
274        // Skip if matches ignore patterns
275        let text_lower = text.to_lowercase();
276        if self.should_ignore(&text_lower) {
277            return Vec::new();
278        }
279
280        let mut candidates = Vec::new();
281
282        // Check each capture type
283        for capture_type in &self.config.capture_types {
284            if let Some(candidate) = self.detect_type(text, &text_lower, *capture_type, source) {
285                if candidate.confidence >= self.config.min_confidence {
286                    candidates.push(candidate);
287                }
288            }
289        }
290
291        // Sort by confidence and limit
292        candidates.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
293        candidates.truncate(self.config.max_per_turn);
294
295        candidates
296    }
297
298    /// Check if text should be ignored
299    fn should_ignore(&self, text_lower: &str) -> bool {
300        // Too short
301        if text_lower.len() < 10 {
302            return true;
303        }
304
305        // Matches ignore patterns
306        for pattern in &self.config.ignore_patterns {
307            if text_lower.trim() == pattern.as_str() {
308                return true;
309            }
310        }
311
312        false
313    }
314
315    /// Detect a specific capture type
316    fn detect_type(
317        &self,
318        text: &str,
319        text_lower: &str,
320        capture_type: CaptureType,
321        source: &str,
322    ) -> Option<CaptureCandidate> {
323        let patterns = capture_type.patterns();
324        let mut confidence: f32 = 0.0;
325        let mut matched_pattern = "";
326
327        // Check patterns
328        for pattern in patterns {
329            if text_lower.contains(pattern) {
330                confidence = 0.7;
331                matched_pattern = pattern;
332                break;
333            }
334        }
335
336        // Boost confidence for trigger keywords
337        let trigger_count = self
338            .config
339            .trigger_keywords
340            .iter()
341            .filter(|kw| text_lower.contains(kw.as_str()))
342            .count();
343        confidence += (trigger_count as f32 * 0.05).min(0.2);
344
345        // Boost for explicit markers
346        if text_lower.contains("remember:") || text_lower.contains("important:") {
347            confidence += 0.15;
348        }
349
350        // Minimum threshold check
351        if confidence < 0.3 {
352            return None;
353        }
354
355        // Extract the relevant content
356        let content = self.extract_content(text, capture_type);
357        if content.is_empty() {
358            return None;
359        }
360
361        // Suggest tags based on content
362        let suggested_tags = self.suggest_tags(&content, capture_type);
363
364        // Calculate importance
365        let suggested_importance = self.calculate_importance(&content, capture_type, confidence);
366
367        Some(CaptureCandidate {
368            content,
369            capture_type,
370            confidence: confidence.min(1.0),
371            source: source.to_string(),
372            suggested_tags,
373            suggested_importance,
374            detected_at: Utc::now(),
375            reason: format!("Matched pattern: '{}'", matched_pattern),
376        })
377    }
378
379    /// Extract the relevant content for capture
380    fn extract_content(&self, text: &str, capture_type: CaptureType) -> String {
381        let text_lower = text.to_lowercase();
382
383        // Try to extract after common markers
384        let markers = match capture_type {
385            CaptureType::Decision => vec!["decided to", "decision:", "we'll"],
386            CaptureType::ActionItem => vec!["todo:", "action:", "need to"],
387            CaptureType::KeyFact => vec!["important:", "note:", "key:"],
388            CaptureType::Preference => vec!["prefer", "always", "never"],
389            CaptureType::Learning => vec!["learned", "til:", "insight:"],
390            CaptureType::Question => vec!["question:", "investigate"],
391            CaptureType::Issue => vec!["bug:", "issue:", "problem:"],
392            CaptureType::CodeSnippet => vec!["```", "code:"],
393        };
394
395        for marker in markers {
396            if let Some(pos) = text_lower.find(marker) {
397                let start = pos + marker.len();
398                let extracted = text[start..].trim();
399                // Take until end of sentence or paragraph
400                let end = extracted
401                    .find(|c: char| c == '\n' || c == '.' && extracted.len() > 10)
402                    .unwrap_or(extracted.len().min(500));
403                return extracted[..end].trim().to_string();
404            }
405        }
406
407        // If no marker found, use the whole text (truncated)
408        let max_len = 500;
409        if text.len() <= max_len {
410            text.trim().to_string()
411        } else {
412            format!("{}...", &text[..max_len].trim())
413        }
414    }
415
416    /// Suggest tags based on content
417    fn suggest_tags(&self, content: &str, capture_type: CaptureType) -> Vec<String> {
418        let mut tags = Vec::new();
419        let content_lower = content.to_lowercase();
420
421        // Add type-based tag
422        tags.push(format!("auto-{:?}", capture_type).to_lowercase());
423
424        // Common technology tags
425        let tech_tags = [
426            ("rust", "rust"),
427            ("python", "python"),
428            ("javascript", "javascript"),
429            ("typescript", "typescript"),
430            ("react", "react"),
431            ("sql", "sql"),
432            ("api", "api"),
433            ("database", "database"),
434            ("frontend", "frontend"),
435            ("backend", "backend"),
436        ];
437
438        for (keyword, tag) in tech_tags {
439            if content_lower.contains(keyword) {
440                tags.push(tag.to_string());
441            }
442        }
443
444        // Domain tags
445        let domain_tags = [
446            ("auth", "authentication"),
447            ("login", "authentication"),
448            ("security", "security"),
449            ("performance", "performance"),
450            ("test", "testing"),
451            ("deploy", "deployment"),
452            ("config", "configuration"),
453            ("error", "error-handling"),
454        ];
455
456        for (keyword, tag) in domain_tags {
457            if content_lower.contains(keyword) {
458                tags.push(tag.to_string());
459            }
460        }
461
462        // Deduplicate
463        tags.sort();
464        tags.dedup();
465        tags.truncate(5);
466
467        tags
468    }
469
470    /// Calculate suggested importance
471    fn calculate_importance(
472        &self,
473        content: &str,
474        capture_type: CaptureType,
475        confidence: f32,
476    ) -> f32 {
477        let content_lower = content.to_lowercase();
478        let mut importance: f32 = 0.5;
479
480        // Base importance by type
481        importance += match capture_type {
482            CaptureType::Decision => 0.2,
483            CaptureType::ActionItem => 0.15,
484            CaptureType::Issue => 0.15,
485            CaptureType::Preference => 0.1,
486            CaptureType::Learning => 0.1,
487            CaptureType::KeyFact => 0.1,
488            CaptureType::Question => 0.05,
489            CaptureType::CodeSnippet => 0.05,
490        };
491
492        // Boost for urgency indicators
493        let urgency_words = ["critical", "urgent", "asap", "immediately", "blocker"];
494        for word in urgency_words {
495            if content_lower.contains(word) {
496                importance += 0.1;
497            }
498        }
499
500        // Boost based on confidence
501        importance += confidence * 0.1;
502
503        importance.min(1.0)
504    }
505
506    /// Update configuration
507    pub fn set_config(&mut self, config: AutoCaptureConfig) {
508        self.config = config;
509    }
510
511    /// Enable/disable auto-capture
512    pub fn set_enabled(&mut self, enabled: bool) {
513        self.config.enabled = enabled;
514    }
515
516    /// Get current config
517    pub fn config(&self) -> &AutoCaptureConfig {
518        &self.config
519    }
520}
521
522/// Conversation context for multi-turn capture
523#[derive(Debug, Default)]
524pub struct ConversationTracker {
525    /// Recent messages in the conversation
526    messages: Vec<TrackedMessage>,
527    /// Candidates detected but not yet confirmed
528    pending_captures: Vec<CaptureCandidate>,
529    /// Maximum messages to track
530    max_messages: usize,
531}
532
533#[derive(Debug, Clone)]
534struct TrackedMessage {
535    content: String,
536    role: String,
537    #[allow(dead_code)]
538    timestamp: DateTime<Utc>,
539}
540
541impl ConversationTracker {
542    pub fn new(max_messages: usize) -> Self {
543        Self {
544            messages: Vec::new(),
545            pending_captures: Vec::new(),
546            max_messages,
547        }
548    }
549
550    /// Add a message to the tracker
551    pub fn add_message(&mut self, content: &str, role: &str) {
552        self.messages.push(TrackedMessage {
553            content: content.to_string(),
554            role: role.to_string(),
555            timestamp: Utc::now(),
556        });
557
558        // Trim old messages
559        if self.messages.len() > self.max_messages {
560            self.messages.remove(0);
561        }
562    }
563
564    /// Get recent context as a string
565    pub fn recent_context(&self, num_messages: usize) -> String {
566        self.messages
567            .iter()
568            .rev()
569            .take(num_messages)
570            .rev()
571            .map(|m| format!("[{}]: {}", m.role, m.content))
572            .collect::<Vec<_>>()
573            .join("\n")
574    }
575
576    /// Add pending capture
577    pub fn add_pending(&mut self, candidate: CaptureCandidate) {
578        self.pending_captures.push(candidate);
579    }
580
581    /// Get pending captures
582    pub fn pending(&self) -> &[CaptureCandidate] {
583        &self.pending_captures
584    }
585
586    /// Clear pending captures
587    pub fn clear_pending(&mut self) {
588        self.pending_captures.clear();
589    }
590
591    /// Confirm and remove a pending capture
592    pub fn confirm_pending(&mut self, index: usize) -> Option<CaptureCandidate> {
593        if index < self.pending_captures.len() {
594            Some(self.pending_captures.remove(index))
595        } else {
596            None
597        }
598    }
599
600    /// Reject a pending capture
601    pub fn reject_pending(&mut self, index: usize) {
602        if index < self.pending_captures.len() {
603            self.pending_captures.remove(index);
604        }
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    #[test]
613    fn test_auto_capture_decision() {
614        let engine = AutoCaptureEngine::with_default_config();
615        let candidates = engine.analyze(
616            "We decided to use Rust for the backend because of performance",
617            "conversation",
618        );
619
620        assert!(!candidates.is_empty());
621        assert_eq!(candidates[0].capture_type, CaptureType::Decision);
622        assert!(candidates[0].confidence >= 0.6);
623    }
624
625    #[test]
626    fn test_auto_capture_action_item() {
627        let engine = AutoCaptureEngine::with_default_config();
628        let candidates = engine.analyze(
629            "TODO: implement the authentication module before Friday",
630            "conversation",
631        );
632
633        assert!(!candidates.is_empty());
634        assert_eq!(candidates[0].capture_type, CaptureType::ActionItem);
635    }
636
637    #[test]
638    fn test_auto_capture_preference() {
639        let engine = AutoCaptureEngine::with_default_config();
640        let candidates = engine.analyze(
641            "I always prefer using TypeScript over JavaScript for better type safety",
642            "conversation",
643        );
644
645        assert!(!candidates.is_empty());
646        assert_eq!(candidates[0].capture_type, CaptureType::Preference);
647    }
648
649    #[test]
650    fn test_auto_capture_learning() {
651        let engine = AutoCaptureEngine::with_default_config();
652        let candidates = engine.analyze(
653            "TIL: Rust's ownership system prevents data races at compile time",
654            "conversation",
655        );
656
657        assert!(!candidates.is_empty());
658        assert_eq!(candidates[0].capture_type, CaptureType::Learning);
659    }
660
661    #[test]
662    fn test_ignore_short_text() {
663        let engine = AutoCaptureEngine::with_default_config();
664        let candidates = engine.analyze("ok", "conversation");
665        assert!(candidates.is_empty());
666    }
667
668    #[test]
669    fn test_ignore_greetings() {
670        let engine = AutoCaptureEngine::with_default_config();
671        let candidates = engine.analyze("hello", "conversation");
672        assert!(candidates.is_empty());
673    }
674
675    #[test]
676    fn test_suggest_tags() {
677        let engine = AutoCaptureEngine::with_default_config();
678        let tags = engine.suggest_tags(
679            "implement rust api for authentication",
680            CaptureType::ActionItem,
681        );
682
683        assert!(tags.contains(&"rust".to_string()));
684        assert!(tags.contains(&"api".to_string()));
685        assert!(tags.contains(&"authentication".to_string()));
686    }
687
688    #[test]
689    fn test_conversation_tracker() {
690        let mut tracker = ConversationTracker::new(10);
691
692        tracker.add_message("Hello", "user");
693        tracker.add_message("Hi there!", "assistant");
694        tracker.add_message("I need help with Rust", "user");
695
696        let context = tracker.recent_context(2);
697        assert!(context.contains("Hi there!"));
698        assert!(context.contains("I need help with Rust"));
699    }
700
701    #[test]
702    fn test_pending_captures() {
703        let mut tracker = ConversationTracker::new(10);
704
705        let candidate = CaptureCandidate {
706            content: "Use async/await".to_string(),
707            capture_type: CaptureType::Decision,
708            confidence: 0.8,
709            source: "test".to_string(),
710            suggested_tags: vec!["rust".to_string()],
711            suggested_importance: 0.7,
712            detected_at: Utc::now(),
713            reason: "test".to_string(),
714        };
715
716        tracker.add_pending(candidate);
717        assert_eq!(tracker.pending().len(), 1);
718
719        let confirmed = tracker.confirm_pending(0);
720        assert!(confirmed.is_some());
721        assert_eq!(tracker.pending().len(), 0);
722    }
723
724    #[test]
725    fn test_capture_to_memory() {
726        let candidate = CaptureCandidate {
727            content: "Always use Rust for performance-critical code".to_string(),
728            capture_type: CaptureType::Preference,
729            confidence: 0.85,
730            source: "conversation".to_string(),
731            suggested_tags: vec!["rust".to_string(), "performance".to_string()],
732            suggested_importance: 0.8,
733            detected_at: Utc::now(),
734            reason: "Matched pattern".to_string(),
735        };
736
737        let memory = candidate.to_memory();
738        assert_eq!(memory.content, candidate.content);
739        assert_eq!(memory.memory_type, MemoryType::Preference);
740        assert_eq!(memory.tags, candidate.suggested_tags);
741    }
742
743    #[test]
744    fn test_disabled_capture() {
745        let config = AutoCaptureConfig {
746            enabled: false,
747            ..Default::default()
748        };
749
750        let engine = AutoCaptureEngine::new(config);
751        let candidates = engine.analyze("We decided to use Rust for everything", "conversation");
752
753        assert!(candidates.is_empty());
754    }
755}