forked from kent/consciousness
295 lines
9.7 KiB
Rust
295 lines
9.7 KiB
Rust
// cli/node.rs — node subcommand handlers
|
|
//
|
|
// render, write, node-delete, node-rename, history, list-keys,
|
|
// list-edges, dump-json, lookup-bump, lookups.
|
|
|
|
use crate::store;
|
|
|
|
pub fn cmd_weight_set(key: &str, weight: f32) -> Result<(), String> {
|
|
super::check_dry_run();
|
|
let result = crate::mcp_server::memory_rpc(
|
|
"memory_weight_set",
|
|
serde_json::json!({"key": key, "weight": weight}),
|
|
).map_err(|e| e.to_string())?;
|
|
println!("{}", result);
|
|
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 result = crate::mcp_server::memory_rpc(
|
|
"memory_delete",
|
|
serde_json::json!({"key": key}),
|
|
).map_err(|e| e.to_string())?;
|
|
println!("{}", result);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn cmd_node_rename(old_key: &str, new_key: &str) -> Result<(), String> {
|
|
super::check_dry_run();
|
|
let result = crate::mcp_server::memory_rpc(
|
|
"memory_rename",
|
|
serde_json::json!({"old_key": old_key, "new_key": new_key}),
|
|
).map_err(|e| e.to_string())?;
|
|
println!("{}", result);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn cmd_render(key: &[String]) -> Result<(), String> {
|
|
if key.is_empty() {
|
|
return Err("render requires a key".into());
|
|
}
|
|
let key = key.join(" ");
|
|
let bare = store::strip_md_suffix(&key);
|
|
|
|
let rendered = crate::mcp_server::memory_rpc(
|
|
"memory_render",
|
|
serde_json::json!({"key": bare}),
|
|
).map_err(|e| e.to_string())?;
|
|
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(())
|
|
}
|
|
|
|
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<store::Node> = Vec::new();
|
|
while let Ok(msg) = capnp::serialize::read_message(&mut reader, capnp::message::ReaderOptions::new()) {
|
|
let log = msg.get_root::<crate::memory_capnp::node_log::Reader>()
|
|
.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 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 result = crate::mcp_server::memory_rpc(
|
|
"memory_write",
|
|
serde_json::json!({"key": key, "content": content}),
|
|
).map_err(|e| e.to_string())?;
|
|
println!("{}", result);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn cmd_edit(key: &[String]) -> Result<(), String> {
|
|
if key.is_empty() {
|
|
return Err("edit requires a key".into());
|
|
}
|
|
let key = key.join(" ");
|
|
|
|
// Get raw content via RPC
|
|
let content = crate::mcp_server::memory_rpc(
|
|
"memory_render",
|
|
serde_json::json!({"key": key, "raw": true}),
|
|
).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());
|
|
}
|
|
|
|
super::check_dry_run();
|
|
let result = crate::mcp_server::memory_rpc(
|
|
"memory_write",
|
|
serde_json::json!({"key": key, "content": new_content}),
|
|
).map_err(|e| e.to_string())?;
|
|
println!("{}", result);
|
|
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<String> = 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::<u64>());
|
|
Ok(())
|
|
}
|