diff --git a/src/cli/admin.rs b/src/cli/admin.rs index f9f271a..464e97c 100644 --- a/src/cli/admin.rs +++ b/src/cli/admin.rs @@ -56,8 +56,30 @@ pub async fn cmd_init() -> Result<()> { } pub async fn cmd_fsck() -> Result<()> { - // Check/repair capnp log integrity first - store::fsck()?; + // Full fsck: verify capnp logs and compare index with rebuilt + let report = store::fsck_full()?; + + if report.capnp_repaired { + eprintln!("capnp log was repaired (corrupt messages truncated)"); + } + + if !report.zombies.is_empty() { + eprintln!("\nZOMBIE entries (in index but not in log):"); + for key in &report.zombies { + eprintln!(" {}", key); + } + } + + if !report.missing.is_empty() { + eprintln!("\nMISSING entries (in log but not in index):"); + for key in &report.missing { + eprintln!(" {}", key); + } + } + + if !report.is_clean() { + eprintln!("\nTo repair: poc-memory admin repair-index"); + } let store = memory::access_local()?; @@ -110,6 +132,12 @@ pub async fn cmd_fsck() -> Result<()> { Ok(()) } +pub async fn cmd_repair_index() -> Result<()> { + store::repair_index()?; + println!("Index repaired successfully."); + Ok(()) +} + pub async fn cmd_dedup(apply: bool) -> Result<()> { use std::collections::HashMap; @@ -255,6 +283,7 @@ pub async fn cmd_dedup(apply: bool) -> Result<()> { store::RelationType::from_u8(rel_type), strength, &uuid_to_key.get(&old_src).cloned().unwrap_or_default(), &uuid_to_key.get(&old_tgt).cloned().unwrap_or_default(), + "system", ); tombstone.deleted = true; tombstone.version = 2; @@ -263,6 +292,7 @@ pub async fn cmd_dedup(apply: bool) -> Result<()> { new_src, new_tgt, store::RelationType::from_u8(rel_type), strength, &src_key, &tgt_key, + "system", ); redirected.version = 2; @@ -299,6 +329,7 @@ pub async fn cmd_dedup(apply: bool) -> Result<()> { src, tgt, store::RelationType::from_u8(rel_type), strength, &src_key, &tgt_key, + "system", ); tombstone.deleted = true; tombstone.version = 2; diff --git a/src/config.rs b/src/config.rs index 4a7e02a..09793a1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -78,6 +78,9 @@ pub struct Config { #[serde(deserialize_with = "deserialize_path")] pub projects_dir: PathBuf, pub core_nodes: Vec, + /// Nodes that cannot be deleted or renamed without --force + #[serde(default)] + pub protected_nodes: Vec, pub journal_days: u32, pub journal_max: usize, pub context_groups: Vec, @@ -146,6 +149,7 @@ impl Default for Config { identity_dir: home.join(".consciousness/identity"), projects_dir: home.join(".claude/projects"), core_nodes: vec!["identity".to_string(), "core-practices".to_string()], + protected_nodes: Vec::new(), journal_days: 7, journal_max: 20, context_groups: vec![ diff --git a/src/hippocampus/local.rs b/src/hippocampus/local.rs index 0616877..b4db33e 100644 --- a/src/hippocampus/local.rs +++ b/src/hippocampus/local.rs @@ -91,10 +91,10 @@ pub fn memory_links(store: &Store, _provenance: &str, key: &str) -> Result Result { +pub fn memory_link_set(store: &Store, provenance: &str, source: &str, target: &str, strength: f32) -> Result { let s = store.resolve_key(source).map_err(|e| anyhow::anyhow!("{}", e))?; let t = store.resolve_key(target).map_err(|e| anyhow::anyhow!("{}", e))?; - let old = store.set_link_strength(&s, &t, strength).map_err(|e| anyhow::anyhow!("{}", e))?; + let old = store.set_link_strength(&s, &t, strength, provenance).map_err(|e| anyhow::anyhow!("{}", e))?; store.save().map_err(|e| anyhow::anyhow!("{}", e))?; Ok(format!("{} ↔ {} strength {:.2} → {:.2}", s, t, old, strength)) } @@ -107,13 +107,19 @@ pub fn memory_link_add(store: &Store, provenance: &str, source: &str, target: &s Ok(format!("linked {} → {} (strength={:.2})", s, t, strength)) } -pub fn memory_delete(store: &Store, _provenance: &str, key: &str) -> Result { +pub fn memory_delete(store: &Store, provenance: &str, key: &str) -> Result { let resolved = store.resolve_key(key).map_err(|e| anyhow::anyhow!("{}", e))?; - store.delete_node(&resolved).map_err(|e| anyhow::anyhow!("{}", e))?; + store.delete_node(&resolved, provenance).map_err(|e| anyhow::anyhow!("{}", e))?; store.save().map_err(|e| anyhow::anyhow!("{}", e))?; Ok(format!("deleted {}", resolved)) } +pub fn memory_restore(store: &Store, provenance: &str, key: &str) -> Result { + let result = store.restore_node(key, provenance).map_err(|e| anyhow::anyhow!("{}", e))?; + store.save().map_err(|e| anyhow::anyhow!("{}", e))?; + Ok(result) +} + pub fn memory_history(store: &Store, _provenance: &str, key: &str, full: Option) -> Result { let key = store.resolve_key(key).unwrap_or_else(|_| key.to_string()); let full = full.unwrap_or(false); @@ -171,9 +177,9 @@ pub fn memory_weight_set(store: &Store, _provenance: &str, key: &str, weight: f3 Ok(format!("weight {} {:.2} → {:.2}", resolved, old, new)) } -pub fn memory_rename(store: &Store, _provenance: &str, old_key: &str, new_key: &str) -> Result { +pub fn memory_rename(store: &Store, provenance: &str, old_key: &str, new_key: &str) -> Result { let resolved = store.resolve_key(old_key).map_err(|e| anyhow::anyhow!("{}", e))?; - store.rename_node(&resolved, new_key).map_err(|e| anyhow::anyhow!("{}", e))?; + store.rename_node(&resolved, new_key, provenance).map_err(|e| anyhow::anyhow!("{}", e))?; store.save().map_err(|e| anyhow::anyhow!("{}", e))?; Ok(format!("Renamed '{}' → '{}'", resolved, new_key)) } @@ -184,13 +190,43 @@ pub fn memory_supersede(store: &Store, provenance: &str, old_key: &str, new_key: .map_err(|e| anyhow::anyhow!("{}", e))? .map(|n| n.content) .ok_or_else(|| anyhow::anyhow!("node not found: {}", old_key))?; + + // Transfer links from old node to new node (if new_key exists) + let mut links_transferred = 0; + if store.contains_key(new_key).unwrap_or(false) { + // Get old node's neighbors + let old_neighbors = store.neighbors(old_key).unwrap_or_default(); + // Get new node's existing neighbors (to avoid weakening existing links) + let new_neighbors: std::collections::HashMap = store.neighbors(new_key) + .unwrap_or_default() + .into_iter() + .collect(); + + for (neighbor_key, old_strength) in old_neighbors { + // Skip self-links + if neighbor_key == new_key { continue; } + // Only add/strengthen link if new node doesn't have a stronger one + let current = new_neighbors.get(&neighbor_key).copied().unwrap_or(0.0); + if old_strength > current { + if store.set_link_strength(new_key, &neighbor_key, old_strength, provenance).is_ok() { + links_transferred += 1; + } + } + } + } + let notice = format!("**SUPERSEDED** by `{}` — {}\n\n---\n\n{}", new_key, reason, content.trim()); store.upsert_provenance(old_key, ¬ice, provenance) .map_err(|e| anyhow::anyhow!("{}", e))?; store.set_weight(old_key, 0.01).map_err(|e| anyhow::anyhow!("{}", e))?; store.save().map_err(|e| anyhow::anyhow!("{}", e))?; - Ok(format!("superseded {} → {} ({})", old_key, new_key, reason)) + + if links_transferred > 0 { + Ok(format!("superseded {} → {} ({}), transferred {} links", old_key, new_key, reason, links_transferred)) + } else { + Ok(format!("superseded {} → {} ({})", old_key, new_key, reason)) + } } /// Convert a list of keys to ReplayItems with priority and graph metrics. @@ -396,7 +432,7 @@ pub fn graph_communities(store: &Store, _provenance: &str, top_n: Option, Ok(out) } -pub fn graph_normalize_strengths(store: &Store, _provenance: &str, apply: Option) -> Result { +pub fn graph_normalize_strengths(store: &Store, provenance: &str, apply: Option) -> Result { use crate::store::{StoreView, RelationType}; let apply = apply.unwrap_or(false); @@ -459,7 +495,7 @@ pub fn graph_normalize_strengths(store: &Store, _provenance: &str, apply: Option if apply { for (source, target, new_strength) in to_update { - store.set_link_strength(&source, &target, new_strength)?; + store.set_link_strength(&source, &target, new_strength, provenance)?; } writeln!(out, "\nApplied {} strength updates.", changed).ok(); } else { diff --git a/src/hippocampus/store/ops.rs b/src/hippocampus/store/ops.rs index c7ff977..f45ac88 100644 --- a/src/hippocampus/store/ops.rs +++ b/src/hippocampus/store/ops.rs @@ -7,11 +7,12 @@ use super::{index, types::*, Store}; use anyhow::{anyhow, bail, Result}; use std::collections::{HashMap, HashSet}; -/// Fallback provenance for non-tool-dispatch paths (CLI, digest, etc.). -/// Tool dispatch passes provenance directly through thought::dispatch. -pub fn current_provenance() -> String { - std::env::var("POC_PROVENANCE") - .unwrap_or_else(|_| "manual".to_string()) + +/// Check if a key is protected from deletion/rename. +/// Uses protected_nodes list from config. +pub fn is_protected(key: &str) -> bool { + let config = crate::config::get(); + config.protected_nodes.iter().any(|k| k == key) } impl Store { @@ -51,15 +52,13 @@ impl Store { /// Upsert a node: update if exists (and content changed), create if not. /// Returns: "created", "updated", or "unchanged". - /// - /// Provenance is determined by the POC_PROVENANCE env var if set, - /// otherwise defaults to Manual. + /// Uses "manual" as the provenance (for CLI operations). pub fn upsert(&self, key: &str, content: &str) -> Result<&'static str> { - let prov = current_provenance(); - self.upsert_provenance(key, content, &prov) + self.upsert_provenance(key, content, "manual") } /// Upsert with explicit provenance (for agent-created nodes). + /// Updates to protected nodes are blocked. pub fn upsert_provenance(&self, key: &str, content: &str, provenance: &str) -> Result<&'static str> { let db = self.db.as_ref().ok_or_else(|| anyhow!("store not loaded"))?; @@ -67,6 +66,9 @@ impl Store { if existing.content == content { return Ok("unchanged"); } + if is_protected(key) { + bail!("Cannot modify protected node '{}' (in config protected_nodes)", key); + } let mut node = existing; node.content = content.to_string(); node.provenance = provenance.to_string(); @@ -78,7 +80,18 @@ impl Store { txn.commit()?; Ok("updated") } else { - let mut node = new_node(key, content); + // Check if there's a previous (possibly deleted) version to continue from + let mut node = if let Some(prev) = self.find_latest_by_key(key)? { + // Continue from previous version (maintains UUID and version continuity) + let mut n = prev; + n.content = content.to_string(); + n.deleted = false; + n.timestamp = now_epoch(); + n.version += 1; + n + } else { + new_node(key, content) + }; node.provenance = provenance.to_string(); let txn = db.begin_write()?; let offset = self.append_nodes(std::slice::from_ref(&node))?; @@ -89,8 +102,11 @@ impl Store { } /// Soft-delete a node (appends deleted version, removes from index). - pub fn delete_node(&self, key: &str) -> Result<()> { - let prov = current_provenance(); + /// Fails if node is in protected_nodes list. + pub fn delete_node(&self, key: &str, provenance: &str) -> Result<()> { + if is_protected(key) { + bail!("Cannot delete protected node '{}' (in config protected_nodes)", key); + } let db = self.db.as_ref().ok_or_else(|| anyhow!("store not loaded"))?; let node = self.get_node(key)? @@ -98,7 +114,7 @@ impl Store { let mut deleted = node; deleted.deleted = true; deleted.version += 1; - deleted.provenance = prov; + deleted.provenance = provenance.to_string(); deleted.timestamp = now_epoch(); let txn = db.begin_write()?; @@ -108,15 +124,52 @@ impl Store { Ok(()) } + /// Restore a deleted node to its last non-deleted state. + /// Returns the restored node's content preview. + pub fn restore_node(&self, key: &str, provenance: &str) -> Result { + let db = self.db.as_ref().ok_or_else(|| anyhow!("store not loaded"))?; + + // Check if node already exists (not deleted) + if self.contains_key(key)? { + bail!("Node '{}' is not deleted", key); + } + + // Find the last non-deleted version (for content) + let last_live = self.find_last_live_version(key)? + .ok_or_else(|| anyhow!("No previous version of '{}' found", key))?; + + // Find the absolute latest version (for version number continuity) + let latest = self.find_latest_by_key(key)? + .ok_or_else(|| anyhow!("No previous version of '{}' found", key))?; + + // Create restored version: content from last_live, version from latest + 1 + let mut restored = last_live.clone(); + restored.deleted = false; + restored.version = latest.version + 1; + restored.timestamp = now_epoch(); + restored.provenance = provenance.to_string(); + + let txn = db.begin_write()?; + let offset = self.append_nodes(std::slice::from_ref(&restored))?; + index::index_node(&txn, &restored.key, offset, &restored.uuid, restored.node_type as u8, restored.timestamp, &restored.provenance)?; + txn.commit()?; + + let preview: String = restored.content.chars().take(100).collect(); + Ok(format!("Restored '{}' (v{}): {}...", key, restored.version, preview)) + } + /// Rename a node: change its key, update debug strings on all edges. /// /// Graph edges (source/target UUIDs) are unaffected — they're already /// UUID-based. We update the human-readable source_key/target_key strings /// on relations, and created_at is preserved untouched. - pub fn rename_node(&self, old_key: &str, new_key: &str) -> Result<()> { + pub fn rename_node(&self, old_key: &str, new_key: &str, provenance: &str) -> Result<()> { if old_key == new_key { return Ok(()); } + if is_protected(old_key) { + bail!("Cannot rename protected node '{}' (in config protected_nodes)", old_key); + } if self.contains_key(new_key)? { bail!("Key '{}' already exists", new_key); } @@ -124,20 +177,18 @@ impl Store { let node = self.get_node(old_key)? .ok_or_else(|| anyhow!("No node '{}'", old_key))?; - let prov = current_provenance(); - // New version under the new key let mut renamed = node.clone(); renamed.key = new_key.to_string(); renamed.version += 1; - renamed.provenance = prov.clone(); + renamed.provenance = provenance.to_string(); renamed.timestamp = now_epoch(); // Deletion record for the old key (same UUID, independent version counter) let mut tombstone = node.clone(); tombstone.deleted = true; tombstone.version += 1; - tombstone.provenance = prov; + tombstone.provenance = provenance.to_string(); tombstone.timestamp = now_epoch(); // Find relations touching this node's UUID (read before txn) @@ -164,7 +215,7 @@ impl Store { }; let mut rel = new_relation(src_uuid, tgt_uuid, RelationType::from_u8(rel_type), strength, - &src_key, &tgt_key); + &src_key, &tgt_key, provenance); rel.version = 2; // indicate update updated_rels.push(rel); } @@ -278,7 +329,7 @@ impl Store { index::remove_relation(&txn, &source_uuid, &target_uuid, strength, rel_type)?; let mut rel = new_relation(source_uuid, target_uuid, RelationType::from_u8(rel_type), strength, - &source_key, &target_key); + &source_key, &target_key, "system"); rel.deleted = true; rel.version = 2; self.append_relations(std::slice::from_ref(&rel))?; @@ -311,7 +362,7 @@ impl Store { /// Set the strength of a link between two nodes. /// Returns the old strength. Creates link if it doesn't exist. - pub fn set_link_strength(&self, source: &str, target: &str, strength: f32) -> Result { + pub fn set_link_strength(&self, source: &str, target: &str, strength: f32, provenance: &str) -> Result { let strength = strength.clamp(0.01, 1.0); let source_uuid = self.get_node(source)? @@ -337,14 +388,14 @@ impl Store { index::index_relation(&txn, &source_uuid, &target_uuid, strength, rel_type)?; // Append updated relation to log let mut rel = new_relation(source_uuid, target_uuid, - RelationType::from_u8(rel_type), strength, source, target); + RelationType::from_u8(rel_type), strength, source, target, provenance); rel.version = 2; // indicate update self.append_relations(std::slice::from_ref(&rel))?; txn.commit()?; Ok(old_strength) } else { // Create new link then update its strength - self.add_link(source, target, "link_set")?; + self.add_link(source, target, provenance)?; let db = self.db.as_ref().ok_or_else(|| anyhow!("store not loaded"))?; let txn = db.begin_write()?; index::remove_relation(&txn, &source_uuid, &target_uuid, 0.1, RelationType::Link as u8)?; @@ -377,12 +428,11 @@ impl Store { let jaccard = graph.jaccard(source, target); let strength = (jaccard * 3.0).clamp(0.1, 1.0) as f32; - let mut rel = new_relation( + let rel = new_relation( source_uuid, target_uuid, RelationType::Link, strength, - source, target, + source, target, provenance, ); - rel.provenance = provenance.to_string(); self.add_relation(rel)?; Ok(strength) }