// cli/node.rs — node subcommand handlers // // render, write, used, wrong, not-relevant, not-useful, gap, // node-delete, node-rename, history, list-keys, list-edges, // dump-json, lookup-bump, lookups. use crate::store; pub fn cmd_used(key: &[String]) -> Result<(), String> { if key.is_empty() { return Err("used requires a key".into()); } super::check_dry_run(); let key = key.join(" "); let mut store = store::Store::load()?; let resolved = store.resolve_key(&key)?; store.mark_used(&resolved); // Also strengthen edges to this node — conscious-tier delta. const DELTA: f32 = 0.01; let mut strengthened = 0; for rel in &mut store.relations { if rel.deleted { continue; } if rel.source_key == resolved || rel.target_key == resolved { let old = rel.strength; rel.strength = (rel.strength + DELTA).clamp(0.05, 0.95); if (rel.strength - old).abs() > 0.001 { rel.version += 1; strengthened += 1; } } } store.save()?; println!("Marked '{}' as used (strengthened {} edges)", resolved, strengthened); Ok(()) } pub fn cmd_wrong(key: &str, context: &[String]) -> Result<(), String> { let ctx = if context.is_empty() { None } else { Some(context.join(" ")) }; super::check_dry_run(); let mut store = store::Store::load()?; let resolved = store.resolve_key(key)?; store.mark_wrong(&resolved, ctx.as_deref()); store.save()?; println!("Marked '{}' as wrong", resolved); Ok(()) } pub fn cmd_not_relevant(key: &str) -> Result<(), String> { let mut store = store::Store::load()?; let resolved = store.resolve_key(key)?; // Weaken all edges to this node — it was routed to incorrectly. // Conscious-tier delta: 0.01 per edge. const DELTA: f32 = -0.01; let mut adjusted = 0; for rel in &mut store.relations { if rel.deleted { continue; } if rel.source_key == resolved || rel.target_key == resolved { let old = rel.strength; rel.strength = (rel.strength + DELTA).clamp(0.05, 0.95); if (rel.strength - old).abs() > 0.001 { rel.version += 1; adjusted += 1; } } } store.save()?; println!("Not relevant: '{}' — weakened {} edges by {}", resolved, adjusted, DELTA.abs()); Ok(()) } pub fn cmd_not_useful(key: &str) -> Result<(), String> { // no args to validate super::check_dry_run(); let mut store = store::Store::load()?; let resolved = store.resolve_key(key)?; // Same as wrong but with clearer semantics: node content is bad, edges are fine. store.mark_wrong(&resolved, Some("not-useful")); store.save()?; println!("Not useful: '{}' — node weight reduced", resolved); Ok(()) } pub fn cmd_weight_set(key: &str, weight: f32) -> Result<(), String> { super::check_dry_run(); let mut store = store::Store::load()?; let resolved = store.resolve_key(key)?; let (old, new) = store.set_weight(&resolved, weight)?; println!("Weight: {} {:.2} → {:.2}", resolved, old, new); store.save()?; Ok(()) } pub fn cmd_gap(description: &[String]) -> Result<(), String> { if description.is_empty() { return Err("gap requires a description".into()); } super::check_dry_run(); let desc = description.join(" "); let mut store = store::Store::load()?; store.record_gap(&desc); store.save()?; println!("Recorded gap: {}", desc); Ok(()) } pub fn cmd_list_keys(pattern: Option<&str>) -> Result<(), String> { let store = store::Store::load()?; let g = store.build_graph(); if let Some(pat) = pattern { let pat_lower = pat.to_lowercase(); let (prefix, suffix, middle) = if pat_lower.starts_with('*') && pat_lower.ends_with('*') { (None, None, Some(pat_lower.trim_matches('*').to_string())) } else if pat_lower.starts_with('*') { (None, Some(pat_lower.trim_start_matches('*').to_string()), None) } else if pat_lower.ends_with('*') { (Some(pat_lower.trim_end_matches('*').to_string()), None, None) } else { (None, None, Some(pat_lower.clone())) }; let mut keys: Vec<_> = store.nodes.keys() .filter(|k| { let kl = k.to_lowercase(); if let Some(ref m) = middle { kl.contains(m.as_str()) } else if let Some(ref p) = prefix { kl.starts_with(p.as_str()) } else if let Some(ref s) = suffix { kl.ends_with(s.as_str()) } else { true } }) .cloned() .collect(); keys.sort(); for k in keys { println!("{}", k); } Ok(()) } else { crate::query_parser::run_query(&store, &g, "* | sort key asc") } } pub fn cmd_list_edges() -> Result<(), String> { let store = store::Store::load()?; for rel in &store.relations { println!("{}\t{}\t{:.2}\t{:?}", rel.source_key, rel.target_key, rel.strength, rel.rel_type); } Ok(()) } pub fn cmd_dump_json() -> Result<(), String> { let store = store::Store::load()?; let json = serde_json::to_string_pretty(&store) .map_err(|e| format!("serialize: {}", e))?; println!("{}", json); Ok(()) } pub fn cmd_node_delete(key: &[String]) -> Result<(), String> { if key.is_empty() { return Err("node-delete requires a key".into()); } super::check_dry_run(); let key = key.join(" "); let mut store = store::Store::load()?; let resolved = store.resolve_key(&key)?; store.delete_node(&resolved)?; store.save()?; println!("Deleted '{}'", resolved); Ok(()) } pub fn cmd_node_rename(old_key: &str, new_key: &str) -> Result<(), String> { // args are positional, always valid if present super::check_dry_run(); let mut store = store::Store::load()?; let old_resolved = store.resolve_key(old_key)?; store.rename_node(&old_resolved, new_key)?; store.save()?; println!("Renamed '{}' → '{}'", old_resolved, new_key); Ok(()) } /// Render a node to a string: content + deduped footer links. /// Used by both the CLI command and agent placeholders. pub fn render_node(store: &store::Store, key: &str) -> Option { crate::hippocampus::memory::MemoryNode::from_store(store, key) .map(|node| node.render()) } pub fn cmd_render(key: &[String]) -> Result<(), String> { if key.is_empty() { return Err("render requires a key".into()); } let key = key.join(" "); let store = store::Store::load()?; let bare = store::strip_md_suffix(&key); let rendered = render_node(&store, &bare) .ok_or_else(|| format!("Node not found: {}", bare))?; print!("{}", rendered); // Mark as seen if we're inside a Claude session (not an agent subprocess — // agents read the seen set but shouldn't write to it as a side effect of // tool calls; only surface_agent_cycle should mark keys seen) if std::env::var("POC_AGENT").is_err() && let Ok(session_id) = std::env::var("POC_SESSION_ID") && !session_id.is_empty() { let state_dir = crate::store::memory_dir().join("sessions"); let seen_path = state_dir.join(format!("seen-{}", session_id)); if let Ok(mut f) = std::fs::OpenOptions::new() .create(true).append(true).open(seen_path) { use std::io::Write; let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S"); let _ = writeln!(f, "{}\t{}", ts, bare); } } Ok(()) } /// Check content for common inline reference problems: /// - `poc-memory render key` embedded in content (render artifact, should be just `key`) /// - `→ something` where something doesn't parse as a valid key /// - `key` referencing a node that doesn't exist fn validate_inline_refs(content: &str, store: &store::Store) -> Vec { let mut warnings = Vec::new(); for line in content.lines() { // Check for render commands embedded in content if line.contains("poc-memory render ") && !line.starts_with(" ") { // Skip lines that look like CLI documentation/examples if !line.contains("CLI") && !line.contains("equivalent") && !line.contains("tool") { warnings.push(format!( "render command in content (should be just `key`): {}", line.chars().take(80).collect::(), )); } } // Check → references if let Some(rest) = line.trim().strip_prefix("→ ") { // Extract the key (may be backtick-quoted) let key = rest.trim().trim_matches('`').trim(); if !key.is_empty() && !store.nodes.contains_key(key) { // Might be a poc-memory render artifact if let Some(k) = key.strip_prefix("poc-memory render ") { warnings.push(format!( "render artifact in → reference (use `{}` not `poc-memory render {}`)", k, k, )); } else if key.contains(' ') { warnings.push(format!( "→ reference doesn't look like a key: → {}", key, )); } // Don't warn about missing keys — the target might be created later } } } warnings } pub fn cmd_history(key: &[String], full: bool) -> Result<(), String> { if key.is_empty() { return Err("history requires a key".into()); } let raw_key = key.join(" "); let store = store::Store::load()?; let key = store.resolve_key(&raw_key).unwrap_or(raw_key); drop(store); let path = store::nodes_path(); if !path.exists() { return Err("No node log found".into()); } use std::io::BufReader; let file = std::fs::File::open(&path) .map_err(|e| format!("open {}: {}", path.display(), e))?; let mut reader = BufReader::new(file); let mut versions: Vec = Vec::new(); while let Ok(msg) = capnp::serialize::read_message(&mut reader, capnp::message::ReaderOptions::new()) { let log = msg.get_root::() .map_err(|e| format!("read log: {}", e))?; for node_reader in log.get_nodes() .map_err(|e| format!("get nodes: {}", e))? { let node = store::Node::from_capnp_migrate(node_reader)?; if node.key == key { versions.push(node); } } } if versions.is_empty() { return Err(format!("No history found for '{}'", key)); } eprintln!("{} versions of '{}':\n", versions.len(), key); for node in &versions { let ts = if node.timestamp > 0 && node.timestamp < 4_000_000_000 { store::format_datetime(node.timestamp) } else { format!("(raw:{})", node.timestamp) }; let deleted_marker = if node.deleted { " DELETED" } else { "" }; let content_len = node.content.len(); if full { eprintln!("=== v{} {} {}{} w={:.3} {}b ===", node.version, ts, node.provenance, deleted_marker, node.weight, content_len); eprintln!("{}", node.content); } else { let preview = crate::util::first_n_chars(&node.content, 120); let preview = preview.replace('\n', "\\n"); eprintln!(" v{:<3} {} {:24} w={:.3} {}b{}", node.version, ts, node.provenance, node.weight, content_len, deleted_marker); eprintln!(" {}", preview); } } if !full && let Some(latest) = versions.last() { eprintln!("\n--- Latest content (v{}, {}) ---", latest.version, latest.provenance); print!("{}", latest.content); } Ok(()) } pub fn cmd_write(key: &[String]) -> Result<(), String> { if key.is_empty() { return Err("write requires a key (reads content from stdin)".into()); } let raw_key = key.join(" "); let mut content = String::new(); std::io::Read::read_to_string(&mut std::io::stdin(), &mut content) .map_err(|e| format!("read stdin: {}", e))?; if content.trim().is_empty() { return Err("No content on stdin".into()); } super::check_dry_run(); let mut store = store::Store::load()?; let key = store.resolve_key(&raw_key).unwrap_or(raw_key); // Validate inline references: warn about render commands embedded // in content (should be just `key`) and broken references. let warnings = validate_inline_refs(&content, &store); for w in &warnings { eprintln!("warning: {}", w); } let result = store.upsert(&key, &content)?; match result { "unchanged" => println!("No change: '{}'", key), "updated" => println!("Updated '{}' (v{})", key, store.nodes[&key].version), _ => println!("Created '{}'", key), } if result != "unchanged" { store.save()?; } Ok(()) } pub fn cmd_edit(key: &[String]) -> Result<(), String> { if key.is_empty() { return Err("edit requires a key".into()); } let raw_key = key.join(" "); let store = store::Store::load()?; let key = store.resolve_key(&raw_key).unwrap_or(raw_key.clone()); let content = store.nodes.get(&key) .map(|n| n.content.clone()) .unwrap_or_default(); let tmp = std::env::temp_dir().join(format!("poc-memory-edit-{}.md", key.replace('/', "_"))); std::fs::write(&tmp, &content) .map_err(|e| format!("write temp file: {}", e))?; let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".into()); let status = std::process::Command::new(&editor) .arg(&tmp) .status() .map_err(|e| format!("spawn {}: {}", editor, e))?; if !status.success() { let _ = std::fs::remove_file(&tmp); return Err(format!("{} exited with {}", editor, status)); } let new_content = std::fs::read_to_string(&tmp) .map_err(|e| format!("read temp file: {}", e))?; let _ = std::fs::remove_file(&tmp); if new_content == content { println!("No change: '{}'", key); return Ok(()); } if new_content.trim().is_empty() { return Err("Content is empty, aborting".into()); } drop(store); let mut store = store::Store::load()?; let result = store.upsert(&key, &new_content)?; match result { "unchanged" => println!("No change: '{}'", key), "updated" => println!("Updated '{}' (v{})", key, store.nodes[&key].version), _ => println!("Created '{}'", key), } if result != "unchanged" { store.save()?; } Ok(()) } pub fn cmd_lookup_bump(keys: &[String]) -> Result<(), String> { if keys.is_empty() { return Err("lookup-bump requires at least one key".into()); } let keys: Vec<&str> = keys.iter().map(|s| s.as_str()).collect(); crate::lookups::bump_many(&keys) } pub fn cmd_lookups(date: Option<&str>) -> Result<(), String> { let date = date.map(|d| d.to_string()) .unwrap_or_else(|| chrono::Local::now().format("%Y-%m-%d").to_string()); let store = store::Store::load()?; let keys: Vec = store.nodes.values().map(|n| n.key.clone()).collect(); let resolved = crate::lookups::dump_resolved(&date, &keys)?; if resolved.is_empty() { println!("No lookups for {}", date); return Ok(()); } println!("Lookups for {}:", date); for (key, count) in &resolved { println!(" {:4} {}", count, key); } println!("\n{} distinct keys, {} total lookups", resolved.len(), resolved.iter().map(|(_, c)| *c as u64).sum::()); Ok(()) }