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")
|
.join(".claude/memory")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn memory_dir_pub() -> PathBuf { memory_dir() }
|
||||||
|
|
||||||
fn nodes_path() -> PathBuf { memory_dir().join("nodes.capnp") }
|
fn nodes_path() -> PathBuf { memory_dir().join("nodes.capnp") }
|
||||||
fn relations_path() -> PathBuf { memory_dir().join("relations.capnp") }
|
fn relations_path() -> PathBuf { memory_dir().join("relations.capnp") }
|
||||||
fn state_path() -> PathBuf { memory_dir().join("state.bin") }
|
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..]),
|
"node-delete" => cmd_node_delete(&args[2..]),
|
||||||
"load-context" => cmd_load_context(),
|
"load-context" => cmd_load_context(),
|
||||||
"render" => cmd_render(&args[2..]),
|
"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]);
|
eprintln!("Unknown command: {}", args[1]);
|
||||||
usage();
|
usage();
|
||||||
|
|
@ -115,7 +118,10 @@ Commands:
|
||||||
dump-json Dump entire store as JSON
|
dump-json Dump entire store as JSON
|
||||||
node-delete KEY Soft-delete a node (appends deleted version to log)
|
node-delete KEY Soft-delete a node (appends deleted version to log)
|
||||||
load-context Output session-start context from the store
|
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> {
|
fn cmd_search(args: &[String]) -> Result<(), String> {
|
||||||
|
|
@ -919,6 +925,231 @@ fn cmd_render(args: &[String]) -> Result<(), String> {
|
||||||
Ok(())
|
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> {
|
fn cmd_interference(args: &[String]) -> Result<(), String> {
|
||||||
let mut threshold = 0.4f32;
|
let mut threshold = 0.4f32;
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue