1use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::collections::HashSet;
13
14use crate::types::{Memory, MemoryType};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct AutoCaptureConfig {
19 pub enabled: bool,
21 pub min_confidence: f32,
23 pub capture_types: HashSet<CaptureType>,
25 pub max_per_turn: usize,
27 pub require_confirmation: bool,
29 pub trigger_keywords: Vec<String>,
31 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
87pub enum CaptureType {
88 Decision,
90 ActionItem,
92 KeyFact,
94 Preference,
96 Learning,
98 Question,
100 Issue,
102 CodeSnippet,
104}
105
106impl CaptureType {
107 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct CaptureCandidate {
202 pub content: String,
204 pub capture_type: CaptureType,
206 pub confidence: f32,
208 pub source: String,
210 pub suggested_tags: Vec<String>,
212 pub suggested_importance: f32,
214 pub detected_at: DateTime<Utc>,
216 pub reason: String,
218}
219
220impl CaptureCandidate {
221 pub fn to_memory(&self) -> Memory {
223 Memory {
224 id: 0, 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, 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
255pub 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 pub fn analyze(&self, text: &str, source: &str) -> Vec<CaptureCandidate> {
271 if !self.config.enabled {
272 return Vec::new();
273 }
274
275 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 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 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 fn should_ignore(&self, text_lower: &str) -> bool {
301 if text_lower.len() < 10 {
303 return true;
304 }
305
306 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 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 for pattern in patterns {
330 if text_lower.contains(pattern) {
331 confidence = 0.7;
332 matched_pattern = pattern;
333 break;
334 }
335 }
336
337 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 if text_lower.contains("remember:") || text_lower.contains("important:") {
348 confidence += 0.15;
349 }
350
351 if confidence < 0.3 {
353 return None;
354 }
355
356 let content = self.extract_content(text, capture_type);
358 if content.is_empty() {
359 return None;
360 }
361
362 let suggested_tags = self.suggest_tags(&content, capture_type);
364
365 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 fn extract_content(&self, text: &str, capture_type: CaptureType) -> String {
382 let text_lower = text.to_lowercase();
383
384 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 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 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 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 tags.push(format!("auto-{:?}", capture_type).to_lowercase());
424
425 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 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 tags.sort();
465 tags.dedup();
466 tags.truncate(5);
467
468 tags
469 }
470
471 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 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 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 importance += confidence * 0.1;
503
504 importance.min(1.0)
505 }
506
507 pub fn set_config(&mut self, config: AutoCaptureConfig) {
509 self.config = config;
510 }
511
512 pub fn set_enabled(&mut self, enabled: bool) {
514 self.config.enabled = enabled;
515 }
516
517 pub fn config(&self) -> &AutoCaptureConfig {
519 &self.config
520 }
521}
522
523#[derive(Debug, Default)]
525pub struct ConversationTracker {
526 messages: Vec<TrackedMessage>,
528 pending_captures: Vec<CaptureCandidate>,
530 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 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 if self.messages.len() > self.max_messages {
561 self.messages.remove(0);
562 }
563 }
564
565 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 pub fn add_pending(&mut self, candidate: CaptureCandidate) {
579 self.pending_captures.push(candidate);
580 }
581
582 pub fn pending(&self) -> &[CaptureCandidate] {
584 &self.pending_captures
585 }
586
587 pub fn clear_pending(&mut self) {
589 self.pending_captures.clear();
590 }
591
592 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 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}