use crate::EventRecord; use anyhow::Result; use std::collections::{BTreeMap, HashMap}; use std::fs; /// Obsidian vault projector (deterministic, byte-identical). pub struct ObsidianProjector { vault_dir: String, _emit_evidence: bool, } /// Vault note metadata (stable frontmatter order). #[derive(Debug, Clone)] pub struct VaultNote { pub project: String, pub level: String, pub query_id: Option, pub updated: String, pub chunks_seen: u32, pub chunks_used: u32, pub run_id: String, pub body: String, pub parents: Vec<(String, String)>, } impl ObsidianProjector { /// Create projector. pub fn new(_log_dir: &str, vault_dir: &str, emit_evidence: bool) -> Self { Self { vault_dir: vault_dir.to_string(), _emit_evidence: emit_evidence, } } /// Project log to vault (deterministic). pub fn project(&self, events: &[EventRecord]) -> Result<()> { fs::create_dir_all(&self.vault_dir)?; // Group by project and query let mut by_project: HashMap>> = HashMap::new(); for event in events { by_project .entry(event.project.clone()) .or_insert_with(HashMap::new) .entry(event.query.clone()) .or_insert_with(Vec::new) .push(event); } // Generate notes per project (in sorted order for determinism) let mut sorted_projects: Vec<_> = by_project.iter().collect(); sorted_projects.sort_by_key(|(p, _)| p.as_str()); for (project, queries) in sorted_projects { let proj_dir = format!("{}/{}", self.vault_dir, project); fs::create_dir_all(&proj_dir)?; // Generate index (L2) let index_note = VaultNote { project: project.clone(), level: "L2".to_string(), query_id: None, updated: "2026-01-01".to_string(), chunks_seen: 0, chunks_used: 0, run_id: "index".to_string(), body: String::new(), parents: vec![], }; self.write_note(&proj_dir, "index", &index_note)?; // Generate per-query notes (L1) in sorted order let mut sorted_queries: Vec<_> = queries.iter().collect(); sorted_queries.sort_by_key(|(qid, _)| qid.as_str()); for (query_id, query_events) in sorted_queries { let (chunks_seen, chunks_used, body, parents) = Self::summarize_query(query_events); let note = VaultNote { project: project.clone(), level: "L1".to_string(), query_id: Some(query_id.to_string()), updated: "2026-01-01".to_string(), chunks_seen, chunks_used, run_id: "run1".to_string(), body, parents, }; self.write_note(&proj_dir, query_id, ¬e)?; } } Ok(()) } /// Write note with deterministic formatting. fn write_note(&self, dir: &str, name: &str, note: &VaultNote) -> Result<()> { // Stable frontmatter order (BTreeMap keeps keys sorted) let mut fm = BTreeMap::new(); fm.insert("chunks_seen", note.chunks_seen.to_string()); fm.insert("chunks_used", note.chunks_used.to_string()); fm.insert("level", note.level.clone()); fm.insert("project", note.project.clone()); if let Some(qid) = ¬e.query_id { fm.insert("query_id", qid.clone()); } fm.insert("run_id", note.run_id.clone()); fm.insert("updated", note.updated.clone()); // Build frontmatter let mut content = String::from("---\n"); for (k, v) in fm.iter() { content.push_str(&format!("{}: {}\n", k, v)); } content.push_str("---\n"); // Title let title = note.query_id.as_ref().unwrap_or(¬e.project); content.push_str(&format!("# {}\n\n", title)); // Body if note.body.is_empty() { content.push_str("No evidence found.\n\n"); } else { content.push_str(¬e.body); if !note.body.ends_with('\n') { content.push('\n'); } content.push('\n'); } // Provenance (sorted) if !note.parents.is_empty() { content.push_str("## Provenance\n"); let mut sorted_parents = note.parents.clone(); sorted_parents.sort(); for (source, time) in sorted_parents { content.push_str(&format!("- [[{}-{}]]\n", source, time)); } } // Ensure exactly one trailing newline if !content.ends_with('\n') { content.push('\n'); } // Write to file let path = format!("{}/{}.md", dir, name); fs::write(&path, &content)?; Ok(()) } /// Summarize query events. fn summarize_query( events: &[&EventRecord], ) -> (u32, u32, String, Vec<(String, String)>) { let mut chunks_seen = 0u32; let mut chunks_used = 0u32; let mut body = String::new(); let mut parents = Vec::new(); for event in events.iter() { if event.event_type.contains("Gate") { chunks_seen += 1; } if event.event_type.contains("Evidence") { chunks_used += 1; } // Simplified parent extraction if let Some(obj) = event.data.as_object() { if let Some(parent) = obj.get("parent") { if let Some(s) = parent.as_str() { parents.push((s.to_string(), format!("t{}", event.turn))); } } } } if chunks_used > 0 { body = format!( "Extracted from {} chunks, using {}\n", chunks_seen, chunks_used ); } (chunks_seen, chunks_used, body, parents) } }