diff --git a/src/capnp_store.rs b/src/capnp_store.rs index d605cbb..a61361b 100644 --- a/src/capnp_store.rs +++ b/src/capnp_store.rs @@ -32,6 +32,8 @@ fn memory_dir() -> PathBuf { .join(".claude/memory") } +pub fn memory_dir_pub() -> PathBuf { memory_dir() } + fn nodes_path() -> PathBuf { memory_dir().join("nodes.capnp") } fn relations_path() -> PathBuf { memory_dir().join("relations.capnp") } fn state_path() -> PathBuf { memory_dir().join("state.bin") } diff --git a/src/main.rs b/src/main.rs index b8c1b1f..333a1e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,6 +65,9 @@ fn main() { "node-delete" => cmd_node_delete(&args[2..]), "load-context" => cmd_load_context(), "render" => cmd_render(&args[2..]), + "write" => cmd_write(&args[2..]), + "import" => cmd_import(&args[2..]), + "export" => cmd_export(&args[2..]), _ => { eprintln!("Unknown command: {}", args[1]); usage(); @@ -115,7 +118,10 @@ Commands: dump-json Dump entire store as JSON node-delete KEY Soft-delete a node (appends deleted version to log) load-context Output session-start context from the store - render KEY Output a node's content to stdout"); + render KEY Output a node's content to stdout + write KEY Upsert node content from stdin + import FILE [FILE...] Import markdown file(s) into the store + export [FILE|--all] Export store nodes to markdown file(s)"); } fn cmd_search(args: &[String]) -> Result<(), String> { @@ -919,6 +925,231 @@ fn cmd_render(args: &[String]) -> Result<(), String> { Ok(()) } +fn cmd_write(args: &[String]) -> Result<(), String> { + if args.is_empty() { + return Err("Usage: poc-memory write KEY < content\n\ + Reads content from stdin, upserts into the store.".into()); + } + let key = args.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()); + } + + let mut store = capnp_store::Store::load()?; + + if let Some(existing) = store.nodes.get(&key) { + if existing.content == content { + println!("No change: '{}'", key); + return Ok(()); + } + let mut node = existing.clone(); + node.content = content; + node.version += 1; + store.append_nodes(&[node.clone()])?; + store.nodes.insert(key.clone(), node); + println!("Updated '{}' (v{})", key, store.nodes[&key].version); + } else { + let node = capnp_store::Store::new_node(&key, &content); + store.append_nodes(&[node.clone()])?; + store.uuid_to_key.insert(node.uuid, node.key.clone()); + store.nodes.insert(key.clone(), node); + println!("Created '{}'", key); + } + + store.save()?; + Ok(()) +} + +fn cmd_import(args: &[String]) -> Result<(), String> { + if args.is_empty() { + return Err("Usage: poc-memory import FILE [FILE...]".into()); + } + + let mut store = capnp_store::Store::load()?; + let mut total_new = 0; + let mut total_updated = 0; + + for arg in args { + let path = std::path::PathBuf::from(arg); + if !path.exists() { + // Try relative to memory dir + let mem_path = capnp_store::memory_dir_pub().join(arg); + if !mem_path.exists() { + eprintln!("File not found: {}", arg); + continue; + } + let (n, u) = import_file(&mut store, &mem_path)?; + total_new += n; + total_updated += u; + } else { + let (n, u) = import_file(&mut store, &path)?; + total_new += n; + total_updated += u; + } + } + + if total_new > 0 || total_updated > 0 { + store.save()?; + } + println!("Import: {} new, {} updated", total_new, total_updated); + Ok(()) +} + +fn import_file(store: &mut capnp_store::Store, path: &std::path::Path) -> Result<(usize, usize), String> { + let filename = path.file_name().unwrap().to_string_lossy().to_string(); + let content = std::fs::read_to_string(path) + .map_err(|e| format!("read {}: {}", path.display(), e))?; + + let units = capnp_store::parse_units(&filename, &content); + let mut new_nodes = Vec::new(); + let mut updated_nodes = Vec::new(); + + let node_type = if filename.starts_with("daily-") { + capnp_store::NodeType::EpisodicDaily + } else if filename.starts_with("weekly-") { + capnp_store::NodeType::EpisodicWeekly + } else if filename == "journal.md" { + capnp_store::NodeType::EpisodicSession + } else { + capnp_store::NodeType::Semantic + }; + + for unit in &units { + if let Some(existing) = store.nodes.get(&unit.key) { + if existing.content != unit.content { + let mut node = existing.clone(); + node.content = unit.content.clone(); + node.version += 1; + println!(" U {}", unit.key); + updated_nodes.push(node); + } + } else { + let mut node = capnp_store::Store::new_node(&unit.key, &unit.content); + node.node_type = node_type; + println!(" + {}", unit.key); + new_nodes.push(node); + } + } + + if !new_nodes.is_empty() { + store.append_nodes(&new_nodes)?; + for node in &new_nodes { + store.uuid_to_key.insert(node.uuid, node.key.clone()); + store.nodes.insert(node.key.clone(), node.clone()); + } + } + if !updated_nodes.is_empty() { + store.append_nodes(&updated_nodes)?; + for node in &updated_nodes { + store.nodes.insert(node.key.clone(), node.clone()); + } + } + + Ok((new_nodes.len(), updated_nodes.len())) +} + +fn cmd_export(args: &[String]) -> Result<(), String> { + let store = capnp_store::Store::load()?; + + let export_all = args.iter().any(|a| a == "--all"); + let targets: Vec = if export_all { + // Find all unique file-level keys (no # in key) + let mut files: Vec = store.nodes.keys() + .filter(|k| !k.contains('#')) + .cloned() + .collect(); + files.sort(); + files + } else if args.is_empty() { + return Err("Usage: poc-memory export FILE [FILE...] | --all".into()); + } else { + args.iter().map(|a| { + // If it doesn't end in .md, try resolving + if a.ends_with(".md") { + a.clone() + } else { + format!("{}.md", a) + } + }).collect() + }; + + let mem_dir = capnp_store::memory_dir_pub(); + + for file_key in &targets { + // Gather file-level node + section nodes + let prefix = format!("{}#", file_key); + let mut sections: Vec<_> = store.nodes.values() + .filter(|n| n.key == *file_key || n.key.starts_with(&prefix)) + .collect(); + + if sections.is_empty() { + eprintln!("No nodes for '{}'", file_key); + continue; + } + + // Sort: file-level key first (no #), then sections alphabetically + sections.sort_by(|a, b| { + let a_is_file = !a.key.contains('#'); + let b_is_file = !b.key.contains('#'); + match (a_is_file, b_is_file) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.key.cmp(&b.key), + } + }); + + // Build output: file-level content first, then each section + // with its mem marker reconstituted + let mut output = String::new(); + + for node in §ions { + if node.key.contains('#') { + // Section node — emit mem marker + content + let section_id = node.key.split('#').last().unwrap_or(""); + + // Find edges FROM this node to build links= attribute + let links: Vec<_> = store.relations.iter() + .filter(|r| r.source_key == node.key && !r.deleted + && r.rel_type != capnp_store::RelationType::Causal) + .map(|r| r.target_key.clone()) + .collect(); + let causes: Vec<_> = store.relations.iter() + .filter(|r| r.target_key == node.key && !r.deleted + && r.rel_type == capnp_store::RelationType::Causal) + .map(|r| r.source_key.clone()) + .collect(); + + let mut marker_parts = vec![format!("id={}", section_id)]; + if !links.is_empty() { + marker_parts.push(format!("links={}", links.join(","))); + } + if !causes.is_empty() { + marker_parts.push(format!("causes={}", causes.join(","))); + } + + output.push_str(&format!("\n", marker_parts.join(" "))); + } + output.push_str(&node.content); + if !node.content.ends_with('\n') { + output.push('\n'); + } + output.push('\n'); + } + + // Determine output path + let out_path = mem_dir.join(file_key); + std::fs::write(&out_path, output.trim_end()) + .map_err(|e| format!("write {}: {}", out_path.display(), e))?; + println!("Exported {} ({} sections)", file_key, sections.len()); + } + + Ok(()) +} + fn cmd_interference(args: &[String]) -> Result<(), String> { let mut threshold = 0.4f32; let mut i = 0;