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 {
154 auto_memory_dir: PathBuf,
156 oxios_memory: std::sync::Arc<MemoryManager>,
158}
159
160impl AutoMemoryBridge {
161 pub fn new(auto_memory_dir: PathBuf, oxios_memory: std::sync::Arc<MemoryManager>) -> Self {
167 Self {
168 auto_memory_dir,
169 oxios_memory,
170 }
171 }
172
173 pub async fn import_from_auto(&self) -> Result<ImportResult> {
178 let mut result = ImportResult::default();
179
180 let memory_files = self.find_memory_files()?;
182
183 for file_path in &memory_files {
184 match self.import_file(file_path).await {
185 Ok(file_result) => {
186 result.imported += file_result.imported;
187 result.skipped_duplicates += file_result.skipped_duplicates;
188 result.failed += file_result.failed;
189 result.errors.extend(file_result.errors);
190 }
191 Err(e) => {
192 result.failed += 1;
193 result
194 .errors
195 .push(format!("{}: {}", file_path.display(), e));
196 }
197 }
198 }
199
200 Ok(result)
201 }
202
203 pub async fn export_to_auto(&self, patterns: &[GuidancePattern]) -> Result<ExportResult> {
208 let mut result = ExportResult::default();
209
210 tokio::fs::create_dir_all(&self.auto_memory_dir).await?;
212
213 let mut by_category: HashMap<InsightCategory, Vec<&GuidancePattern>> = HashMap::new();
215 for pattern in patterns {
216 by_category
217 .entry(pattern.category.clone())
218 .or_default()
219 .push(pattern);
220 }
221
222 for (category, cat_patterns) in &by_category {
224 let filename = match category {
225 InsightCategory::ProjectPatterns => "patterns.md",
226 InsightCategory::Debugging => "debugging.md",
227 InsightCategory::Architecture => "architecture.md",
228 InsightCategory::Performance => "performance.md",
229 InsightCategory::Security => "security.md",
230 InsightCategory::General => "general.md",
231 };
232
233 let content = self.format_patterns_md(cat_patterns);
234 let path = self.auto_memory_dir.join(filename);
235
236 tokio::fs::write(&path, &content).await?;
237 result.categories_updated += 1;
238 }
239
240 let mut all_patterns: Vec<&GuidancePattern> = patterns.iter().collect();
242 all_patterns.sort_by(|a, b| {
243 b.confidence
244 .partial_cmp(&a.confidence)
245 .unwrap_or(std::cmp::Ordering::Equal)
246 });
247
248 let main_content = self.format_main_md(&all_patterns);
249 let main_path = self.auto_memory_dir.join("MEMORY.md");
250 tokio::fs::write(&main_path, &main_content).await?;
251
252 result.exported = patterns.len();
253 result.categories_updated += 1; Ok(result)
255 }
256
257 pub async fn sync_session(&self, direction: SyncDirection) -> Result<SyncResult> {
259 let mut sync_result = SyncResult::default();
260
261 match direction {
262 SyncDirection::FromAuto => {
263 sync_result.import = self.import_from_auto().await?;
264 }
265 SyncDirection::ToAuto => {
266 sync_result.export = self.export_knowledge_to_auto().await?;
267 }
268 SyncDirection::Bidirectional => {
269 sync_result.import = self.import_from_auto().await?;
270 sync_result.export = self.export_knowledge_to_auto().await?;
271 }
272 }
273
274 Ok(sync_result)
275 }
276
277 pub fn auto_memory_dir(&self) -> &Path {
279 &self.auto_memory_dir
280 }
281
282 async fn export_knowledge_to_auto(&self) -> Result<ExportResult> {
284 let entries = self
285 .oxios_memory
286 .list(MemoryType::Knowledge, 1000)
287 .await
288 .unwrap_or_default();
289
290 let patterns: Vec<GuidancePattern> = entries
291 .iter()
292 .map(|e| GuidancePattern {
293 id: e.id.clone(),
294 category: e
295 .tags
296 .first()
297 .map(|t| InsightCategory::from_str_loose(t))
298 .unwrap_or(InsightCategory::General),
299 description: e.content.clone(),
300 confidence: e.importance,
301 usage_count: e.access_count,
302 })
303 .collect();
304
305 self.export_to_auto(&patterns).await
306 }
307}
308
309impl AutoMemoryBridge {
314 fn find_memory_files(&self) -> Result<Vec<PathBuf>> {
316 let mut files = Vec::new();
317
318 if self.auto_memory_dir.exists() {
319 let main = self.auto_memory_dir.join("MEMORY.md");
321 if main.exists() {
322 files.push(main);
323 }
324
325 for topic in &[
327 "patterns.md",
328 "debugging.md",
329 "architecture.md",
330 "performance.md",
331 "security.md",
332 "general.md",
333 ] {
334 let path = self.auto_memory_dir.join(topic);
335 if path.exists() {
336 files.push(path);
337 }
338 }
339
340 if let Ok(entries) = std::fs::read_dir(&self.auto_memory_dir) {
342 for entry in entries.flatten() {
343 let path = entry.path();
344 if path.extension().is_some_and(|ext| ext == "md") {
345 let name = path.file_name().unwrap_or_default().to_string_lossy();
346 if ![
348 "MEMORY.md",
349 "patterns.md",
350 "debugging.md",
351 "architecture.md",
352 "performance.md",
353 "security.md",
354 "general.md",
355 ]
356 .contains(&name.as_ref())
357 {
358 files.push(path);
359 }
360 }
361 }
362 }
363 }
364
365 Ok(files)
366 }
367
368 async fn import_file(&self, path: &Path) -> Result<ImportResult> {
370 let content = tokio::fs::read_to_string(path).await?;
371 let insights = self.parse_markdown_insights(&content);
372 let mut result = ImportResult::default();
373
374 for insight in &insights {
375 if self.oxios_memory.is_duplicate(&insight.summary).await {
377 result.skipped_duplicates += 1;
378 continue;
379 }
380
381 let entry = MemoryEntry {
382 id: format!(
383 "auto-{}-{}",
384 insight.source,
385 chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
386 ),
387 memory_type: MemoryType::Knowledge,
388 content: match &insight.detail {
389 Some(d) => format!("{}\n\n{}", insight.summary, d),
390 None => insight.summary.clone(),
391 },
392 source: insight.source.clone(),
393 session_id: None,
394 tags: vec![insight.category.to_tag().to_string()],
395 importance: insight.confidence,
396 created_at: Utc::now(),
397 accessed_at: Utc::now(),
398 access_count: 0,
399 };
400
401 match self.oxios_memory.remember(entry).await {
402 Ok(_) => result.imported += 1,
403 Err(e) => {
404 result.failed += 1;
405 result.errors.push(e.to_string());
406 tracing::warn!(error = %e, "Failed to import memory insight");
407 }
408 }
409 }
410
411 Ok(result)
412 }
413
414 fn parse_markdown_insights(&self, content: &str) -> Vec<MemoryInsight> {
421 let mut insights = Vec::new();
422 let mut current_category = InsightCategory::General;
423
424 for line in content.lines() {
425 let trimmed = line.trim();
426
427 if trimmed.is_empty() {
429 continue;
430 }
431
432 if trimmed.starts_with('#') {
434 let header = trimmed.trim_start_matches('#').trim().to_lowercase();
435 current_category = InsightCategory::from_str_loose(&header);
436 continue;
437 }
438
439 if trimmed.starts_with('-') || trimmed.starts_with('*') {
441 let item = if trimmed.starts_with('-') {
443 trimmed.trim_start_matches('-').trim_start()
444 } else {
445 trimmed.trim_start_matches('*').trim_start()
446 };
447
448 if let Some(rest) = Self::extract_bold_category(item) {
450 let (cat_name, description) = rest;
451 let category = InsightCategory::from_str_loose(cat_name);
452
453 let (summary, detail) = if let Some(pos) = description.find(':') {
455 let s = description[..pos].trim();
456 let d = description[pos + 1..].trim();
457 (
458 s.to_string(),
459 if d.is_empty() {
460 None
461 } else {
462 Some(d.to_string())
463 },
464 )
465 } else {
466 (description.to_string(), None)
467 };
468
469 if !summary.is_empty() {
470 insights.push(MemoryInsight {
471 category,
472 summary,
473 detail,
474 source: "auto-import".to_string(),
475 confidence: 0.7,
476 });
477 }
478 } else if !item.is_empty() {
479 insights.push(MemoryInsight {
481 category: current_category.clone(),
482 summary: item.to_string(),
483 detail: None,
484 source: "auto-import".to_string(),
485 confidence: 0.6,
486 });
487 }
488 }
489 }
490
491 if insights.is_empty() && !content.trim().is_empty() {
493 let summary: String = content
494 .lines()
495 .take(3)
496 .collect::<Vec<_>>()
497 .join(" ")
498 .chars()
499 .take(200)
500 .collect();
501
502 if !summary.trim().is_empty() {
503 insights.push(MemoryInsight {
504 category: InsightCategory::General,
505 summary,
506 detail: Some(content.to_string()),
507 source: "auto-import".to_string(),
508 confidence: 0.5,
509 });
510 }
511 }
512
513 insights
514 }
515
516 fn extract_bold_category(item: &str) -> Option<(&str, &str)> {
520 if !item.starts_with("**") {
521 return None;
522 }
523
524 let end = item[2..].find("**")?;
525 let category = &item[2..2 + end];
526 let rest = item[2 + end + 2..].trim_start_matches([' ', ':']);
527
528 Some((category, rest))
529 }
530
531 fn format_patterns_md(&self, patterns: &[&GuidancePattern]) -> String {
533 let mut md = String::new();
534 md.push_str("# Memory Insights\n\n");
535
536 for pattern in patterns {
537 let confidence_bar = format_confidence_bar(pattern.confidence);
538 md.push_str(&format!(
539 "- **{}**: {} [{}]\n",
540 pattern.category.to_tag(),
541 pattern.description,
542 confidence_bar,
543 ));
544 }
545
546 md.push('\n');
547 md
548 }
549
550 fn format_main_md(&self, patterns: &[&GuidancePattern]) -> String {
552 let mut md = String::new();
553 md.push_str("# Oxios Memory\n\n");
554 md.push_str(&format!(
555 "Auto-generated at {}\n\n",
556 Utc::now().to_rfc3339()
557 ));
558 md.push_str("## Insights\n\n");
559
560 for pattern in patterns {
561 let confidence_pct = (pattern.confidence * 100.0) as u8;
562 md.push_str(&format!(
563 "- **{}** [{}%]: {}\n",
564 pattern.category.to_tag(),
565 confidence_pct,
566 pattern.description,
567 ));
568 }
569
570 md.push('\n');
571 md
572 }
573}
574
575fn format_confidence_bar(confidence: f32) -> String {
577 let bars = (confidence * 5.0).round() as usize;
578 let bars = bars.min(5);
579 let filled: String = "█".repeat(bars);
580 let empty: String = "░".repeat(5 - bars);
581 format!("{}{} {:.0}%", filled, empty, confidence * 100.0)
582}
583
584#[cfg(test)]
589mod tests {
590 use super::*;
591 use std::sync::Arc;
592
593 fn make_bridge(dir: &Path) -> AutoMemoryBridge {
594 let store = Arc::new(crate::state_store::StateStore::new(dir.join("state")).unwrap());
595 let memory = Arc::new(MemoryManager::new(store));
596 AutoMemoryBridge::new(dir.join("auto"), memory)
597 }
598
599 #[test]
600 fn test_parse_bold_category() {
601 let result =
602 AutoMemoryBridge::extract_bold_category("**Debugging**: Use trace-level logging");
603 assert!(result.is_some());
604 let (cat, rest) = result.unwrap();
605 assert_eq!(cat, "Debugging");
606 assert_eq!(rest, "Use trace-level logging");
607 }
608
609 #[test]
610 fn test_parse_bold_category_no_colon() {
611 let result = AutoMemoryBridge::extract_bold_category("**Security** important rule");
612 assert!(result.is_some());
613 let (cat, rest) = result.unwrap();
614 assert_eq!(cat, "Security");
615 assert_eq!(rest, "important rule");
616 }
617
618 #[test]
619 fn test_parse_no_bold() {
620 let result = AutoMemoryBridge::extract_bold_category("Just a plain item");
621 assert!(result.is_none());
622 }
623
624 #[test]
625 fn test_parse_markdown_insights() {
626 let temp_dir = tempfile::tempdir().unwrap();
627 let bridge = make_bridge(temp_dir.path());
628
629 let md = r#"# Project Patterns
630
631- **Debugging**: Use trace-level logging for async tasks
632- **Architecture**: Follow the kernel module pattern
633- **Performance**: Batch embeddings when possible
634
635## Security
636
637- Always validate input at the boundary
638- Use RBAC for multi-agent access control
639"#;
640
641 let insights = bridge.parse_markdown_insights(md);
642 assert!(!insights.is_empty());
643
644 let debugging = insights
646 .iter()
647 .find(|i| i.category == InsightCategory::Debugging);
648 assert!(debugging.is_some());
649
650 let security = insights
651 .iter()
652 .find(|i| i.category == InsightCategory::Security);
653 assert!(security.is_some());
654 }
655
656 #[test]
657 fn test_parse_empty_markdown() {
658 let temp_dir = tempfile::tempdir().unwrap();
659 let bridge = make_bridge(temp_dir.path());
660
661 let insights = bridge.parse_markdown_insights("");
662 assert!(insights.is_empty());
663 }
664
665 #[test]
666 fn test_parse_plain_text_as_single_insight() {
667 let temp_dir = tempfile::tempdir().unwrap();
668 let bridge = make_bridge(temp_dir.path());
669
670 let text = "This is a plain text memory entry without markdown formatting.";
671 let insights = bridge.parse_markdown_insights(text);
672 assert_eq!(insights.len(), 1);
673 assert_eq!(insights[0].category, InsightCategory::General);
674 }
675
676 #[test]
677 fn test_insight_category_parsing() {
678 assert_eq!(
679 InsightCategory::from_str_loose("patterns"),
680 InsightCategory::ProjectPatterns
681 );
682 assert_eq!(
683 InsightCategory::from_str_loose("debug"),
684 InsightCategory::Debugging
685 );
686 assert_eq!(
687 InsightCategory::from_str_loose("arch"),
688 InsightCategory::Architecture
689 );
690 assert_eq!(
691 InsightCategory::from_str_loose("perf"),
692 InsightCategory::Performance
693 );
694 assert_eq!(
695 InsightCategory::from_str_loose("sec"),
696 InsightCategory::Security
697 );
698 assert_eq!(
699 InsightCategory::from_str_loose("unknown"),
700 InsightCategory::General
701 );
702 }
703
704 #[test]
705 fn test_confidence_bar() {
706 let bar = format_confidence_bar(0.8);
707 assert!(bar.contains("80%"));
708
709 let bar_low = format_confidence_bar(0.2);
710 assert!(bar_low.contains("20%"));
711 }
712
713 #[tokio::test]
714 async fn test_import_from_empty_dir() {
715 let temp_dir = tempfile::tempdir().unwrap();
716 let bridge = make_bridge(temp_dir.path());
717
718 let result = bridge.import_from_auto().await.unwrap();
720 assert_eq!(result.imported, 0);
721 assert_eq!(result.failed, 0);
722 }
723
724 #[tokio::test]
725 async fn test_import_from_file() {
726 let temp_dir = tempfile::tempdir().unwrap();
727 let auto_dir = temp_dir.path().join("auto");
728 tokio::fs::create_dir_all(&auto_dir).await.unwrap();
729
730 let md = r#"- **Debugging**: Use println! for quick debugging
732- **Architecture**: Keep modules small and focused
733"#;
734 tokio::fs::write(auto_dir.join("MEMORY.md"), md)
735 .await
736 .unwrap();
737
738 let bridge = make_bridge(temp_dir.path());
739 let result = bridge.import_from_auto().await.unwrap();
740 assert!(result.imported >= 1, "Should import at least 1 insight");
741 }
742
743 #[tokio::test]
744 async fn test_export_to_auto() {
745 let temp_dir = tempfile::tempdir().unwrap();
746 let bridge = make_bridge(temp_dir.path());
747
748 let patterns = vec![
749 GuidancePattern {
750 id: "p1".to_string(),
751 category: InsightCategory::Debugging,
752 description: "Always check error chains".to_string(),
753 confidence: 0.9,
754 usage_count: 5,
755 },
756 GuidancePattern {
757 id: "p2".to_string(),
758 category: InsightCategory::Architecture,
759 description: "Use actor model for concurrency".to_string(),
760 confidence: 0.7,
761 usage_count: 3,
762 },
763 ];
764
765 let result = bridge.export_to_auto(&patterns).await.unwrap();
766 assert_eq!(result.exported, 2);
767 assert!(result.categories_updated >= 2);
768
769 assert!(bridge.auto_memory_dir.join("MEMORY.md").exists());
771 assert!(bridge.auto_memory_dir.join("debugging.md").exists());
772 assert!(bridge.auto_memory_dir.join("architecture.md").exists());
773
774 let main = tokio::fs::read_to_string(bridge.auto_memory_dir.join("MEMORY.md"))
776 .await
777 .unwrap();
778 assert!(main.contains("Oxios Memory"));
779 assert!(main.contains("Always check error chains"));
780 assert!(main.contains("Use actor model for concurrency"));
781 }
782
783 #[tokio::test]
784 async fn test_bidirectional_sync() {
785 let temp_dir = tempfile::tempdir().unwrap();
786 let auto_dir = temp_dir.path().join("auto");
787 tokio::fs::create_dir_all(&auto_dir).await.unwrap();
788
789 tokio::fs::write(
791 auto_dir.join("MEMORY.md"),
792 "- **Debugging**: Test insight for sync",
793 )
794 .await
795 .unwrap();
796
797 let bridge = make_bridge(temp_dir.path());
798 let result = bridge
799 .sync_session(SyncDirection::Bidirectional)
800 .await
801 .unwrap();
802
803 assert!(result.import.imported >= 1 || result.import.skipped_duplicates > 0);
805
806 assert!(result.export.exported >= 0);
808 }
809
810 #[test]
811 fn test_sync_direction_serialization() {
812 let dir = SyncDirection::ToAuto;
813 let json = serde_json::to_string(&dir).unwrap();
814 assert_eq!(json, "\"to_auto\"");
815
816 let parsed: SyncDirection = serde_json::from_str(&json).unwrap();
817 assert_eq!(parsed, SyncDirection::ToAuto);
818 }
819}