diff --git a/src/capnp_store.rs b/src/capnp_store.rs index 6bab353..71d6a6c 100644 --- a/src/capnp_store.rs +++ b/src/capnp_store.rs @@ -1071,9 +1071,6 @@ impl Store { let entries = fs::read_dir(dir) .map_err(|e| format!("read dir {}: {}", dir.display(), e))?; - // Track which keys we see in markdown so we can detect removed sections - let mut seen_keys = std::collections::HashSet::new(); - for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { @@ -1088,73 +1085,11 @@ impl Store { .map_err(|e| format!("read {}: {}", path.display(), e))?; let units = parse_units(&filename, &content); - let mut new_nodes = Vec::new(); - let mut updated_nodes = Vec::new(); + let (new_count, _) = self.ingest_units(&units, &filename)?; + count += new_count; + + // Create relations from links let mut new_relations = Vec::new(); - - // Determine node type from filename - let node_type = if filename.starts_with("daily-") { - NodeType::EpisodicDaily - } else if filename.starts_with("weekly-") { - NodeType::EpisodicWeekly - } else if filename == "journal.md" { - NodeType::EpisodicSession - } else { - NodeType::Semantic - }; - - for (pos, unit) in units.iter().enumerate() { - seen_keys.insert(unit.key.clone()); - - if let Some(existing) = self.nodes.get(&unit.key) { - // Update if content or position changed - let pos_changed = existing.position != pos as u32; - if existing.content != unit.content || pos_changed { - let mut node = existing.clone(); - node.content = unit.content.clone(); - node.position = pos as u32; - node.version += 1; - if let Some(ref state) = unit.state { - node.state_tag = state.clone(); - } - if let Some(ref src) = unit.source_ref { - node.source_ref = src.clone(); - } - updated_nodes.push(node); - } - } else { - let mut node = Store::new_node(&unit.key, &unit.content); - node.node_type = node_type; - node.position = pos as u32; - if let Some(ref state) = unit.state { - node.state_tag = state.clone(); - } - if let Some(ref src) = unit.source_ref { - node.source_ref = src.clone(); - } - new_nodes.push(node); - } - } - - // Batch append new nodes - if !new_nodes.is_empty() { - self.append_nodes(&new_nodes)?; - for node in &new_nodes { - self.uuid_to_key.insert(node.uuid, node.key.clone()); - self.nodes.insert(node.key.clone(), node.clone()); - } - count += new_nodes.len(); - } - - // Batch append updated nodes - if !updated_nodes.is_empty() { - self.append_nodes(&updated_nodes)?; - for node in &updated_nodes { - self.nodes.insert(node.key.clone(), node.clone()); - } - } - - // Create relations from links, using resolve_redirect for targets for unit in &units { let source_uuid = match self.nodes.get(&unit.key) { Some(n) => n.uuid, @@ -1162,54 +1097,28 @@ impl Store { }; for link in unit.marker_links.iter().chain(unit.md_links.iter()) { - // Try direct lookup, then redirect - let resolved_link = if self.nodes.contains_key(link) { - link.clone() - } else if let Some(redirect) = self.resolve_redirect(link) { - redirect - } else { - continue; - }; - let target_uuid = match self.nodes.get(&resolved_link) { - Some(n) => n.uuid, - None => continue, - }; - // Check if relation already exists + let Some((key, uuid)) = self.resolve_node_uuid(link) else { continue }; let exists = self.relations.iter().any(|r| - (r.source == source_uuid && r.target == target_uuid) || - (r.source == target_uuid && r.target == source_uuid)); + (r.source == source_uuid && r.target == uuid) || + (r.source == uuid && r.target == source_uuid)); if !exists { - let rel = Store::new_relation( - source_uuid, target_uuid, - RelationType::Link, 1.0, - &unit.key, &resolved_link, - ); - new_relations.push(rel); + new_relations.push(Store::new_relation( + source_uuid, uuid, RelationType::Link, 1.0, + &unit.key, &key, + )); } } for cause in &unit.causes { - let resolved_cause = if self.nodes.contains_key(cause) { - cause.clone() - } else if let Some(redirect) = self.resolve_redirect(cause) { - redirect - } else { - continue; - }; - let target_uuid = match self.nodes.get(&resolved_cause) { - Some(n) => n.uuid, - None => continue, - }; + let Some((key, uuid)) = self.resolve_node_uuid(cause) else { continue }; let exists = self.relations.iter().any(|r| - r.source == target_uuid && r.target == source_uuid + r.source == uuid && r.target == source_uuid && r.rel_type == RelationType::Causal); if !exists { - let rel = Store::new_relation( - target_uuid, source_uuid, - RelationType::Causal, 1.0, - &resolved_cause, &unit.key, - ); - new_relations.push(rel); + new_relations.push(Store::new_relation( + uuid, source_uuid, RelationType::Causal, 1.0, + &key, &unit.key, + )); } } } @@ -1309,6 +1218,16 @@ impl Store { .map(|(_, to)| to.to_string()) } + /// Resolve a link target to (key, uuid), trying direct lookup then redirect. + fn resolve_node_uuid(&self, target: &str) -> Option<(String, [u8; 16])> { + if let Some(n) = self.nodes.get(target) { + return Some((target.to_string(), n.uuid)); + } + let redirected = self.resolve_redirect(target)?; + let n = self.nodes.get(&redirected)?; + Some((redirected, n.uuid)) + } + pub fn log_retrieval(&mut self, query: &str, results: &[String]) { self.retrieval_log.push(RetrievalEvent { query: query.to_string(), @@ -1339,41 +1258,36 @@ impl Store { } } + /// Modify a node in-place, bump version, and persist to capnp log. + fn modify_node(&mut self, key: &str, f: impl FnOnce(&mut Node)) -> Result<(), String> { + let node = self.nodes.get_mut(key) + .ok_or_else(|| format!("No node '{}'", key))?; + f(node); + node.version += 1; + let node = node.clone(); + self.append_nodes(&[node]) + } + pub fn mark_used(&mut self, key: &str) { - let updated = if let Some(node) = self.nodes.get_mut(key) { - node.uses += 1; - node.weight = (node.weight + self.params.use_boost as f32).min(1.0); - // Reset spaced repetition — used successfully, move up interval - if node.spaced_repetition_interval < 30 { - node.spaced_repetition_interval = match node.spaced_repetition_interval { + let boost = self.params.use_boost as f32; + let _ = self.modify_node(key, |n| { + n.uses += 1; + n.weight = (n.weight + boost).min(1.0); + if n.spaced_repetition_interval < 30 { + n.spaced_repetition_interval = match n.spaced_repetition_interval { 1 => 3, 3 => 7, 7 => 14, 14 => 30, _ => 30, }; } - node.last_replayed = now_epoch(); - node.version += 1; - Some(node.clone()) - } else { - None - }; - if let Some(node) = updated { - let _ = self.append_nodes(&[node]); - } + n.last_replayed = now_epoch(); + }); } pub fn mark_wrong(&mut self, key: &str, _ctx: Option<&str>) { - let updated = if let Some(node) = self.nodes.get_mut(key) { - node.wrongs += 1; - node.weight = (node.weight - 0.1).max(0.0); - // Reset spaced repetition interval — needs review - node.spaced_repetition_interval = 1; - node.version += 1; - Some(node.clone()) - } else { - None - }; - if let Some(node) = updated { - let _ = self.append_nodes(&[node]); - } + let _ = self.modify_node(key, |n| { + n.wrongs += 1; + n.weight = (n.weight - 0.1).max(0.0); + n.spaced_repetition_interval = 1; + }); } pub fn record_gap(&mut self, desc: &str) { @@ -1386,20 +1300,7 @@ impl Store { pub fn categorize(&mut self, key: &str, cat_str: &str) -> Result<(), String> { let cat = Category::from_str(cat_str) .ok_or_else(|| format!("Unknown category '{}'. Use: core/tech/gen/obs/task", cat_str))?; - let updated = if let Some(node) = self.nodes.get_mut(key) { - node.category = cat; - node.version += 1; - Some(node.clone()) - } else { - None - }; - if let Some(node) = updated { - // Persist to capnp log so category survives cache rebuilds - self.append_nodes(&[node])?; - Ok(()) - } else { - Err(format!("No node '{}'", key)) - } + self.modify_node(key, |n| { n.category = cat; }) } pub fn decay(&mut self) -> (usize, usize) { @@ -1556,7 +1457,7 @@ impl Store { let excess = active.len() - max_degree; // Sort Auto by strength ascending - auto_indices.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap()); + auto_indices.sort_by(|a, b| a.1.total_cmp(&b.1)); let auto_prune = excess.min(auto_indices.len()); for &(i, _) in auto_indices.iter().take(auto_prune) { to_delete.insert(i); @@ -1615,43 +1516,30 @@ impl Store { } } - /// Import a markdown file into the store, parsing it into nodes. + /// Process parsed memory units: diff against existing nodes, persist changes. /// Returns (new_count, updated_count). - pub fn import_file(&mut self, path: &Path) -> Result<(usize, usize), String> { - let filename = path.file_name().unwrap().to_string_lossy().to_string(); - let content = fs::read_to_string(path) - .map_err(|e| format!("read {}: {}", path.display(), e))?; - - let units = parse_units(&filename, &content); + fn ingest_units(&mut self, units: &[MemoryUnit], filename: &str) -> Result<(usize, usize), String> { + let node_type = classify_filename(filename); let mut new_nodes = Vec::new(); let mut updated_nodes = Vec::new(); - let node_type = if filename.starts_with("daily-") { - NodeType::EpisodicDaily - } else if filename.starts_with("weekly-") { - NodeType::EpisodicWeekly - } else if filename == "journal.md" { - NodeType::EpisodicSession - } else { - NodeType::Semantic - }; - for (pos, unit) in units.iter().enumerate() { if let Some(existing) = self.nodes.get(&unit.key) { - let pos_changed = existing.position != pos as u32; - if existing.content != unit.content || pos_changed { + if existing.content != unit.content || existing.position != pos as u32 { let mut node = existing.clone(); node.content = unit.content.clone(); node.position = pos as u32; node.version += 1; - println!(" U {}", unit.key); + if let Some(ref s) = unit.state { node.state_tag = s.clone(); } + if let Some(ref s) = unit.source_ref { node.source_ref = s.clone(); } updated_nodes.push(node); } } else { let mut node = Store::new_node(&unit.key, &unit.content); node.node_type = node_type; node.position = pos as u32; - println!(" + {}", unit.key); + if let Some(ref s) = unit.state { node.state_tag = s.clone(); } + if let Some(ref s) = unit.source_ref { node.source_ref = s.clone(); } new_nodes.push(node); } } @@ -1673,6 +1561,16 @@ impl Store { Ok((new_nodes.len(), updated_nodes.len())) } + /// Import a markdown file into the store, parsing it into nodes. + /// Returns (new_count, updated_count). + pub fn import_file(&mut self, path: &Path) -> Result<(usize, usize), String> { + let filename = path.file_name().unwrap().to_string_lossy().to_string(); + let content = fs::read_to_string(path) + .map_err(|e| format!("read {}: {}", path.display(), e))?; + let units = parse_units(&filename, &content); + self.ingest_units(&units, &filename) + } + /// Gather all sections for a file key, sorted by position. /// Returns None if no nodes found. pub fn file_sections(&self, file_key: &str) -> Option> { @@ -1777,6 +1675,13 @@ impl Store { // Markdown parsing — same as old system but returns structured units +fn classify_filename(filename: &str) -> NodeType { + if filename.starts_with("daily-") { NodeType::EpisodicDaily } + else if filename.starts_with("weekly-") { NodeType::EpisodicWeekly } + else if filename == "journal.md" { NodeType::EpisodicSession } + else { NodeType::Semantic } +} + pub struct MemoryUnit { pub key: String, pub content: String,