add write, import, and export commands

write KEY: upsert a single node from stdin. Creates new or updates
existing with version bump. No-op if content unchanged.

import FILE: parse markdown sections, diff against store, upsert
changed/new nodes. Incremental — only touches what changed.

export FILE|--all: regenerate markdown from store nodes. Gathers
file-level + section nodes, reconstitutes mem markers with links
and causes from the relation graph.

Together these close the bidirectional sync loop:
  markdown → import → store → export → markdown

Also exposes memory_dir_pub() for use from main.rs.
This commit is contained in:
ProofOfConcept 2026-02-28 23:00:52 -05:00
parent 14b6457231
commit 57cf61de44
2 changed files with 234 additions and 1 deletions

View file

@ -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<String> = if export_all {
// Find all unique file-level keys (no # in key)
let mut files: Vec<String> = 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 &sections {
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!("<!-- mem: {} -->\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;