1use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16use anyhow::Result;
17use chrono::Utc;
18use serde::{Deserialize, Serialize};
19
20use super::{MemoryEntry, MemoryManager, MemoryType};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum SyncDirection {
30 ToAuto,
32 FromAuto,
34 Bidirectional,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum InsightCategory {
42 ProjectPatterns,
44 Debugging,
46 Architecture,
48 Performance,
50 Security,
52 General,
54}
55
56impl InsightCategory {
57 pub fn from_str_loose(s: &str) -> Self {
59 match s.to_lowercase().as_str() {
60 "project-patterns" | "project_patterns" | "patterns" => {
61 InsightCategory::ProjectPatterns
62 }
63 "debugging" | "debug" => InsightCategory::Debugging,
64 "architecture" | "arch" => InsightCategory::Architecture,
65 "performance" | "perf" => InsightCategory::Performance,
66 "security" | "sec" => InsightCategory::Security,
67 _ => InsightCategory::General,
68 }
69 }
70
71 pub fn to_tag(&self) -> &'static str {
73 match self {
74 InsightCategory::ProjectPatterns => "project-patterns",
75 InsightCategory::Debugging => "debugging",
76 InsightCategory::Architecture => "architecture",
77 InsightCategory::Performance => "performance",
78 InsightCategory::Security => "security",
79 InsightCategory::General => "general",
80 }
81 }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct MemoryInsight {
87 pub category: InsightCategory,
89 pub summary: String,
91 pub detail: Option<String>,
93 pub source: String,
95 pub confidence: f32,
97}
98
99#[derive(Debug, Clone, Default, Serialize, Deserialize)]
101pub struct ImportResult {
102 pub imported: usize,
104 pub skipped_duplicates: usize,
106 pub failed: usize,
108 pub errors: Vec<String>,
110}
111
112#[derive(Debug, Clone, Default, Serialize, Deserialize)]
114pub struct ExportResult {
115 pub exported: usize,
117 pub categories_updated: usize,
119}
120
121#[derive(Debug, Clone, Default, Serialize, Deserialize)]
123pub struct SyncResult {
124 pub import: ImportResult,
126 pub export: ExportResult,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct GuidancePattern {
133 pub id: String,
135 pub category: InsightCategory,
137 pub description: String,
139 pub confidence: f32,
141 pub usage_count: u32,
143}
144
145pub struct AutoMemoryBridge {
160 auto_memory_dir: PathBuf,
162 oxios_memory: std::sync::Arc<MemoryManager>,
164 knowledge_base: Option<std::sync::Arc<oxios_markdown::KnowledgeBase>>,
166}
167
168impl AutoMemoryBridge {
169 pub fn new(auto_memory_dir: PathBuf, oxios_memory: std::sync::Arc<MemoryManager>) -> Self {
175 Self {
176 auto_memory_dir,
177 oxios_memory,
178 knowledge_base: None,
179 }
180 }
181
182 pub fn with_knowledge_base(
187 mut self,
188 kb: std::sync::Arc<oxios_markdown::KnowledgeBase>,
189 ) -> Self {
190 self.knowledge_base = Some(kb);
191 self
192 }
193
194 pub async fn import_from_auto(&self) -> Result<ImportResult> {
199 let mut result = ImportResult::default();
200
201 let memory_files = self.find_memory_files()?;
203
204 for file_path in &memory_files {
205 match self.import_file(file_path).await {
206 Ok(file_result) => {
207 result.imported += file_result.imported;
208 result.skipped_duplicates += file_result.skipped_duplicates;
209 result.failed += file_result.failed;
210 result.errors.extend(file_result.errors);
211 }
212 Err(e) => {
213 result.failed += 1;
214 result
215 .errors
216 .push(format!("{}: {}", file_path.display(), e));
217 }
218 }
219 }
220
221 Ok(result)
222 }
223
224 pub async fn export_to_auto(&self, patterns: &[GuidancePattern]) -> Result<ExportResult> {
229 let mut result = ExportResult::default();
230
231 tokio::fs::create_dir_all(&self.auto_memory_dir).await?;
233
234 let mut by_category: HashMap<InsightCategory, Vec<&GuidancePattern>> = HashMap::new();
236 for pattern in patterns {
237 by_category
238 .entry(pattern.category.clone())
239 .or_default()
240 .push(pattern);
241 }
242
243 for (category, cat_patterns) in &by_category {
245 let filename = match category {
246 InsightCategory::ProjectPatterns => "patterns.md",
247 InsightCategory::Debugging => "debugging.md",
248 InsightCategory::Architecture => "architecture.md",
249 InsightCategory::Performance => "performance.md",
250 InsightCategory::Security => "security.md",
251 InsightCategory::General => "general.md",
252 };
253
254 let content = self.format_patterns_md(cat_patterns);
255 let path = self.auto_memory_dir.join(filename);
256
257 tokio::fs::write(&path, &content).await?;
258 result.categories_updated += 1;
259 }
260
261 let mut all_patterns: Vec<&GuidancePattern> = patterns.iter().collect();
263 all_patterns.sort_by(|a, b| {
264 b.confidence
265 .partial_cmp(&a.confidence)
266 .unwrap_or(std::cmp::Ordering::Equal)
267 });
268
269 let main_content = self.format_main_md(&all_patterns);
270 let main_path = self.auto_memory_dir.join("MEMORY.md");
271 tokio::fs::write(&main_path, &main_content).await?;
272
273 result.exported = patterns.len();
274 result.categories_updated += 1; Ok(result)
276 }
277
278 pub async fn sync_session(&self, direction: SyncDirection) -> Result<SyncResult> {
280 let mut sync_result = SyncResult::default();
281
282 match direction {
283 SyncDirection::FromAuto => {
284 sync_result.import = self.import_from_auto().await?;
285 }
286 SyncDirection::ToAuto => {
287 sync_result.export = self.export_knowledge_to_auto().await?;
288 }
289 SyncDirection::Bidirectional => {
290 sync_result.import = self.import_from_auto().await?;
291 sync_result.export = self.export_knowledge_to_auto().await?;
292 }
293 }
294
295 Ok(sync_result)
296 }
297
298 pub fn auto_memory_dir(&self) -> &Path {
300 &self.auto_memory_dir
301 }
302
303 async fn export_knowledge_to_auto(&self) -> Result<ExportResult> {
309 let entries = self
311 .oxios_memory
312 .list(MemoryType::Knowledge, 1000)
313 .await
314 .unwrap_or_default();
315
316 if !entries.is_empty() {
317 let patterns: Vec<GuidancePattern> = entries
318 .iter()
319 .map(|e| GuidancePattern {
320 id: e.id.clone(),
321 category: e
322 .tags
323 .first()
324 .map(|t| InsightCategory::from_str_loose(t))
325 .unwrap_or(InsightCategory::General),
326 description: e.content.clone(),
327 confidence: e.importance,
328 usage_count: e.access_count,
329 })
330 .collect();
331 return self.export_to_auto(&patterns).await;
332 }
333
334 if let Some(kb) = &self.knowledge_base {
336 let entries = kb.index_all()?;
337 if entries > 0 {
338 let patterns = kb
339 .note_tree("/")
340 .unwrap_or_default()
341 .into_iter()
342 .filter(|e| !e.is_dir && e.name.ends_with(".md"))
343 .map(|e| {
344 let path = if e.parent_dir == "/" || e.parent_dir.is_empty() {
345 e.name.clone()
346 } else {
347 format!("{}/{}", e.parent_dir, e.name)
348 };
349 let content = kb.note_read(&path).ok().flatten().unwrap_or_default();
350 let headings = kb.extract_headings(&content);
351 let tag = headings.first().map(|s| s.as_str()).unwrap_or("general");
352 GuidancePattern {
353 id: format!("note-{}", path.replace('/', "-").trim_end_matches(".md")),
354 category: InsightCategory::from_str_loose(tag),
355 description: content.chars().take(300).collect(),
356 confidence: 0.6,
357 usage_count: 0,
358 }
359 })
360 .collect::<Vec<_>>();
361 if !patterns.is_empty() {
362 return self.export_to_auto(&patterns).await;
363 }
364 }
365 }
366
367 Ok(ExportResult::default())
369 }
370}
371
372impl AutoMemoryBridge {
377 fn find_memory_files(&self) -> Result<Vec<PathBuf>> {
379 let mut files = Vec::new();
380
381 if self.auto_memory_dir.exists() {
382 let main = self.auto_memory_dir.join("MEMORY.md");
384 if main.exists() {
385 files.push(main);
386 }
387
388 for topic in &[
390 "patterns.md",
391 "debugging.md",
392 "architecture.md",
393 "performance.md",
394 "security.md",
395 "general.md",
396 ] {
397 let path = self.auto_memory_dir.join(topic);
398 if path.exists() {
399 files.push(path);
400 }
401 }
402
403 if let Ok(entries) = std::fs::read_dir(&self.auto_memory_dir) {
405 for entry in entries.flatten() {
406 let path = entry.path();
407 if path.extension().is_some_and(|ext| ext == "md") {
408 let name = path.file_name().unwrap_or_default().to_string_lossy();
409 if ![
411 "MEMORY.md",
412 "patterns.md",
413 "debugging.md",
414 "architecture.md",
415 "performance.md",
416 "security.md",
417 "general.md",
418 ]
419 .contains(&name.as_ref())
420 {
421 files.push(path);
422 }
423 }
424 }
425 }
426 }
427
428 Ok(files)
429 }
430
431 async fn import_file(&self, path: &Path) -> Result<ImportResult> {
433 let content = tokio::fs::read_to_string(path).await?;
434 let insights = self.parse_markdown_insights(&content);
435 let mut result = ImportResult::default();
436
437 for insight in &insights {
438 if self.oxios_memory.is_duplicate(&insight.summary).await {
440 result.skipped_duplicates += 1;
441 continue;
442 }
443
444 let entry = MemoryEntry {
445 id: format!(
446 "auto-{}-{}",
447 insight.source,
448 chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
449 ),
450 memory_type: MemoryType::Knowledge,
451 content: match &insight.detail {
452 Some(d) => format!("{}\n\n{}", insight.summary, d),
453 None => insight.summary.clone(),
454 },
455 source: insight.source.clone(),
456 session_id: None,
457 tags: vec![insight.category.to_tag().to_string()],
458 importance: insight.confidence,
459 created_at: Utc::now(),
460 accessed_at: Utc::now(),
461 access_count: 0,
462 };
463
464 match self.oxios_memory.remember(entry).await {
465 Ok(_) => result.imported += 1,
466 Err(e) => {
467 result.failed += 1;
468 result.errors.push(e.to_string());
469 tracing::warn!(error = %e, "Failed to import memory insight");
470 }
471 }
472 }
473
474 Ok(result)
475 }
476
477 fn parse_markdown_insights(&self, content: &str) -> Vec<MemoryInsight> {
484 let mut insights = Vec::new();
485 let mut current_category = InsightCategory::General;
486
487 for line in content.lines() {
488 let trimmed = line.trim();
489
490 if trimmed.is_empty() {
492 continue;
493 }
494
495 if trimmed.starts_with('#') {
497 let header = trimmed.trim_start_matches('#').trim().to_lowercase();
498 current_category = InsightCategory::from_str_loose(&header);
499 continue;
500 }
501
502 if trimmed.starts_with('-') || trimmed.starts_with('*') {
504 let item = if trimmed.starts_with('-') {
506 trimmed.trim_start_matches('-').trim_start()
507 } else {
508 trimmed.trim_start_matches('*').trim_start()
509 };
510
511 if let Some(rest) = Self::extract_bold_category(item) {
513 let (cat_name, description) = rest;
514 let category = InsightCategory::from_str_loose(cat_name);
515
516 let (summary, detail) = if let Some(pos) = description.find(':') {
518 let s = description[..pos].trim();
519 let d = description[pos + 1..].trim();
520 (
521 s.to_string(),
522 if d.is_empty() {
523 None
524 } else {
525 Some(d.to_string())
526 },
527 )
528 } else {
529 (description.to_string(), None)
530 };
531
532 if !summary.is_empty() {
533 insights.push(MemoryInsight {
534 category,
535 summary,
536 detail,
537 source: "auto-import".to_string(),
538 confidence: 0.7,
539 });
540 }
541 } else if !item.is_empty() {
542 insights.push(MemoryInsight {
544 category: current_category.clone(),
545 summary: item.to_string(),
546 detail: None,
547 source: "auto-import".to_string(),
548 confidence: 0.6,
549 });
550 }
551 }
552 }
553
554 if insights.is_empty() && !content.trim().is_empty() {
556 let summary: String = content
557 .lines()
558 .take(3)
559 .collect::<Vec<_>>()
560 .join(" ")
561 .chars()
562 .take(200)
563 .collect();
564
565 if !summary.trim().is_empty() {
566 insights.push(MemoryInsight {
567 category: InsightCategory::General,
568 summary,
569 detail: Some(content.to_string()),
570 source: "auto-import".to_string(),
571 confidence: 0.5,
572 });
573 }
574 }
575
576 insights
577 }
578
579 fn extract_bold_category(item: &str) -> Option<(&str, &str)> {
583 if !item.starts_with("**") {
584 return None;
585 }
586
587 let end = item[2..].find("**")?;
588 let category = &item[2..2 + end];
589 let rest = item[2 + end + 2..].trim_start_matches([' ', ':']);
590
591 Some((category, rest))
592 }
593
594 fn format_patterns_md(&self, patterns: &[&GuidancePattern]) -> String {
596 let mut md = String::new();
597 md.push_str("# Memory Insights\n\n");
598
599 for pattern in patterns {
600 let confidence_bar = format_confidence_bar(pattern.confidence);
601 md.push_str(&format!(
602 "- **{}**: {} [{}]\n",
603 pattern.category.to_tag(),
604 pattern.description,
605 confidence_bar,
606 ));
607 }
608
609 md.push('\n');
610 md
611 }
612
613 fn format_main_md(&self, patterns: &[&GuidancePattern]) -> String {
615 let mut md = String::new();
616 md.push_str("# Oxios Memory\n\n");
617 md.push_str(&format!(
618 "Auto-generated at {}\n\n",
619 Utc::now().to_rfc3339()
620 ));
621 md.push_str("## Insights\n\n");
622
623 for pattern in patterns {
624 let confidence_pct = (pattern.confidence * 100.0) as u8;
625 md.push_str(&format!(
626 "- **{}** [{}%]: {}\n",
627 pattern.category.to_tag(),
628 confidence_pct,
629 pattern.description,
630 ));
631 }
632
633 md.push('\n');
634 md
635 }
636}
637
638fn format_confidence_bar(confidence: f32) -> String {
640 let bars = (confidence * 5.0).round() as usize;
641 let bars = bars.min(5);
642 let filled: String = "█".repeat(bars);
643 let empty: String = "░".repeat(5 - bars);
644 format!("{}{} {:.0}%", filled, empty, confidence * 100.0)
645}
646
647#[cfg(test)]
652mod tests {
653 use super::*;
654 use std::sync::Arc;
655
656 fn make_bridge(dir: &Path) -> AutoMemoryBridge {
657 let store = Arc::new(crate::state_store::StateStore::new(dir.join("state")).unwrap());
658 let memory = Arc::new(MemoryManager::new(store));
659 AutoMemoryBridge::new(dir.join("auto"), memory).with_knowledge_base(Arc::new(
660 oxios_markdown::KnowledgeBase::new(dir.join("kb")).unwrap(),
661 ))
662 }
663
664 #[test]
665 fn test_parse_bold_category() {
666 let result =
667 AutoMemoryBridge::extract_bold_category("**Debugging**: Use trace-level logging");
668 assert!(result.is_some());
669 let (cat, rest) = result.unwrap();
670 assert_eq!(cat, "Debugging");
671 assert_eq!(rest, "Use trace-level logging");
672 }
673
674 #[test]
675 fn test_parse_bold_category_no_colon() {
676 let result = AutoMemoryBridge::extract_bold_category("**Security** important rule");
677 assert!(result.is_some());
678 let (cat, rest) = result.unwrap();
679 assert_eq!(cat, "Security");
680 assert_eq!(rest, "important rule");
681 }
682
683 #[test]
684 fn test_parse_no_bold() {
685 let result = AutoMemoryBridge::extract_bold_category("Just a plain item");
686 assert!(result.is_none());
687 }
688
689 #[test]
690 fn test_parse_markdown_insights() {
691 let temp_dir = tempfile::tempdir().unwrap();
692 let bridge = make_bridge(temp_dir.path());
693
694 let md = r#"# Project Patterns
695
696- **Debugging**: Use trace-level logging for async tasks
697- **Architecture**: Follow the kernel module pattern
698- **Performance**: Batch embeddings when possible
699
700## Security
701
702- Always validate input at the boundary
703- Use RBAC for multi-agent access control
704"#;
705
706 let insights = bridge.parse_markdown_insights(md);
707 assert!(!insights.is_empty());
708
709 let debugging = insights
711 .iter()
712 .find(|i| i.category == InsightCategory::Debugging);
713 assert!(debugging.is_some());
714
715 let security = insights
716 .iter()
717 .find(|i| i.category == InsightCategory::Security);
718 assert!(security.is_some());
719 }
720
721 #[test]
722 fn test_parse_empty_markdown() {
723 let temp_dir = tempfile::tempdir().unwrap();
724 let bridge = make_bridge(temp_dir.path());
725
726 let insights = bridge.parse_markdown_insights("");
727 assert!(insights.is_empty());
728 }
729
730 #[test]
731 fn test_parse_plain_text_as_single_insight() {
732 let temp_dir = tempfile::tempdir().unwrap();
733 let bridge = make_bridge(temp_dir.path());
734
735 let text = "This is a plain text memory entry without markdown formatting.";
736 let insights = bridge.parse_markdown_insights(text);
737 assert_eq!(insights.len(), 1);
738 assert_eq!(insights[0].category, InsightCategory::General);
739 }
740
741 #[test]
742 fn test_insight_category_parsing() {
743 assert_eq!(
744 InsightCategory::from_str_loose("patterns"),
745 InsightCategory::ProjectPatterns
746 );
747 assert_eq!(
748 InsightCategory::from_str_loose("debug"),
749 InsightCategory::Debugging
750 );
751 assert_eq!(
752 InsightCategory::from_str_loose("arch"),
753 InsightCategory::Architecture
754 );
755 assert_eq!(
756 InsightCategory::from_str_loose("perf"),
757 InsightCategory::Performance
758 );
759 assert_eq!(
760 InsightCategory::from_str_loose("sec"),
761 InsightCategory::Security
762 );
763 assert_eq!(
764 InsightCategory::from_str_loose("unknown"),
765 InsightCategory::General
766 );
767 }
768
769 #[test]
770 fn test_confidence_bar() {
771 let bar = format_confidence_bar(0.8);
772 assert!(bar.contains("80%"));
773
774 let bar_low = format_confidence_bar(0.2);
775 assert!(bar_low.contains("20%"));
776 }
777
778 #[tokio::test]
779 async fn test_import_from_empty_dir() {
780 let temp_dir = tempfile::tempdir().unwrap();
781 let bridge = make_bridge(temp_dir.path());
782
783 let result = bridge.import_from_auto().await.unwrap();
785 assert_eq!(result.imported, 0);
786 assert_eq!(result.failed, 0);
787 }
788
789 #[tokio::test]
790 async fn test_import_from_file() {
791 let temp_dir = tempfile::tempdir().unwrap();
792 let auto_dir = temp_dir.path().join("auto");
793 tokio::fs::create_dir_all(&auto_dir).await.unwrap();
794
795 let md = r#"- **Debugging**: Use println! for quick debugging
797- **Architecture**: Keep modules small and focused
798"#;
799 tokio::fs::write(auto_dir.join("MEMORY.md"), md)
800 .await
801 .unwrap();
802
803 let bridge = make_bridge(temp_dir.path());
804 let result = bridge.import_from_auto().await.unwrap();
805 assert!(result.imported >= 1, "Should import at least 1 insight");
806 }
807
808 #[tokio::test]
809 async fn test_export_to_auto() {
810 let temp_dir = tempfile::tempdir().unwrap();
811 let bridge = make_bridge(temp_dir.path());
812
813 let patterns = vec![
814 GuidancePattern {
815 id: "p1".to_string(),
816 category: InsightCategory::Debugging,
817 description: "Always check error chains".to_string(),
818 confidence: 0.9,
819 usage_count: 5,
820 },
821 GuidancePattern {
822 id: "p2".to_string(),
823 category: InsightCategory::Architecture,
824 description: "Use actor model for concurrency".to_string(),
825 confidence: 0.7,
826 usage_count: 3,
827 },
828 ];
829
830 let result = bridge.export_to_auto(&patterns).await.unwrap();
831 assert_eq!(result.exported, 2);
832 assert!(result.categories_updated >= 2);
833
834 assert!(bridge.auto_memory_dir.join("MEMORY.md").exists());
836 assert!(bridge.auto_memory_dir.join("debugging.md").exists());
837 assert!(bridge.auto_memory_dir.join("architecture.md").exists());
838
839 let main = tokio::fs::read_to_string(bridge.auto_memory_dir.join("MEMORY.md"))
841 .await
842 .unwrap();
843 assert!(main.contains("Oxios Memory"));
844 assert!(main.contains("Always check error chains"));
845 assert!(main.contains("Use actor model for concurrency"));
846 }
847
848 #[tokio::test]
849 async fn test_bidirectional_sync() {
850 let temp_dir = tempfile::tempdir().unwrap();
851 let auto_dir = temp_dir.path().join("auto");
852 tokio::fs::create_dir_all(&auto_dir).await.unwrap();
853
854 tokio::fs::write(
856 auto_dir.join("MEMORY.md"),
857 "- **Debugging**: Test insight for sync",
858 )
859 .await
860 .unwrap();
861
862 let bridge = make_bridge(temp_dir.path());
863 let result = bridge
864 .sync_session(SyncDirection::Bidirectional)
865 .await
866 .unwrap();
867
868 assert!(result.import.imported >= 1 || result.import.skipped_duplicates > 0);
870
871 }
873
874 #[test]
875 fn test_sync_direction_serialization() {
876 let dir = SyncDirection::ToAuto;
877 let json = serde_json::to_string(&dir).unwrap();
878 assert_eq!(json, "\"to_auto\"");
879
880 let parsed: SyncDirection = serde_json::from_str(&json).unwrap();
881 assert_eq!(parsed, SyncDirection::ToAuto);
882 }
883}