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:
parent
14b6457231
commit
57cf61de44
2 changed files with 234 additions and 1 deletions
|
|
@ -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") }
|
||||
|
|
|
|||
233
src/main.rs
233
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<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 §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!("<!-- 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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue