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 }
251 }
252}
253
254pub 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 pub fn analyze(&self, text: &str, source: &str) -> Vec<CaptureCandidate> {
270 if !self.config.enabled {
271 return Vec::new();
272 }
273
274 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 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 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 fn should_ignore(&self, text_lower: &str) -> bool {
300 if text_lower.len() < 10 {
302 return true;
303 }
304
305 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 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 for pattern in patterns {
329 if text_lower.contains(pattern) {
330 confidence = 0.7;
331 matched_pattern = pattern;
332 break;
333 }
334 }
335
336 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 if text_lower.contains("remember:") || text_lower.contains("important:") {
347 confidence += 0.15;
348 }
349
350 if confidence < 0.3 {
352 return None;
353 }
354
355 let content = self.extract_content(text, capture_type);
357 if content.is_empty() {
358 return None;
359 }
360
361 let suggested_tags = self.suggest_tags(&content, capture_type);
363
364 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 fn extract_content(&self, text: &str, capture_type: CaptureType) -> String {
381 let text_lower = text.to_lowercase();
382
383 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 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 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 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 tags.push(format!("auto-{:?}", capture_type).to_lowercase());
423
424 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 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 tags.sort();
464 tags.dedup();
465 tags.truncate(5);
466
467 tags
468 }
469
470 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 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 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 importance += confidence * 0.1;
502
503 importance.min(1.0)
504 }
505
506 pub fn set_config(&mut self, config: AutoCaptureConfig) {
508 self.config = config;
509 }
510
511 pub fn set_enabled(&mut self, enabled: bool) {
513 self.config.enabled = enabled;
514 }
515
516 pub fn config(&self) -> &AutoCaptureConfig {
518 &self.config
519 }
520}
521
522#[derive(Debug, Default)]
524pub struct ConversationTracker {
525 messages: Vec<TrackedMessage>,
527 pending_captures: Vec<CaptureCandidate>,
529 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 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 if self.messages.len() > self.max_messages {
560 self.messages.remove(0);
561 }
562 }
563
564 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 pub fn add_pending(&mut self, candidate: CaptureCandidate) {
578 self.pending_captures.push(candidate);
579 }
580
581 pub fn pending(&self) -> &[CaptureCandidate] {
583 &self.pending_captures
584 }
585
586 pub fn clear_pending(&mut self) {
588 self.pending_captures.clear();
589 }
590
591 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 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}