flatten: move poc-memory contents to workspace root
No more subcrate nesting — src/, agents/, schema/, defaults/, build.rs all live at the workspace root. poc-daemon remains as the only workspace member. Crate name (poc-memory) and all imports unchanged. Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
parent
891cca57f8
commit
998b71e52c
113 changed files with 79 additions and 78 deletions
484
src/cli/admin.rs
Normal file
484
src/cli/admin.rs
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
// cli/admin.rs — admin subcommand handlers
|
||||
|
||||
use crate::store;
|
||||
fn install_default_file(data_dir: &std::path::Path, name: &str, content: &str) -> Result<(), String> {
|
||||
let path = data_dir.join(name);
|
||||
if !path.exists() {
|
||||
std::fs::write(&path, content)
|
||||
.map_err(|e| format!("write {}: {}", name, e))?;
|
||||
println!("Created {}", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
pub fn cmd_init() -> Result<(), String> {
|
||||
let cfg = crate::config::get();
|
||||
|
||||
// Ensure data directory exists
|
||||
std::fs::create_dir_all(&cfg.data_dir)
|
||||
.map_err(|e| format!("create data_dir: {}", e))?;
|
||||
|
||||
// Install filesystem files (not store nodes)
|
||||
install_default_file(&cfg.data_dir, "instructions.md",
|
||||
include_str!("../../defaults/instructions.md"))?;
|
||||
install_default_file(&cfg.data_dir, "on-consciousness.md",
|
||||
include_str!("../../defaults/on-consciousness.md"))?;
|
||||
|
||||
// Initialize store and seed default identity node if empty
|
||||
let mut store = store::Store::load()?;
|
||||
let count = store.init_from_markdown()?;
|
||||
for key in &cfg.core_nodes {
|
||||
if !store.nodes.contains_key(key) && key == "identity" {
|
||||
let default = include_str!("../../defaults/identity.md");
|
||||
store.upsert(key, default)
|
||||
.map_err(|e| format!("seed {}: {}", key, e))?;
|
||||
println!("Seeded {} in store", key);
|
||||
}
|
||||
}
|
||||
store.save()?;
|
||||
println!("Indexed {} memory units", count);
|
||||
|
||||
// Install hooks
|
||||
crate::daemon::install_hook()?;
|
||||
|
||||
// Create config if none exists
|
||||
let config_path = std::env::var("POC_MEMORY_CONFIG")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
std::path::PathBuf::from(std::env::var("HOME").unwrap())
|
||||
.join(".config/poc-memory/config.jsonl")
|
||||
});
|
||||
if !config_path.exists() {
|
||||
let config_dir = config_path.parent().unwrap();
|
||||
std::fs::create_dir_all(config_dir)
|
||||
.map_err(|e| format!("create config dir: {}", e))?;
|
||||
let example = include_str!("../../config.example.jsonl");
|
||||
std::fs::write(&config_path, example)
|
||||
.map_err(|e| format!("write config: {}", e))?;
|
||||
println!("Created config at {} — edit with your name and context groups",
|
||||
config_path.display());
|
||||
}
|
||||
|
||||
println!("Done. Run `poc-memory load-context --stats` to verify.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_bulk_rename(from: &str, to: &str, apply: bool) -> Result<(), String> {
|
||||
let mut store = store::Store::load()?;
|
||||
|
||||
// Find all keys that need renaming
|
||||
let renames: Vec<(String, String)> = store.nodes.keys()
|
||||
.filter(|k| k.contains(from))
|
||||
.map(|k| (k.clone(), k.replace(from, to)))
|
||||
.collect();
|
||||
|
||||
// Check for collisions
|
||||
let existing: std::collections::HashSet<&String> = store.nodes.keys().collect();
|
||||
let mut collisions = 0;
|
||||
for (old, new) in &renames {
|
||||
if existing.contains(new) && old != new {
|
||||
eprintln!("COLLISION: {} -> {} (target exists)", old, new);
|
||||
collisions += 1;
|
||||
}
|
||||
}
|
||||
|
||||
println!("Bulk rename '{}' -> '{}'", from, to);
|
||||
println!(" Keys to rename: {}", renames.len());
|
||||
println!(" Collisions: {}", collisions);
|
||||
|
||||
if collisions > 0 {
|
||||
return Err(format!("{} collisions — aborting", collisions));
|
||||
}
|
||||
|
||||
if !apply {
|
||||
// Show a sample
|
||||
for (old, new) in renames.iter().take(10) {
|
||||
println!(" {} -> {}", old, new);
|
||||
}
|
||||
if renames.len() > 10 {
|
||||
println!(" ... and {} more", renames.len() - 10);
|
||||
}
|
||||
println!("\nDry run. Use --apply to execute.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Apply renames using rename_node() which properly appends to capnp logs.
|
||||
// Process in batches to avoid holding the lock too long.
|
||||
let mut renamed_count = 0;
|
||||
let mut errors = 0;
|
||||
let total = renames.len();
|
||||
for (i, (old_key, new_key)) in renames.iter().enumerate() {
|
||||
match store.rename_node(old_key, new_key) {
|
||||
Ok(()) => renamed_count += 1,
|
||||
Err(e) => {
|
||||
eprintln!(" RENAME ERROR: {} -> {}: {}", old_key, new_key, e);
|
||||
errors += 1;
|
||||
}
|
||||
}
|
||||
if (i + 1) % 1000 == 0 {
|
||||
println!(" {}/{} ({} errors)", i + 1, total, errors);
|
||||
}
|
||||
}
|
||||
store.save()?;
|
||||
println!("Renamed {} nodes ({} errors).", renamed_count, errors);
|
||||
|
||||
// Run fsck to verify
|
||||
println!("\nRunning fsck...");
|
||||
drop(store);
|
||||
cmd_fsck()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_fsck() -> Result<(), String> {
|
||||
let mut store = store::Store::load()?;
|
||||
|
||||
// Check cache vs log consistency
|
||||
let log_store = store::Store::load_from_logs()?;
|
||||
let mut cache_issues = 0;
|
||||
|
||||
// Nodes in logs but missing from cache
|
||||
for key in log_store.nodes.keys() {
|
||||
if !store.nodes.contains_key(key) {
|
||||
eprintln!("CACHE MISSING: '{}' exists in capnp log but not in cache", key);
|
||||
cache_issues += 1;
|
||||
}
|
||||
}
|
||||
// Nodes in cache but not in logs (phantom nodes)
|
||||
for key in store.nodes.keys() {
|
||||
if !log_store.nodes.contains_key(key) {
|
||||
eprintln!("CACHE PHANTOM: '{}' exists in cache but not in capnp log", key);
|
||||
cache_issues += 1;
|
||||
}
|
||||
}
|
||||
// Version mismatches
|
||||
for (key, log_node) in &log_store.nodes {
|
||||
if let Some(cache_node) = store.nodes.get(key)
|
||||
&& cache_node.version != log_node.version {
|
||||
eprintln!("CACHE STALE: '{}' cache v{} vs log v{}",
|
||||
key, cache_node.version, log_node.version);
|
||||
cache_issues += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if cache_issues > 0 {
|
||||
eprintln!("{} cache inconsistencies found — rebuilding from logs", cache_issues);
|
||||
store = log_store;
|
||||
store.save().map_err(|e| format!("rebuild save: {}", e))?;
|
||||
}
|
||||
|
||||
// Check node-key consistency
|
||||
let mut issues = 0;
|
||||
for (key, node) in &store.nodes {
|
||||
if key != &node.key {
|
||||
eprintln!("MISMATCH: map key '{}' vs node.key '{}'", key, node.key);
|
||||
issues += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Check edge endpoints
|
||||
let mut dangling = 0;
|
||||
for rel in &store.relations {
|
||||
if rel.deleted { continue; }
|
||||
if !store.nodes.contains_key(&rel.source_key) {
|
||||
eprintln!("DANGLING: edge source '{}'", rel.source_key);
|
||||
dangling += 1;
|
||||
}
|
||||
if !store.nodes.contains_key(&rel.target_key) {
|
||||
eprintln!("DANGLING: edge target '{}'", rel.target_key);
|
||||
dangling += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Prune orphan edges
|
||||
let mut to_tombstone = Vec::new();
|
||||
for rel in &store.relations {
|
||||
if rel.deleted { continue; }
|
||||
if !store.nodes.contains_key(&rel.source_key)
|
||||
|| !store.nodes.contains_key(&rel.target_key) {
|
||||
let mut tombstone = rel.clone();
|
||||
tombstone.deleted = true;
|
||||
tombstone.version += 1;
|
||||
to_tombstone.push(tombstone);
|
||||
}
|
||||
}
|
||||
if !to_tombstone.is_empty() {
|
||||
let count = to_tombstone.len();
|
||||
store.append_relations(&to_tombstone)?;
|
||||
for t in &to_tombstone {
|
||||
if let Some(r) = store.relations.iter_mut().find(|r| r.uuid == t.uuid) {
|
||||
r.deleted = true;
|
||||
r.version = t.version;
|
||||
}
|
||||
}
|
||||
store.save()?;
|
||||
eprintln!("Pruned {} orphan edges", count);
|
||||
}
|
||||
|
||||
let g = store.build_graph();
|
||||
println!("fsck: {} nodes, {} edges, {} issues, {} dangling, {} cache",
|
||||
store.nodes.len(), g.edge_count(), issues, dangling, cache_issues);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_dedup(apply: bool) -> Result<(), String> {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
let mut store = store::Store::load()?;
|
||||
let duplicates = store.find_duplicates()?;
|
||||
|
||||
if duplicates.is_empty() {
|
||||
println!("No duplicate keys found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Count edges per UUID
|
||||
let mut edges_by_uuid: HashMap<[u8; 16], usize> = HashMap::new();
|
||||
for rel in &store.relations {
|
||||
if rel.deleted { continue; }
|
||||
*edges_by_uuid.entry(rel.source).or_default() += 1;
|
||||
*edges_by_uuid.entry(rel.target).or_default() += 1;
|
||||
}
|
||||
|
||||
let mut identical_groups = Vec::new();
|
||||
let mut diverged_groups = Vec::new();
|
||||
|
||||
for (key, mut nodes) in duplicates {
|
||||
// Sort by version descending so highest-version is first
|
||||
nodes.sort_by(|a, b| b.version.cmp(&a.version));
|
||||
|
||||
// Check if all copies have identical content
|
||||
let all_same = nodes.windows(2).all(|w| w[0].content == w[1].content);
|
||||
|
||||
let info: Vec<_> = nodes.iter().map(|n| {
|
||||
let edge_count = edges_by_uuid.get(&n.uuid).copied().unwrap_or(0);
|
||||
(n.clone(), edge_count)
|
||||
}).collect();
|
||||
|
||||
if all_same {
|
||||
identical_groups.push((key, info));
|
||||
} else {
|
||||
diverged_groups.push((key, info));
|
||||
}
|
||||
}
|
||||
|
||||
// Report
|
||||
println!("=== Duplicate key report ===\n");
|
||||
println!("{} identical groups, {} diverged groups\n",
|
||||
identical_groups.len(), diverged_groups.len());
|
||||
|
||||
if !identical_groups.is_empty() {
|
||||
println!("── Identical (safe to auto-merge) ──");
|
||||
for (key, copies) in &identical_groups {
|
||||
let total_edges: usize = copies.iter().map(|c| c.1).sum();
|
||||
println!(" {} ({} copies, {} total edges)", key, copies.len(), total_edges);
|
||||
for (node, edges) in copies {
|
||||
let uuid_hex = node.uuid.iter().map(|b| format!("{:02x}", b)).collect::<String>();
|
||||
println!(" v{} uuid={}.. edges={}", node.version, &uuid_hex[..8], edges);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
if !diverged_groups.is_empty() {
|
||||
println!("── Diverged (need review) ──");
|
||||
for (key, copies) in &diverged_groups {
|
||||
let total_edges: usize = copies.iter().map(|c| c.1).sum();
|
||||
println!(" {} ({} copies, {} total edges)", key, copies.len(), total_edges);
|
||||
for (node, edges) in copies {
|
||||
let uuid_hex = node.uuid.iter().map(|b| format!("{:02x}", b)).collect::<String>();
|
||||
let preview: String = node.content.chars().take(80).collect();
|
||||
println!(" v{} uuid={}.. edges={} | {}{}",
|
||||
node.version, &uuid_hex[..8], edges, preview,
|
||||
if node.content.len() > 80 { "..." } else { "" });
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
if !apply {
|
||||
let total_dupes: usize = identical_groups.iter().chain(diverged_groups.iter())
|
||||
.map(|(_, copies)| copies.len() - 1)
|
||||
.sum();
|
||||
println!("Dry run: {} duplicate nodes would be merged. Use --apply to execute.", total_dupes);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Merge all groups: identical + diverged
|
||||
// For diverged: keep the copy with most edges (it's the one that got
|
||||
// woven into the graph — the version that lived). Fall back to highest version.
|
||||
let all_groups: Vec<_> = identical_groups.into_iter()
|
||||
.chain(diverged_groups)
|
||||
.collect();
|
||||
|
||||
let mut merged = 0usize;
|
||||
let mut edges_redirected = 0usize;
|
||||
let mut edges_deduped = 0usize;
|
||||
|
||||
for (_key, mut copies) in all_groups {
|
||||
// Pick survivor: most edges first, then highest version
|
||||
copies.sort_by(|a, b| b.1.cmp(&a.1).then(b.0.version.cmp(&a.0.version)));
|
||||
|
||||
let survivor_uuid = copies[0].0.uuid;
|
||||
let doomed_uuids: Vec<[u8; 16]> = copies[1..].iter().map(|c| c.0.uuid).collect();
|
||||
|
||||
// Redirect edges from doomed UUIDs to survivor
|
||||
let mut updated_rels = Vec::new();
|
||||
for rel in &mut store.relations {
|
||||
if rel.deleted { continue; }
|
||||
let mut changed = false;
|
||||
if doomed_uuids.contains(&rel.source) {
|
||||
rel.source = survivor_uuid;
|
||||
changed = true;
|
||||
}
|
||||
if doomed_uuids.contains(&rel.target) {
|
||||
rel.target = survivor_uuid;
|
||||
changed = true;
|
||||
}
|
||||
if changed {
|
||||
rel.version += 1;
|
||||
updated_rels.push(rel.clone());
|
||||
edges_redirected += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup edges: same (source, target, rel_type) → keep highest strength
|
||||
let mut seen: HashSet<([u8; 16], [u8; 16], String)> = HashSet::new();
|
||||
let mut to_tombstone_rels = Vec::new();
|
||||
// Sort by strength descending so we keep the strongest
|
||||
let mut rels_with_idx: Vec<(usize, &store::Relation)> = store.relations.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, r)| !r.deleted && (r.source == survivor_uuid || r.target == survivor_uuid))
|
||||
.collect();
|
||||
rels_with_idx.sort_by(|a, b| b.1.strength.total_cmp(&a.1.strength));
|
||||
|
||||
for (idx, rel) in &rels_with_idx {
|
||||
let edge_key = (rel.source, rel.target, format!("{:?}", rel.rel_type));
|
||||
if !seen.insert(edge_key) {
|
||||
to_tombstone_rels.push(*idx);
|
||||
edges_deduped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for &idx in &to_tombstone_rels {
|
||||
store.relations[idx].deleted = true;
|
||||
store.relations[idx].version += 1;
|
||||
updated_rels.push(store.relations[idx].clone());
|
||||
}
|
||||
|
||||
// Tombstone doomed nodes
|
||||
let mut tombstones = Vec::new();
|
||||
for (doomed_node, _) in &copies[1..] {
|
||||
let mut t = doomed_node.clone();
|
||||
t.deleted = true;
|
||||
t.version += 1;
|
||||
tombstones.push(t);
|
||||
}
|
||||
|
||||
store.append_nodes(&tombstones)?;
|
||||
if !updated_rels.is_empty() {
|
||||
store.append_relations(&updated_rels)?;
|
||||
}
|
||||
|
||||
for uuid in &doomed_uuids {
|
||||
store.uuid_to_key.remove(uuid);
|
||||
}
|
||||
|
||||
merged += doomed_uuids.len();
|
||||
}
|
||||
|
||||
// Remove tombstoned relations from cache
|
||||
store.relations.retain(|r| !r.deleted);
|
||||
store.save()?;
|
||||
|
||||
println!("Merged {} duplicates, redirected {} edges, deduped {} duplicate edges",
|
||||
merged, edges_redirected, edges_deduped);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_health() -> Result<(), String> {
|
||||
let store = store::Store::load()?;
|
||||
let g = store.build_graph();
|
||||
let report = crate::graph::health_report(&g, &store);
|
||||
print!("{}", report);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_daily_check() -> Result<(), String> {
|
||||
let store = store::Store::load()?;
|
||||
let report = crate::neuro::daily_check(&store);
|
||||
print!("{}", report);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_import(files: &[String]) -> Result<(), String> {
|
||||
if files.is_empty() {
|
||||
return Err("import requires at least one file path".into());
|
||||
}
|
||||
|
||||
let mut store = store::Store::load()?;
|
||||
let mut total_new = 0;
|
||||
let mut total_updated = 0;
|
||||
|
||||
for arg in files {
|
||||
let path = std::path::PathBuf::from(arg);
|
||||
let resolved = if path.exists() {
|
||||
path
|
||||
} else {
|
||||
let mem_path = store::memory_dir().join(arg);
|
||||
if !mem_path.exists() {
|
||||
eprintln!("File not found: {}", arg);
|
||||
continue;
|
||||
}
|
||||
mem_path
|
||||
};
|
||||
let (n, u) = store.import_file(&resolved)?;
|
||||
total_new += n;
|
||||
total_updated += u;
|
||||
}
|
||||
|
||||
if total_new > 0 || total_updated > 0 {
|
||||
store.save()?;
|
||||
}
|
||||
println!("Import: {} new, {} updated", total_new, total_updated);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_export(files: &[String], export_all: bool) -> Result<(), String> {
|
||||
let store = store::Store::load()?;
|
||||
|
||||
let targets: Vec<String> = if export_all {
|
||||
let mut files: Vec<String> = store.nodes.keys()
|
||||
.filter(|k| !k.contains('#'))
|
||||
.cloned()
|
||||
.collect();
|
||||
files.sort();
|
||||
files
|
||||
} else if files.is_empty() {
|
||||
return Err("export requires file keys or --all".into());
|
||||
} else {
|
||||
files.iter().map(|a| {
|
||||
a.strip_suffix(".md").unwrap_or(a).to_string()
|
||||
}).collect()
|
||||
};
|
||||
|
||||
let mem_dir = store::memory_dir();
|
||||
|
||||
for file_key in &targets {
|
||||
match store.export_to_markdown(file_key) {
|
||||
Some(content) => {
|
||||
let out_path = mem_dir.join(format!("{}.md", file_key));
|
||||
std::fs::write(&out_path, &content)
|
||||
.map_err(|e| format!("write {}: {}", out_path.display(), e))?;
|
||||
let section_count = content.matches("<!-- mem:").count() + 1;
|
||||
println!("Exported {} ({} sections)", file_key, section_count);
|
||||
}
|
||||
None => eprintln!("No nodes for '{}'", file_key),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
402
src/cli/agent.rs
Normal file
402
src/cli/agent.rs
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
// cli/agent.rs — agent subcommand handlers
|
||||
|
||||
use crate::store;
|
||||
use crate::agents::llm;
|
||||
|
||||
pub fn cmd_run_agent(agent: &str, count: usize, target: &[String], query: Option<&str>, dry_run: bool, local: bool) -> Result<(), String> {
|
||||
// Mark as agent so tool calls (e.g. poc-memory render) don't
|
||||
// pollute the user's seen set as a side effect
|
||||
// SAFETY: single-threaded at this point (CLI startup, before any agent work)
|
||||
unsafe { std::env::set_var("POC_AGENT", "1"); }
|
||||
|
||||
if dry_run {
|
||||
unsafe { std::env::set_var("POC_MEMORY_DRY_RUN", "1"); }
|
||||
}
|
||||
|
||||
let needs_local = local || dry_run;
|
||||
let has_targets = !target.is_empty() || query.is_some();
|
||||
|
||||
// Fast path: no explicit targets, daemon available — just queue via RPC
|
||||
if !needs_local && !has_targets {
|
||||
if crate::agents::daemon::send_rpc_pub("ping").is_some() {
|
||||
return crate::agents::daemon::rpc_run_agent(agent, count);
|
||||
}
|
||||
println!("Daemon not running — falling back to local execution");
|
||||
}
|
||||
|
||||
// Slow path: need the store for local execution or target resolution
|
||||
let mut store = store::Store::load()?;
|
||||
let log = |msg: &str| println!("{}", msg);
|
||||
|
||||
// Resolve targets: explicit --target, --query, or agent's default query
|
||||
let resolved_targets: Vec<String> = if !target.is_empty() {
|
||||
target.to_vec()
|
||||
} else if let Some(q) = query {
|
||||
let graph = store.build_graph();
|
||||
let stages = crate::search::Stage::parse_pipeline(q)?;
|
||||
let results = crate::search::run_query(&stages, vec![], &graph, &store, false, count);
|
||||
if results.is_empty() {
|
||||
return Err(format!("query returned no results: {}", q));
|
||||
}
|
||||
let keys: Vec<String> = results.into_iter().map(|(k, _)| k).collect();
|
||||
println!("[{}] query matched {} nodes", agent, keys.len());
|
||||
keys
|
||||
} else {
|
||||
vec![] // use agent's built-in query
|
||||
};
|
||||
|
||||
if !resolved_targets.is_empty() {
|
||||
// --local or daemon unavailable: run directly
|
||||
if needs_local || crate::agents::daemon::send_rpc_pub("ping").is_none() {
|
||||
if !needs_local {
|
||||
println!("Daemon not running — falling back to local execution");
|
||||
}
|
||||
for (i, key) in resolved_targets.iter().enumerate() {
|
||||
println!("[{}] [{}/{}] {}", agent, i + 1, resolved_targets.len(), key);
|
||||
if i > 0 { store = store::Store::load()?; }
|
||||
if let Err(e) = crate::agents::knowledge::run_one_agent_with_keys(
|
||||
&mut store, agent, &[key.clone()], count, "test", &log,
|
||||
) {
|
||||
println!("[{}] ERROR on {}: {}", agent, key, e);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Queue to daemon
|
||||
let mut queued = 0;
|
||||
for key in &resolved_targets {
|
||||
let cmd = format!("run-agent {} 1 target:{}", agent, key);
|
||||
if crate::agents::daemon::send_rpc_pub(&cmd).is_some() {
|
||||
queued += 1;
|
||||
}
|
||||
}
|
||||
println!("[{}] queued {} tasks to daemon", agent, queued);
|
||||
} else {
|
||||
// Local execution (--local, --debug, dry-run, or daemon unavailable)
|
||||
crate::agents::knowledge::run_one_agent(
|
||||
&mut store, agent, count, "test", &log,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_consolidate_batch(count: usize, auto: bool, agent: Option<String>) -> Result<(), String> {
|
||||
let store = store::Store::load()?;
|
||||
|
||||
if let Some(agent_name) = agent {
|
||||
let batch = crate::agents::prompts::agent_prompt(&store, &agent_name, count)?;
|
||||
println!("{}", batch.prompt);
|
||||
Ok(())
|
||||
} else {
|
||||
crate::agents::prompts::consolidation_batch(&store, count, auto)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cmd_replay_queue(count: usize) -> Result<(), String> {
|
||||
let store = store::Store::load()?;
|
||||
let queue = crate::neuro::replay_queue(&store, count);
|
||||
println!("Replay queue ({} items):", queue.len());
|
||||
for (i, item) in queue.iter().enumerate() {
|
||||
println!(" {:2}. [{:.3}] {:>10} {} (interval={}d, emotion={:.1}, spectral={:.1})",
|
||||
i + 1, item.priority, item.classification, item.key,
|
||||
item.interval_days, item.emotion, item.outlier_score);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_consolidate_session() -> Result<(), String> {
|
||||
let store = store::Store::load()?;
|
||||
let plan = crate::neuro::consolidation_plan(&store);
|
||||
println!("{}", crate::neuro::format_plan(&plan));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_consolidate_full() -> Result<(), String> {
|
||||
let mut store = store::Store::load()?;
|
||||
crate::consolidate::consolidate_full(&mut store)
|
||||
}
|
||||
|
||||
pub fn cmd_digest_links(do_apply: bool) -> Result<(), String> {
|
||||
let store = store::Store::load()?;
|
||||
let links = crate::digest::parse_all_digest_links(&store);
|
||||
drop(store);
|
||||
println!("Found {} unique links from digest nodes", links.len());
|
||||
|
||||
if !do_apply {
|
||||
for (i, link) in links.iter().enumerate() {
|
||||
println!(" {:3}. {} → {}", i + 1, link.source, link.target);
|
||||
if !link.reason.is_empty() {
|
||||
println!(" ({})", &link.reason[..link.reason.len().min(80)]);
|
||||
}
|
||||
}
|
||||
println!("\nTo apply: poc-memory digest-links --apply");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut store = store::Store::load()?;
|
||||
let (applied, skipped, fallbacks) = crate::digest::apply_digest_links(&mut store, &links);
|
||||
println!("\nApplied: {} ({} file-level fallbacks) Skipped: {}", applied, fallbacks, skipped);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_journal_enrich(_jsonl_path: &str, _entry_text: &str, _grep_line: usize) -> Result<(), String> {
|
||||
Err("journal-enrich has been removed — use the observation agent instead.".into())
|
||||
}
|
||||
|
||||
pub fn cmd_apply_consolidation(_do_apply: bool, _report_file: Option<&str>) -> Result<(), String> {
|
||||
Err("apply-consolidation has been removed — agents now apply changes via tool calls directly.".into())
|
||||
}
|
||||
|
||||
pub fn cmd_knowledge_loop(_max_cycles: usize, _batch_size: usize, _window: usize, _max_depth: i32) -> Result<(), String> {
|
||||
Err("knowledge-loop has been removed — agents now use tool calls directly. Use `poc-memory agent run` instead.".into())
|
||||
}
|
||||
|
||||
pub fn cmd_fact_mine(_path: &str, _batch: bool, _dry_run: bool, _output_file: Option<&str>, _min_messages: usize) -> Result<(), String> {
|
||||
Err("fact-mine has been removed — use the observation agent instead.".into())
|
||||
}
|
||||
|
||||
pub fn cmd_fact_mine_store(_path: &str) -> Result<(), String> {
|
||||
Err("fact-mine-store has been removed — use the observation agent instead.".into())
|
||||
}
|
||||
|
||||
/// Sample recent actions from each agent type, sort by quality using
|
||||
/// LLM pairwise comparison, report per-type rankings.
|
||||
/// Elo ratings file path
|
||||
fn elo_path() -> std::path::PathBuf {
|
||||
crate::config::get().data_dir.join("agent-elo.json")
|
||||
}
|
||||
|
||||
/// Load persisted Elo ratings, or initialize at 1000.0
|
||||
fn load_elo_ratings(agent_types: &[&str]) -> std::collections::HashMap<String, f64> {
|
||||
let path = elo_path();
|
||||
let mut ratings: std::collections::HashMap<String, f64> = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
for t in agent_types {
|
||||
ratings.entry(t.to_string()).or_insert(1000.0);
|
||||
}
|
||||
ratings
|
||||
}
|
||||
|
||||
fn save_elo_ratings(ratings: &std::collections::HashMap<String, f64>) {
|
||||
let path = elo_path();
|
||||
if let Ok(json) = serde_json::to_string_pretty(ratings) {
|
||||
let _ = std::fs::write(path, json);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cmd_evaluate_agents(matchups: usize, model: &str, dry_run: bool) -> Result<(), String> {
|
||||
use skillratings::elo::{elo, EloConfig, EloRating};
|
||||
use skillratings::Outcomes;
|
||||
|
||||
let store = store::Store::load()?;
|
||||
|
||||
let agent_types: Vec<&str> = vec![
|
||||
"linker", "organize", "distill", "separator",
|
||||
"split", "rename",
|
||||
];
|
||||
|
||||
// Load agent prompt files
|
||||
let prompts_dir = {
|
||||
let repo = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("agents");
|
||||
if repo.is_dir() { repo } else { crate::store::memory_dir().join("agents") }
|
||||
};
|
||||
|
||||
// Collect recent actions per agent type
|
||||
let mut actions: std::collections::HashMap<String, Vec<(String, String)>> = std::collections::HashMap::new();
|
||||
|
||||
for agent_type in &agent_types {
|
||||
let prompt_file = prompts_dir.join(format!("{}.agent", agent_type));
|
||||
let agent_prompt = std::fs::read_to_string(&prompt_file)
|
||||
.unwrap_or_default()
|
||||
.lines().skip(1).collect::<Vec<_>>().join("\n");
|
||||
let agent_prompt = crate::util::truncate(&agent_prompt, 500, "...");
|
||||
|
||||
let prefix = format!("_consolidate-{}", agent_type);
|
||||
let mut keys: Vec<(String, i64)> = store.nodes.iter()
|
||||
.filter(|(k, _)| k.starts_with(&prefix))
|
||||
.map(|(k, n)| (k.clone(), n.timestamp))
|
||||
.collect();
|
||||
keys.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
keys.truncate(20); // pool of recent actions to sample from
|
||||
|
||||
let mut type_actions = Vec::new();
|
||||
for (key, _) in &keys {
|
||||
let report = store.nodes.get(key)
|
||||
.map(|n| n.content.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut target_content = String::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for word in report.split_whitespace() {
|
||||
let clean = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_');
|
||||
if clean.len() > 10 && seen.insert(clean.to_string()) && store.nodes.contains_key(clean)
|
||||
&& let Some(node) = store.nodes.get(clean) {
|
||||
let preview = crate::util::truncate(&node.content, 200, "...");
|
||||
target_content.push_str(&format!("\n### {}\n{}\n", clean, preview));
|
||||
if target_content.len() > 1500 { break; }
|
||||
}
|
||||
}
|
||||
|
||||
let context = format!(
|
||||
"## Agent instructions\n{}\n\n## Report output\n{}\n\n## Affected nodes\n{}",
|
||||
agent_prompt,
|
||||
crate::util::truncate(&report, 1000, "..."),
|
||||
if target_content.is_empty() { "(none found)".into() } else { target_content }
|
||||
);
|
||||
type_actions.push((key.clone(), context));
|
||||
}
|
||||
actions.insert(agent_type.to_string(), type_actions);
|
||||
}
|
||||
|
||||
// Filter to types that have at least 1 action
|
||||
let active_types: Vec<&str> = agent_types.iter()
|
||||
.filter(|t| actions.get(**t).map(|a| !a.is_empty()).unwrap_or(false))
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
if active_types.len() < 2 {
|
||||
return Err("Need at least 2 agent types with actions".into());
|
||||
}
|
||||
|
||||
eprintln!("Evaluating {} agent types with {} matchups (model={})",
|
||||
active_types.len(), matchups, model);
|
||||
|
||||
if dry_run {
|
||||
let t1 = active_types[0];
|
||||
let t2 = active_types[active_types.len() - 1];
|
||||
let a1 = &actions[t1][0];
|
||||
let a2 = &actions[t2][0];
|
||||
let sample_a = (t1.to_string(), a1.0.clone(), a1.1.clone());
|
||||
let sample_b = (t2.to_string(), a2.0.clone(), a2.1.clone());
|
||||
println!("=== DRY RUN: Example comparison ===\n");
|
||||
println!("{}", build_compare_prompt(&sample_a, &sample_b));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Load persisted ratings
|
||||
let mut ratings = load_elo_ratings(&agent_types);
|
||||
let config = EloConfig { k: 32.0 };
|
||||
// Simple but adequate RNG: xorshift32
|
||||
let mut rng = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().subsec_nanos() | 1;
|
||||
let mut next_rng = || -> usize {
|
||||
rng ^= rng << 13;
|
||||
rng ^= rng >> 17;
|
||||
rng ^= rng << 5;
|
||||
rng as usize
|
||||
};
|
||||
|
||||
for i in 0..matchups {
|
||||
// Pick two different random agent types
|
||||
let idx_a = next_rng() % active_types.len();
|
||||
let mut idx_b = next_rng() % active_types.len();
|
||||
if idx_b == idx_a { idx_b = (idx_b + 1) % active_types.len(); }
|
||||
|
||||
let type_a = active_types[idx_a];
|
||||
let type_b = active_types[idx_b];
|
||||
|
||||
// Pick random recent action from each
|
||||
let acts_a = &actions[type_a];
|
||||
let acts_b = &actions[type_b];
|
||||
let act_a = &acts_a[next_rng() % acts_a.len()];
|
||||
let act_b = &acts_b[next_rng() % acts_b.len()];
|
||||
|
||||
let sample_a = (type_a.to_string(), act_a.0.clone(), act_a.1.clone());
|
||||
let sample_b = (type_b.to_string(), act_b.0.clone(), act_b.1.clone());
|
||||
|
||||
let result = llm_compare(&sample_a, &sample_b, model);
|
||||
|
||||
let rating_a = EloRating { rating: ratings[type_a] };
|
||||
let rating_b = EloRating { rating: ratings[type_b] };
|
||||
|
||||
let outcome = match result {
|
||||
Ok(std::cmp::Ordering::Less) => Outcomes::WIN, // A wins
|
||||
Ok(std::cmp::Ordering::Greater) => Outcomes::LOSS, // B wins
|
||||
_ => Outcomes::WIN, // default to A
|
||||
};
|
||||
|
||||
let (new_a, new_b) = elo(&rating_a, &rating_b, &outcome, &config);
|
||||
ratings.insert(type_a.to_string(), new_a.rating);
|
||||
ratings.insert(type_b.to_string(), new_b.rating);
|
||||
|
||||
eprint!(" matchup {}/{}: {} vs {} → {}\r",
|
||||
i + 1, matchups, type_a, type_b,
|
||||
if matches!(outcome, Outcomes::WIN) { type_a } else { type_b });
|
||||
}
|
||||
eprintln!();
|
||||
|
||||
// Save updated ratings
|
||||
save_elo_ratings(&ratings);
|
||||
|
||||
// Print rankings
|
||||
let mut ranked: Vec<_> = ratings.iter().collect();
|
||||
ranked.sort_by(|a, b| b.1.total_cmp(a.1));
|
||||
|
||||
println!("\nAgent Elo Ratings (after {} matchups):\n", matchups);
|
||||
for (agent_type, rating) in &ranked {
|
||||
let bar_len = ((*rating - 800.0) / 10.0).max(0.0) as usize;
|
||||
let bar = "#".repeat(bar_len.min(40));
|
||||
println!(" {:12} {:7.1} {}", agent_type, rating, bar);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_compare_prompt(
|
||||
a: &(String, String, String),
|
||||
b: &(String, String, String),
|
||||
) -> String {
|
||||
if a.0 == b.0 {
|
||||
// Same agent type — show instructions once
|
||||
// Split context at "## Report output" to extract shared prompt
|
||||
let split_a: Vec<&str> = a.2.splitn(2, "## Report output").collect();
|
||||
let split_b: Vec<&str> = b.2.splitn(2, "## Report output").collect();
|
||||
let shared_prompt = split_a.first().unwrap_or(&"");
|
||||
let report_a = split_a.get(1).unwrap_or(&"");
|
||||
let report_b = split_b.get(1).unwrap_or(&"");
|
||||
format!(
|
||||
"Compare two actions from the same {} agent. Which was better?\n\n\
|
||||
{}\n\n\
|
||||
## Action A\n## Report output{}\n\n\
|
||||
## Action B\n## Report output{}\n\n\
|
||||
Say which is better and why in 1-2 sentences, then end with:\n\
|
||||
BETTER: A or BETTER: B\n\
|
||||
You must pick one. No ties.",
|
||||
a.0, shared_prompt, report_a, report_b
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Compare these two memory graph agent actions. Which one was better \
|
||||
for building a useful, well-organized knowledge graph?\n\n\
|
||||
## Action A ({} agent)\n{}\n\n\
|
||||
## Action B ({} agent)\n{}\n\n\
|
||||
Say which is better and why in 1-2 sentences, then end with:\n\
|
||||
BETTER: A or BETTER: B\n\
|
||||
You must pick one. No ties.",
|
||||
a.0, a.2, b.0, b.2
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn llm_compare(
|
||||
a: &(String, String, String),
|
||||
b: &(String, String, String),
|
||||
model: &str,
|
||||
) -> Result<std::cmp::Ordering, String> {
|
||||
let prompt = build_compare_prompt(a, b);
|
||||
|
||||
let _ = model; // model selection handled by API backend config
|
||||
let response = llm::call_simple("compare", &prompt)?;
|
||||
let response = response.trim().to_uppercase();
|
||||
|
||||
if response.contains("BETTER: B") {
|
||||
Ok(std::cmp::Ordering::Greater)
|
||||
} else {
|
||||
// Default to A (includes "BETTER: A" and any unparseable response)
|
||||
Ok(std::cmp::Ordering::Less)
|
||||
}
|
||||
}
|
||||
|
||||
695
src/cli/graph.rs
Normal file
695
src/cli/graph.rs
Normal file
|
|
@ -0,0 +1,695 @@
|
|||
// cli/graph.rs — graph subcommand handlers
|
||||
//
|
||||
// Extracted from main.rs. All graph-related CLI commands:
|
||||
// link, link-add, link-impact, link-audit, link-orphans,
|
||||
// triangle-close, cap-degree, normalize-strengths, differentiate,
|
||||
// trace, spectral-*, organize, interference.
|
||||
|
||||
use crate::{store, graph, neuro, spectral};
|
||||
|
||||
pub fn cmd_graph() -> Result<(), String> {
|
||||
let store = store::Store::load()?;
|
||||
let g = store.build_graph();
|
||||
println!("Graph: {} nodes, {} edges, {} communities",
|
||||
g.nodes().len(), g.edge_count(), g.community_count());
|
||||
println!("σ={:.2} α={:.2} gini={:.3} cc={:.4}",
|
||||
g.small_world_sigma(), g.degree_power_law_exponent(),
|
||||
g.degree_gini(), g.avg_clustering_coefficient());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_link_orphans(min_deg: usize, links_per: usize, sim_thresh: f32) -> Result<(), String> {
|
||||
let mut store = store::Store::load()?;
|
||||
let (orphans, links) = neuro::link_orphans(&mut store, min_deg, links_per, sim_thresh);
|
||||
println!("Linked {} orphans, added {} connections (min_degree={}, links_per={}, sim>{})",
|
||||
orphans, links, min_deg, links_per, sim_thresh);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_cap_degree(max_deg: usize) -> Result<(), String> {
|
||||
let mut store = store::Store::load()?;
|
||||
let (hubs, pruned) = store.cap_degree(max_deg)?;
|
||||
store.save()?;
|
||||
println!("Capped {} hubs, pruned {} weak Auto edges (max_degree={})", hubs, pruned, max_deg);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_normalize_strengths(apply: bool) -> Result<(), String> {
|
||||
let mut store = store::Store::load()?;
|
||||
let graph = store.build_graph();
|
||||
let strengths = graph.jaccard_strengths();
|
||||
|
||||
// Build a lookup from (source_key, target_key) → new_strength
|
||||
let mut updates: std::collections::HashMap<(String, String), f32> = std::collections::HashMap::new();
|
||||
for (a, b, s) in &strengths {
|
||||
// Store both directions for easy lookup
|
||||
updates.insert((a.clone(), b.clone()), *s);
|
||||
updates.insert((b.clone(), a.clone()), *s);
|
||||
}
|
||||
|
||||
// Stats
|
||||
let mut changed = 0usize;
|
||||
let mut unchanged = 0usize;
|
||||
let mut temporal_skipped = 0usize;
|
||||
let mut delta_sum: f64 = 0.0;
|
||||
|
||||
// Histogram of new strengths
|
||||
let mut buckets = [0usize; 10]; // 0.0-0.1, 0.1-0.2, ...
|
||||
|
||||
for rel in &mut store.relations {
|
||||
if rel.deleted { continue; }
|
||||
|
||||
// Skip implicit temporal edges (strength 1.0, Auto type)
|
||||
if rel.strength == 1.0 && rel.rel_type == store::RelationType::Auto {
|
||||
temporal_skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(&new_s) = updates.get(&(rel.source_key.clone(), rel.target_key.clone())) {
|
||||
let old_s = rel.strength;
|
||||
let delta = (new_s - old_s).abs();
|
||||
if delta > 0.001 {
|
||||
delta_sum += delta as f64;
|
||||
if apply {
|
||||
rel.strength = new_s;
|
||||
}
|
||||
changed += 1;
|
||||
} else {
|
||||
unchanged += 1;
|
||||
}
|
||||
let bucket = ((new_s * 10.0) as usize).min(9);
|
||||
buckets[bucket] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
println!("Normalize link strengths (Jaccard similarity)");
|
||||
println!(" Total edges in graph: {}", strengths.len());
|
||||
println!(" Would change: {}", changed);
|
||||
println!(" Unchanged: {}", unchanged);
|
||||
println!(" Temporal (skipped): {}", temporal_skipped);
|
||||
if changed > 0 {
|
||||
println!(" Avg delta: {:.3}", delta_sum / changed as f64);
|
||||
}
|
||||
println!();
|
||||
println!(" Strength distribution:");
|
||||
for (i, &count) in buckets.iter().enumerate() {
|
||||
let lo = i as f32 / 10.0;
|
||||
let hi = lo + 0.1;
|
||||
let bar = "#".repeat(count / 50 + if count > 0 { 1 } else { 0 });
|
||||
println!(" {:.1}-{:.1}: {:5} {}", lo, hi, count, bar);
|
||||
}
|
||||
|
||||
if apply {
|
||||
store.save()?;
|
||||
println!("\nApplied {} strength updates.", changed);
|
||||
} else {
|
||||
println!("\nDry run. Use --apply to write changes.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_link(key: &[String]) -> Result<(), String> {
|
||||
if key.is_empty() {
|
||||
return Err("link requires a key".into());
|
||||
}
|
||||
let key = key.join(" ");
|
||||
let store = store::Store::load()?;
|
||||
let resolved = store.resolve_key(&key)?;
|
||||
let g = store.build_graph();
|
||||
println!("Neighbors of '{}':", resolved);
|
||||
crate::query_parser::run_query(&store, &g,
|
||||
&format!("neighbors('{}') | select strength,clustering_coefficient", resolved))
|
||||
}
|
||||
|
||||
pub fn cmd_triangle_close(min_degree: usize, sim_threshold: f32, max_per_hub: usize) -> Result<(), String> {
|
||||
println!("Triangle closure: min_degree={}, sim_threshold={}, max_per_hub={}",
|
||||
min_degree, sim_threshold, max_per_hub);
|
||||
|
||||
let mut store = store::Store::load()?;
|
||||
let (hubs, added) = neuro::triangle_close(&mut store, min_degree, sim_threshold, max_per_hub);
|
||||
println!("\nProcessed {} hubs, added {} lateral links", hubs, added);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_link_add(source: &str, target: &str, reason: &[String]) -> Result<(), String> {
|
||||
super::check_dry_run();
|
||||
let mut store = store::Store::load()?;
|
||||
let source = store.resolve_key(source)?;
|
||||
let target = store.resolve_key(target)?;
|
||||
let reason = reason.join(" ");
|
||||
|
||||
// Refine target to best-matching section
|
||||
let source_content = store.nodes.get(&source)
|
||||
.map(|n| n.content.as_str()).unwrap_or("");
|
||||
let target = neuro::refine_target(&store, source_content, &target);
|
||||
|
||||
// Find UUIDs
|
||||
let source_uuid = store.nodes.get(&source)
|
||||
.map(|n| n.uuid)
|
||||
.ok_or_else(|| format!("source not found: {}", source))?;
|
||||
let target_uuid = store.nodes.get(&target)
|
||||
.map(|n| n.uuid)
|
||||
.ok_or_else(|| format!("target not found: {}", target))?;
|
||||
|
||||
// Check for existing link
|
||||
let exists = store.relations.iter().any(|r|
|
||||
!r.deleted &&
|
||||
((r.source_key == source && r.target_key == target) ||
|
||||
(r.source_key == target && r.target_key == source)));
|
||||
if exists {
|
||||
println!("Link already exists: {} ↔ {}", source, target);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Compute initial strength from Jaccard neighborhood similarity
|
||||
let graph = store.build_graph();
|
||||
let jaccard = graph.jaccard(&source, &target);
|
||||
let strength = (jaccard * 3.0).clamp(0.1, 1.0);
|
||||
|
||||
let rel = store::new_relation(
|
||||
source_uuid, target_uuid,
|
||||
store::RelationType::Link, strength,
|
||||
&source, &target,
|
||||
);
|
||||
store.add_relation(rel)?;
|
||||
store.save()?;
|
||||
println!("Linked: {} → {} (strength={:.2}, {})", source, target, strength, reason);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_link_set(source: &str, target: &str, strength: f32) -> Result<(), String> {
|
||||
super::check_dry_run();
|
||||
let mut store = store::Store::load()?;
|
||||
let source = store.resolve_key(source)?;
|
||||
let target = store.resolve_key(target)?;
|
||||
let strength = strength.clamp(0.01, 1.0);
|
||||
|
||||
let mut found = false;
|
||||
let mut first = true;
|
||||
for rel in &mut store.relations {
|
||||
if rel.deleted { continue; }
|
||||
if (rel.source_key == source && rel.target_key == target)
|
||||
|| (rel.source_key == target && rel.target_key == source)
|
||||
{
|
||||
if first {
|
||||
let old = rel.strength;
|
||||
rel.strength = strength;
|
||||
println!("Set: {} ↔ {} strength {:.2} → {:.2}", source, target, old, strength);
|
||||
first = false;
|
||||
} else {
|
||||
// Duplicate — mark deleted
|
||||
rel.deleted = true;
|
||||
println!(" (removed duplicate link)");
|
||||
}
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return Err(format!("No link found between {} and {}", source, target));
|
||||
}
|
||||
|
||||
store.save()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_link_impact(source: &str, target: &str) -> Result<(), String> {
|
||||
let store = store::Store::load()?;
|
||||
let source = store.resolve_key(source)?;
|
||||
let target = store.resolve_key(target)?;
|
||||
let g = store.build_graph();
|
||||
|
||||
let impact = g.link_impact(&source, &target);
|
||||
|
||||
println!("Link impact: {} → {}", source, target);
|
||||
println!(" Source degree: {} Target degree: {}", impact.source_deg, impact.target_deg);
|
||||
println!(" Hub link: {} Same community: {}", impact.is_hub_link, impact.same_community);
|
||||
println!(" ΔCC source: {:+.4} ΔCC target: {:+.4}", impact.delta_cc_source, impact.delta_cc_target);
|
||||
println!(" ΔGini: {:+.6}", impact.delta_gini);
|
||||
println!(" Assessment: {}", impact.assessment);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_differentiate(key_arg: Option<&str>, do_apply: bool) -> Result<(), String> {
|
||||
let mut store = store::Store::load()?;
|
||||
|
||||
if let Some(key) = key_arg {
|
||||
let resolved = store.resolve_key(key)?;
|
||||
let moves = neuro::differentiate_hub(&store, &resolved)
|
||||
.ok_or_else(|| format!("'{}' is not a file-level hub with sections", resolved))?;
|
||||
|
||||
// Group by target section for display
|
||||
let mut by_section: std::collections::BTreeMap<String, Vec<&neuro::LinkMove>> =
|
||||
std::collections::BTreeMap::new();
|
||||
for mv in &moves {
|
||||
by_section.entry(mv.to_section.clone()).or_default().push(mv);
|
||||
}
|
||||
|
||||
println!("Hub '{}' — {} links to redistribute across {} sections\n",
|
||||
resolved, moves.len(), by_section.len());
|
||||
|
||||
for (section, section_moves) in &by_section {
|
||||
println!(" {} ({} links):", section, section_moves.len());
|
||||
for mv in section_moves.iter().take(5) {
|
||||
println!(" [{:.3}] {} — {}", mv.similarity,
|
||||
mv.neighbor_key, mv.neighbor_snippet);
|
||||
}
|
||||
if section_moves.len() > 5 {
|
||||
println!(" ... and {} more", section_moves.len() - 5);
|
||||
}
|
||||
}
|
||||
|
||||
if !do_apply {
|
||||
println!("\nTo apply: poc-memory differentiate {} --apply", resolved);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (applied, skipped) = neuro::apply_differentiation(&mut store, &moves);
|
||||
store.save()?;
|
||||
println!("\nApplied: {} Skipped: {}", applied, skipped);
|
||||
} else {
|
||||
let hubs = neuro::find_differentiable_hubs(&store);
|
||||
if hubs.is_empty() {
|
||||
println!("No file-level hubs with sections found above threshold");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Differentiable hubs (file-level nodes with sections):\n");
|
||||
for (key, degree, sections) in &hubs {
|
||||
println!(" {:40} deg={:3} sections={}", key, degree, sections);
|
||||
}
|
||||
println!("\nRun: poc-memory differentiate KEY to preview a specific hub");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_link_audit(apply: bool) -> Result<(), String> {
|
||||
let mut store = store::Store::load()?;
|
||||
let stats = crate::audit::link_audit(&mut store, apply)?;
|
||||
println!("\n{}", "=".repeat(60));
|
||||
println!("Link audit complete:");
|
||||
println!(" Kept: {} Deleted: {} Retargeted: {} Weakened: {} Strengthened: {} Errors: {}",
|
||||
stats.kept, stats.deleted, stats.retargeted, stats.weakened, stats.strengthened, stats.errors);
|
||||
println!("{}", "=".repeat(60));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_trace(key: &[String]) -> Result<(), String> {
|
||||
if key.is_empty() {
|
||||
return Err("trace requires a key".into());
|
||||
}
|
||||
let key = key.join(" ");
|
||||
let store = store::Store::load()?;
|
||||
let resolved = store.resolve_key(&key)?;
|
||||
let g = store.build_graph();
|
||||
|
||||
let node = store.nodes.get(&resolved)
|
||||
.ok_or_else(|| format!("Node not found: {}", resolved))?;
|
||||
|
||||
// Display the node itself
|
||||
println!("=== {} ===", resolved);
|
||||
println!("Type: {:?} Weight: {:.2}",
|
||||
node.node_type, node.weight);
|
||||
if !node.source_ref.is_empty() {
|
||||
println!("Source: {}", node.source_ref);
|
||||
}
|
||||
|
||||
// Show content preview
|
||||
let preview = crate::util::truncate(&node.content, 200, "...");
|
||||
println!("\n{}\n", preview);
|
||||
|
||||
// Walk neighbors, grouped by node type
|
||||
let neighbors = g.neighbors(&resolved);
|
||||
let mut episodic_session = Vec::new();
|
||||
let mut episodic_daily = Vec::new();
|
||||
let mut episodic_weekly = Vec::new();
|
||||
let mut semantic = Vec::new();
|
||||
|
||||
for (n, strength) in &neighbors {
|
||||
if let Some(nnode) = store.nodes.get(n.as_str()) {
|
||||
let entry = (n.as_str(), *strength, nnode);
|
||||
match nnode.node_type {
|
||||
store::NodeType::EpisodicSession =>
|
||||
episodic_session.push(entry),
|
||||
store::NodeType::EpisodicDaily =>
|
||||
episodic_daily.push(entry),
|
||||
store::NodeType::EpisodicWeekly
|
||||
| store::NodeType::EpisodicMonthly =>
|
||||
episodic_weekly.push(entry),
|
||||
store::NodeType::Semantic =>
|
||||
semantic.push(entry),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !episodic_weekly.is_empty() {
|
||||
println!("Weekly digests:");
|
||||
for (k, s, n) in &episodic_weekly {
|
||||
let preview = crate::util::first_n_chars(n.content.lines().next().unwrap_or(""), 80);
|
||||
println!(" [{:.2}] {} — {}", s, k, preview);
|
||||
}
|
||||
}
|
||||
|
||||
if !episodic_daily.is_empty() {
|
||||
println!("Daily digests:");
|
||||
for (k, s, n) in &episodic_daily {
|
||||
let preview = crate::util::first_n_chars(n.content.lines().next().unwrap_or(""), 80);
|
||||
println!(" [{:.2}] {} — {}", s, k, preview);
|
||||
}
|
||||
}
|
||||
|
||||
if !episodic_session.is_empty() {
|
||||
println!("Session entries:");
|
||||
for (k, s, n) in &episodic_session {
|
||||
let preview = crate::util::first_n_chars(
|
||||
n.content.lines()
|
||||
.find(|l| !l.is_empty() && !l.starts_with("<!--"))
|
||||
.unwrap_or(""),
|
||||
80);
|
||||
println!(" [{:.2}] {}", s, k);
|
||||
if !n.source_ref.is_empty() {
|
||||
println!(" ↳ source: {}", n.source_ref);
|
||||
}
|
||||
println!(" {}", preview);
|
||||
}
|
||||
}
|
||||
|
||||
if !semantic.is_empty() {
|
||||
println!("Semantic links:");
|
||||
for (k, s, _) in &semantic {
|
||||
println!(" [{:.2}] {}", s, k);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nLinks: {} session, {} daily, {} weekly, {} semantic",
|
||||
episodic_session.len(), episodic_daily.len(),
|
||||
episodic_weekly.len(), semantic.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_spectral(k: usize) -> Result<(), String> {
|
||||
let store = store::Store::load()?;
|
||||
let g = graph::build_graph(&store);
|
||||
let result = spectral::decompose(&g, k);
|
||||
spectral::print_summary(&result, &g);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_spectral_save(k: usize) -> Result<(), String> {
|
||||
let store = store::Store::load()?;
|
||||
let g = graph::build_graph(&store);
|
||||
let result = spectral::decompose(&g, k);
|
||||
let emb = spectral::to_embedding(&result);
|
||||
spectral::save_embedding(&emb)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_spectral_neighbors(key: &str, n: usize) -> Result<(), String> {
|
||||
let emb = spectral::load_embedding()?;
|
||||
|
||||
let dims = spectral::dominant_dimensions(&emb, &[key]);
|
||||
println!("Node: {} (embedding: {} dims)", key, emb.dims);
|
||||
println!("Top spectral axes:");
|
||||
for &(d, loading) in dims.iter().take(5) {
|
||||
println!(" axis {:<2} (λ={:.4}): loading={:.5}", d, emb.eigenvalues[d], loading);
|
||||
}
|
||||
|
||||
println!("\nNearest neighbors in spectral space:");
|
||||
let neighbors = spectral::nearest_neighbors(&emb, key, n);
|
||||
for (i, (k, dist)) in neighbors.iter().enumerate() {
|
||||
println!(" {:>2}. {:.5} {}", i + 1, dist, k);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_spectral_positions(n: usize) -> Result<(), String> {
|
||||
let store = store::Store::load()?;
|
||||
let emb = spectral::load_embedding()?;
|
||||
|
||||
let g = store.build_graph();
|
||||
let communities = g.communities().clone();
|
||||
|
||||
let positions = spectral::analyze_positions(&emb, &communities);
|
||||
|
||||
println!("Spectral position analysis — {} nodes", positions.len());
|
||||
println!(" outlier: dist_to_center / median (>1 = unusual position)");
|
||||
println!(" bridge: dist_to_center / dist_to_nearest_other_community");
|
||||
println!();
|
||||
|
||||
let mut bridges: Vec<&spectral::SpectralPosition> = Vec::new();
|
||||
let mut outliers: Vec<&spectral::SpectralPosition> = Vec::new();
|
||||
|
||||
for pos in positions.iter().take(n) {
|
||||
match spectral::classify_position(pos) {
|
||||
"bridge" => bridges.push(pos),
|
||||
_ => outliers.push(pos),
|
||||
}
|
||||
}
|
||||
|
||||
if !bridges.is_empty() {
|
||||
println!("=== Bridges (between communities) ===");
|
||||
for pos in &bridges {
|
||||
println!(" [{:.2}/{:.2}] c{} → c{} {}",
|
||||
pos.outlier_score, pos.bridge_score,
|
||||
pos.community, pos.nearest_community, pos.key);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
println!("=== Top outliers (far from own community center) ===");
|
||||
for pos in positions.iter().take(n) {
|
||||
let class = spectral::classify_position(pos);
|
||||
println!(" {:>10} outlier={:.2} bridge={:.2} c{:<3} {}",
|
||||
class, pos.outlier_score, pos.bridge_score,
|
||||
pos.community, pos.key);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_spectral_suggest(n: usize) -> Result<(), String> {
|
||||
let store = store::Store::load()?;
|
||||
let emb = spectral::load_embedding()?;
|
||||
let g = store.build_graph();
|
||||
let communities = g.communities();
|
||||
|
||||
let min_degree = 3;
|
||||
let well_connected: std::collections::HashSet<&str> = emb.coords.keys()
|
||||
.filter(|k| g.degree(k) >= min_degree)
|
||||
.map(|k| k.as_str())
|
||||
.collect();
|
||||
|
||||
let filtered_emb = spectral::SpectralEmbedding {
|
||||
dims: emb.dims,
|
||||
eigenvalues: emb.eigenvalues.clone(),
|
||||
coords: emb.coords.iter()
|
||||
.filter(|(k, _)| well_connected.contains(k.as_str()))
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let mut linked: std::collections::HashSet<(String, String)> =
|
||||
std::collections::HashSet::new();
|
||||
for rel in &store.relations {
|
||||
linked.insert((rel.source_key.clone(), rel.target_key.clone()));
|
||||
linked.insert((rel.target_key.clone(), rel.source_key.clone()));
|
||||
}
|
||||
|
||||
eprintln!("Searching {} well-connected nodes (degree >= {})...",
|
||||
filtered_emb.coords.len(), min_degree);
|
||||
let pairs = spectral::unlinked_neighbors(&filtered_emb, &linked, n);
|
||||
|
||||
println!("{} closest unlinked pairs (candidates for extractor agents):", pairs.len());
|
||||
for (i, (k1, k2, dist)) in pairs.iter().enumerate() {
|
||||
let c1 = communities.get(k1)
|
||||
.map(|c| format!("c{}", c))
|
||||
.unwrap_or_else(|| "?".into());
|
||||
let c2 = communities.get(k2)
|
||||
.map(|c| format!("c{}", c))
|
||||
.unwrap_or_else(|| "?".into());
|
||||
let cross = if c1 != c2 { " [cross-community]" } else { "" };
|
||||
println!(" {:>2}. dist={:.4} {} ({}) ↔ {} ({}){}",
|
||||
i + 1, dist, k1, c1, k2, c2, cross);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_organize(term: &str, threshold: f32, key_only: bool, create_anchor: bool) -> Result<(), String> {
|
||||
let mut store = store::Store::load()?;
|
||||
|
||||
// Step 1: find all non-deleted nodes matching the term
|
||||
let term_lower = term.to_lowercase();
|
||||
let mut topic_nodes: Vec<(String, String)> = Vec::new(); // (key, content)
|
||||
|
||||
// Prefixes that indicate ephemeral/generated nodes to skip
|
||||
let skip_prefixes = ["journal#", "daily-", "weekly-", "monthly-", "_",
|
||||
"deep-index#", "facts-", "irc-history#"];
|
||||
|
||||
for (key, node) in &store.nodes {
|
||||
if node.deleted { continue; }
|
||||
let key_matches = key.to_lowercase().contains(&term_lower);
|
||||
let content_matches = !key_only && node.content.to_lowercase().contains(&term_lower);
|
||||
if !key_matches && !content_matches { continue; }
|
||||
if skip_prefixes.iter().any(|p| key.starts_with(p)) { continue; }
|
||||
topic_nodes.push((key.clone(), node.content.clone()));
|
||||
}
|
||||
|
||||
if topic_nodes.is_empty() {
|
||||
println!("No topic nodes found matching '{}'", term);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
topic_nodes.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
println!("=== Organize: '{}' ===", term);
|
||||
println!("Found {} topic nodes:\n", topic_nodes.len());
|
||||
for (key, content) in &topic_nodes {
|
||||
let lines = content.lines().count();
|
||||
let words = content.split_whitespace().count();
|
||||
println!(" {:60} {:>4} lines {:>5} words", key, lines, words);
|
||||
}
|
||||
|
||||
// Step 2: pairwise similarity
|
||||
let pairs = crate::similarity::pairwise_similar(&topic_nodes, threshold);
|
||||
|
||||
if pairs.is_empty() {
|
||||
println!("\nNo similar pairs above threshold {:.2}", threshold);
|
||||
} else {
|
||||
println!("\n=== Similar pairs (cosine > {:.2}) ===\n", threshold);
|
||||
for (a, b, sim) in &pairs {
|
||||
let a_words = topic_nodes.iter().find(|(k,_)| k == a)
|
||||
.map(|(_,c)| c.split_whitespace().count()).unwrap_or(0);
|
||||
let b_words = topic_nodes.iter().find(|(k,_)| k == b)
|
||||
.map(|(_,c)| c.split_whitespace().count()).unwrap_or(0);
|
||||
|
||||
println!(" [{:.3}] {} ({} words) ↔ {} ({} words)", sim, a, a_words, b, b_words);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: check connectivity within cluster
|
||||
let g = store.build_graph();
|
||||
println!("=== Connectivity ===\n");
|
||||
|
||||
// Pick hub by intra-cluster connectivity, not overall degree
|
||||
let cluster_keys: std::collections::HashSet<&str> = topic_nodes.iter()
|
||||
.filter(|(k,_)| store.nodes.contains_key(k.as_str()))
|
||||
.map(|(k,_)| k.as_str())
|
||||
.collect();
|
||||
|
||||
let mut best_hub: Option<(&str, usize)> = None;
|
||||
for key in &cluster_keys {
|
||||
let intra_degree = g.neighbor_keys(key).iter()
|
||||
.filter(|n| cluster_keys.contains(*n))
|
||||
.count();
|
||||
if best_hub.is_none() || intra_degree > best_hub.unwrap().1 {
|
||||
best_hub = Some((key, intra_degree));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((hub, deg)) = best_hub {
|
||||
println!(" Hub: {} (degree {})", hub, deg);
|
||||
let hub_nbrs = g.neighbor_keys(hub);
|
||||
|
||||
let mut unlinked = Vec::new();
|
||||
for (key, _) in &topic_nodes {
|
||||
if key == hub { continue; }
|
||||
if store.nodes.get(key.as_str()).is_none() { continue; }
|
||||
if !hub_nbrs.contains(key.as_str()) {
|
||||
unlinked.push(key.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if unlinked.is_empty() {
|
||||
println!(" All cluster nodes connected to hub ✓");
|
||||
} else {
|
||||
println!(" NOT linked to hub:");
|
||||
for key in &unlinked {
|
||||
println!(" {} → needs link to {}", key, hub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: anchor node
|
||||
if create_anchor {
|
||||
println!("\n=== Anchor node ===\n");
|
||||
if store.nodes.contains_key(term) && !store.nodes[term].deleted {
|
||||
println!(" Anchor '{}' already exists ✓", term);
|
||||
} else {
|
||||
let desc = format!("Anchor node for '{}' search term", term);
|
||||
store.upsert(term, &desc)?;
|
||||
let anchor_uuid = store.nodes.get(term).unwrap().uuid;
|
||||
for (key, _) in &topic_nodes {
|
||||
if store.nodes.get(key.as_str()).is_none() { continue; }
|
||||
let target_uuid = store.nodes[key.as_str()].uuid;
|
||||
let rel = store::new_relation(
|
||||
anchor_uuid, target_uuid,
|
||||
store::RelationType::Link, 0.8,
|
||||
term, key,
|
||||
);
|
||||
store.add_relation(rel)?;
|
||||
}
|
||||
println!(" Created anchor '{}' with {} links", term, topic_nodes.len());
|
||||
}
|
||||
}
|
||||
|
||||
store.save()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_interference(threshold: f32) -> Result<(), String> {
|
||||
let store = store::Store::load()?;
|
||||
let g = store.build_graph();
|
||||
let pairs = neuro::detect_interference(&store, &g, threshold);
|
||||
|
||||
if pairs.is_empty() {
|
||||
println!("No interfering pairs above threshold {:.2}", threshold);
|
||||
} else {
|
||||
println!("Interfering pairs (similarity > {:.2}, different communities):", threshold);
|
||||
for (a, b, sim) in &pairs {
|
||||
println!(" [{:.3}] {} ↔ {}", sim, a, b);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show communities sorted by isolation (most isolated first).
|
||||
/// Useful for finding poorly-integrated knowledge clusters that need
|
||||
/// organize agents aimed at them.
|
||||
pub fn cmd_communities(top_n: usize, min_size: usize) -> Result<(), String> {
|
||||
let store = store::Store::load()?;
|
||||
let g = store.build_graph();
|
||||
let infos = g.community_info();
|
||||
|
||||
let total = infos.len();
|
||||
let shown: Vec<_> = infos.into_iter()
|
||||
.filter(|c| c.size >= min_size)
|
||||
.take(top_n)
|
||||
.collect();
|
||||
|
||||
println!("{} communities total ({} with size >= {})\n",
|
||||
total, shown.len(), min_size);
|
||||
println!("{:<6} {:>5} {:>7} {:>7} members", "id", "size", "iso", "cross");
|
||||
println!("{}", "-".repeat(70));
|
||||
|
||||
for c in &shown {
|
||||
let preview: Vec<&str> = c.members.iter()
|
||||
.take(5)
|
||||
.map(|s| s.as_str())
|
||||
.collect();
|
||||
let more = if c.size > 5 {
|
||||
format!(" +{}", c.size - 5)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
println!("{:<6} {:>5} {:>6.0}% {:>7} {}{}",
|
||||
c.id, c.size, c.isolation * 100.0, c.cross_edges,
|
||||
preview.join(", "), more);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
229
src/cli/journal.rs
Normal file
229
src/cli/journal.rs
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
// cli/journal.rs — journal subcommand handlers
|
||||
|
||||
|
||||
pub fn cmd_tail(n: usize, full: bool) -> Result<(), String> {
|
||||
let path = crate::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);
|
||||
|
||||
// Read all entries, keep last N
|
||||
let mut entries: Vec<crate::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 = crate::store::Node::from_capnp_migrate(node_reader)?;
|
||||
entries.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
let start = entries.len().saturating_sub(n);
|
||||
for node in &entries[start..] {
|
||||
let ts = if node.timestamp > 0 && node.timestamp < 4_000_000_000 {
|
||||
crate::store::format_datetime(node.timestamp)
|
||||
} else {
|
||||
format!("(raw:{})", node.timestamp)
|
||||
};
|
||||
let del = if node.deleted { " [DELETED]" } else { "" };
|
||||
if full {
|
||||
eprintln!("--- {} (v{}) {} via {} w={:.3}{} ---",
|
||||
node.key, node.version, ts, node.provenance, node.weight, del);
|
||||
eprintln!("{}\n", node.content);
|
||||
} else {
|
||||
let preview = crate::util::first_n_chars(&node.content, 100).replace('\n', "\\n");
|
||||
eprintln!(" {} v{} w={:.2}{}",
|
||||
ts, node.version, node.weight, del);
|
||||
eprintln!(" {} via {}", node.key, node.provenance);
|
||||
if !preview.is_empty() {
|
||||
eprintln!(" {}", preview);
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn find_current_transcript() -> Option<String> {
|
||||
let projects = crate::config::get().projects_dir.clone();
|
||||
if !projects.exists() { return None; }
|
||||
|
||||
let mut newest: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
|
||||
if let Ok(dirs) = std::fs::read_dir(&projects) {
|
||||
for dir_entry in dirs.filter_map(|e| e.ok()) {
|
||||
if !dir_entry.path().is_dir() { continue; }
|
||||
if let Ok(files) = std::fs::read_dir(dir_entry.path()) {
|
||||
for f in files.filter_map(|e| e.ok()) {
|
||||
let p = f.path();
|
||||
if p.extension().map(|x| x == "jsonl").unwrap_or(false)
|
||||
&& let Ok(meta) = p.metadata()
|
||||
&& let Ok(mtime) = meta.modified()
|
||||
&& newest.as_ref().is_none_or(|(t, _)| mtime > *t) {
|
||||
newest = Some((mtime, p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
newest.map(|(_, p)| p.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn journal_tail_entries(store: &crate::store::Store, n: usize, full: bool) -> Result<(), String> {
|
||||
let date_re = regex::Regex::new(r"(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2})").unwrap();
|
||||
let key_date_re = regex::Regex::new(r"j-(\d{4}-\d{2}-\d{2}[t-]\d{2}-\d{2})").unwrap();
|
||||
|
||||
let normalize_date = |s: &str| -> String {
|
||||
let s = s.replace('t', "T");
|
||||
if s.len() >= 16 {
|
||||
format!("{}T{}", &s[..10], s[11..].replace('-', ":"))
|
||||
} else {
|
||||
s
|
||||
}
|
||||
};
|
||||
|
||||
let extract_sort = |node: &crate::store::Node| -> (i64, String) {
|
||||
if node.created_at > 0 {
|
||||
return (node.created_at, crate::store::format_datetime(node.created_at));
|
||||
}
|
||||
if let Some(caps) = key_date_re.captures(&node.key) {
|
||||
return (0, normalize_date(&caps[1]));
|
||||
}
|
||||
if let Some(caps) = date_re.captures(&node.content) {
|
||||
return (0, normalize_date(&caps[1]));
|
||||
}
|
||||
(node.timestamp, crate::store::format_datetime(node.timestamp))
|
||||
};
|
||||
|
||||
let mut journal: Vec<_> = store.nodes.values()
|
||||
.filter(|node| node.node_type == crate::store::NodeType::EpisodicSession)
|
||||
.collect();
|
||||
journal.sort_by(|a, b| {
|
||||
let (at, as_) = extract_sort(a);
|
||||
let (bt, bs) = extract_sort(b);
|
||||
if at > 0 && bt > 0 {
|
||||
at.cmp(&bt)
|
||||
} else {
|
||||
as_.cmp(&bs)
|
||||
}
|
||||
});
|
||||
|
||||
let skip = if journal.len() > n { journal.len() - n } else { 0 };
|
||||
for node in journal.iter().skip(skip) {
|
||||
let (_, ts) = extract_sort(node);
|
||||
let title = extract_title(&node.content);
|
||||
if full {
|
||||
println!("--- [{}] {} ---\n{}\n", ts, title, node.content);
|
||||
} else {
|
||||
println!("[{}] {}", ts, title);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn journal_tail_digests(store: &crate::store::Store, node_type: crate::store::NodeType, n: usize, full: bool) -> Result<(), String> {
|
||||
let mut digests: Vec<_> = store.nodes.values()
|
||||
.filter(|node| node.node_type == node_type)
|
||||
.collect();
|
||||
digests.sort_by(|a, b| {
|
||||
if a.timestamp > 0 && b.timestamp > 0 {
|
||||
a.timestamp.cmp(&b.timestamp)
|
||||
} else {
|
||||
a.key.cmp(&b.key)
|
||||
}
|
||||
});
|
||||
|
||||
let skip = if digests.len() > n { digests.len() - n } else { 0 };
|
||||
for node in digests.iter().skip(skip) {
|
||||
let label = &node.key;
|
||||
let title = extract_title(&node.content);
|
||||
if full {
|
||||
println!("--- [{}] {} ---\n{}\n", label, title, node.content);
|
||||
} else {
|
||||
println!("[{}] {}", label, title);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_journal_tail(n: usize, full: bool, level: u8) -> Result<(), String> {
|
||||
let store = crate::store::Store::load()?;
|
||||
|
||||
if level == 0 {
|
||||
journal_tail_entries(&store, n, full)
|
||||
} else {
|
||||
let node_type = match level {
|
||||
1 => crate::store::NodeType::EpisodicDaily,
|
||||
2 => crate::store::NodeType::EpisodicWeekly,
|
||||
_ => crate::store::NodeType::EpisodicMonthly,
|
||||
};
|
||||
journal_tail_digests(&store, node_type, n, full)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cmd_journal_write(text: &[String]) -> Result<(), String> {
|
||||
if text.is_empty() {
|
||||
return Err("journal-write requires text".into());
|
||||
}
|
||||
super::check_dry_run();
|
||||
let text = text.join(" ");
|
||||
|
||||
let timestamp = crate::store::format_datetime(crate::store::now_epoch());
|
||||
|
||||
let slug: String = text.split_whitespace()
|
||||
.take(6)
|
||||
.map(|w| w.to_lowercase()
|
||||
.chars().filter(|c| c.is_alphanumeric() || *c == '-')
|
||||
.collect::<String>())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
let slug = if slug.len() > 50 { &slug[..50] } else { &slug };
|
||||
|
||||
let key = format!("journal#j-{}-{}", timestamp.to_lowercase().replace(':', "-"), slug);
|
||||
|
||||
let content = format!("## {}\n\n{}", timestamp, text);
|
||||
|
||||
let source_ref = find_current_transcript();
|
||||
|
||||
let mut store = crate::store::Store::load()?;
|
||||
|
||||
let mut node = crate::store::new_node(&key, &content);
|
||||
node.node_type = crate::store::NodeType::EpisodicSession;
|
||||
node.provenance = "journal".to_string();
|
||||
if let Some(src) = source_ref {
|
||||
node.source_ref = src;
|
||||
}
|
||||
|
||||
store.upsert_node(node)?;
|
||||
store.save()?;
|
||||
|
||||
let word_count = text.split_whitespace().count();
|
||||
println!("Appended entry at {} ({} words)", timestamp, word_count);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
fn extract_title(content: &str) -> String {
|
||||
let date_re = regex::Regex::new(r"(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2})").unwrap();
|
||||
for line in content.lines() {
|
||||
let stripped = line.trim();
|
||||
if stripped.is_empty() { continue; }
|
||||
if date_re.is_match(stripped) && stripped.len() < 25 { continue; }
|
||||
if let Some(h) = stripped.strip_prefix("## ") {
|
||||
return h.to_string();
|
||||
} else if let Some(h) = stripped.strip_prefix("# ") {
|
||||
return h.to_string();
|
||||
} else {
|
||||
return crate::util::truncate(stripped, 67, "...");
|
||||
}
|
||||
}
|
||||
String::from("(untitled)")
|
||||
}
|
||||
|
||||
315
src/cli/misc.rs
Normal file
315
src/cli/misc.rs
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
// cli/misc.rs — misc subcommand handlers
|
||||
|
||||
|
||||
pub fn cmd_search(terms: &[String], pipeline_args: &[String], expand: bool, full: bool, debug: bool, fuzzy: bool, content: bool) -> Result<(), String> {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
// When running inside an agent session, exclude already-surfaced nodes
|
||||
let seen = crate::memory_search::Session::from_env()
|
||||
.map(|s| s.seen())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Parse pipeline stages (unified: algorithms, filters, transforms, generators)
|
||||
let stages: Vec<crate::search::Stage> = if pipeline_args.is_empty() {
|
||||
vec![crate::search::Stage::Algorithm(crate::search::AlgoStage::parse("spread").unwrap())]
|
||||
} else {
|
||||
pipeline_args.iter()
|
||||
.map(|a| crate::search::Stage::parse(a))
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
};
|
||||
|
||||
// Check if pipeline needs full Store (has filters/transforms/generators)
|
||||
let needs_store = stages.iter().any(|s| !matches!(s, crate::search::Stage::Algorithm(_)));
|
||||
// Check if pipeline starts with a generator (doesn't need seed terms)
|
||||
let has_generator = stages.first().map(|s| matches!(s, crate::search::Stage::Generator(_))).unwrap_or(false);
|
||||
|
||||
if terms.is_empty() && !has_generator {
|
||||
return Err("search requires terms or a generator stage (e.g. 'all')".into());
|
||||
}
|
||||
|
||||
let query: String = terms.join(" ");
|
||||
|
||||
if debug {
|
||||
let names: Vec<String> = stages.iter().map(|s| format!("{}", s)).collect();
|
||||
println!("[search] pipeline: {}", names.join(" → "));
|
||||
}
|
||||
|
||||
let max_results = if expand { 15 } else { 5 };
|
||||
|
||||
if needs_store {
|
||||
// Full Store path — needed for filter/transform/generator stages
|
||||
let store = crate::store::Store::load()?;
|
||||
let graph = store.build_graph();
|
||||
|
||||
let seeds = if has_generator {
|
||||
vec![] // generator will produce its own result set
|
||||
} else {
|
||||
let terms_map: BTreeMap<String, f64> = query.split_whitespace()
|
||||
.map(|t| (t.to_lowercase(), 1.0))
|
||||
.collect();
|
||||
let (seeds, _) = crate::search::match_seeds_opts(&terms_map, &store, fuzzy, content);
|
||||
seeds
|
||||
};
|
||||
|
||||
let raw = crate::search::run_query(&stages, seeds, &graph, &store, debug, max_results);
|
||||
|
||||
let raw: Vec<_> = raw.into_iter()
|
||||
.filter(|(key, _)| !seen.contains(key))
|
||||
.collect();
|
||||
|
||||
if raw.is_empty() {
|
||||
eprintln!("No results");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for (i, (key, score)) in raw.iter().enumerate().take(max_results) {
|
||||
let weight = store.nodes.get(key).map(|n| n.weight).unwrap_or(0.0);
|
||||
println!("{:2}. [{:.2}/{:.2}] {}", i + 1, score, weight, key);
|
||||
if full
|
||||
&& let Some(node) = store.nodes.get(key) {
|
||||
println!();
|
||||
for line in node.content.lines() {
|
||||
println!(" {}", line);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fast MmapView path — algorithm-only pipeline
|
||||
use crate::store::StoreView;
|
||||
let view = crate::store::AnyView::load()?;
|
||||
let graph = crate::graph::build_graph_fast(&view);
|
||||
|
||||
let terms_map: BTreeMap<String, f64> = query.split_whitespace()
|
||||
.map(|t| (t.to_lowercase(), 1.0))
|
||||
.collect();
|
||||
let (seeds, direct_hits) = crate::search::match_seeds_opts(&terms_map, &view, fuzzy, content);
|
||||
|
||||
if seeds.is_empty() {
|
||||
eprintln!("No results for '{}'", query);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if debug {
|
||||
println!("[search] {} seeds from query '{}'", seeds.len(), query);
|
||||
}
|
||||
|
||||
// Extract AlgoStages from the unified stages
|
||||
let algo_stages: Vec<&crate::search::AlgoStage> = stages.iter()
|
||||
.filter_map(|s| match s {
|
||||
crate::search::Stage::Algorithm(a) => Some(a),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let algo_owned: Vec<crate::search::AlgoStage> = algo_stages.into_iter().cloned().collect();
|
||||
|
||||
let raw = crate::search::run_pipeline(&algo_owned, seeds, &graph, &view, debug, max_results);
|
||||
|
||||
let results: Vec<crate::search::SearchResult> = raw.into_iter()
|
||||
.filter(|(key, _)| !seen.contains(key))
|
||||
.map(|(key, activation)| {
|
||||
let is_direct = direct_hits.contains(&key);
|
||||
crate::search::SearchResult { key, activation, is_direct, snippet: None }
|
||||
})
|
||||
.collect();
|
||||
|
||||
if results.is_empty() {
|
||||
eprintln!("No results for '{}'", query);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Log retrieval
|
||||
crate::store::Store::log_retrieval_static(&query,
|
||||
&results.iter().map(|r| r.key.clone()).collect::<Vec<_>>());
|
||||
|
||||
let bump_keys: Vec<&str> = results.iter().take(max_results).map(|r| r.key.as_str()).collect();
|
||||
let _ = crate::lookups::bump_many(&bump_keys);
|
||||
|
||||
for (i, r) in results.iter().enumerate().take(max_results) {
|
||||
let marker = if r.is_direct { "→" } else { " " };
|
||||
let weight = view.node_weight(&r.key);
|
||||
println!("{}{:2}. [{:.2}/{:.2}] {}", marker, i + 1, r.activation, weight, r.key);
|
||||
if full
|
||||
&& let Some(content) = view.node_content(&r.key) {
|
||||
println!();
|
||||
for line in content.lines() {
|
||||
println!(" {}", line);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_status() -> Result<(), String> {
|
||||
// If stdout is a tty and daemon is running, launch TUI
|
||||
if std::io::IsTerminal::is_terminal(&std::io::stdout()) {
|
||||
// Try TUI first — falls back if daemon not running
|
||||
match crate::tui::run_tui() {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(_) => {} // fall through to text output
|
||||
}
|
||||
}
|
||||
|
||||
let store = crate::store::Store::load()?;
|
||||
let g = store.build_graph();
|
||||
|
||||
let mut type_counts = std::collections::HashMap::new();
|
||||
for node in store.nodes.values() {
|
||||
*type_counts.entry(format!("{:?}", node.node_type)).or_insert(0usize) += 1;
|
||||
}
|
||||
let mut types: Vec<_> = type_counts.iter().collect();
|
||||
types.sort_by_key(|(_, c)| std::cmp::Reverse(**c));
|
||||
|
||||
println!("Nodes: {} Relations: {}", store.nodes.len(), store.relations.len());
|
||||
print!("Types:");
|
||||
for (t, c) in &types {
|
||||
let label = match t.as_str() {
|
||||
"Semantic" => "semantic",
|
||||
"EpisodicSession" | "EpisodicDaily" | "EpisodicWeekly" | "EpisodicMonthly"
|
||||
=> "episodic",
|
||||
_ => t,
|
||||
};
|
||||
print!(" {}={}", label, c);
|
||||
}
|
||||
println!();
|
||||
println!("Graph edges: {} Communities: {}",
|
||||
g.edge_count(), g.community_count());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_log() -> Result<(), String> {
|
||||
let store = crate::store::Store::load()?;
|
||||
for event in store.retrieval_log.iter().rev().take(20) {
|
||||
println!("[{}] q=\"{}\" → {} results",
|
||||
event.timestamp, event.query, event.results.len());
|
||||
for r in &event.results {
|
||||
println!(" {}", r);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_params() -> Result<(), String> {
|
||||
let store = crate::store::Store::load()?;
|
||||
println!("decay_factor: {}", store.params.decay_factor);
|
||||
println!("use_boost: {}", store.params.use_boost);
|
||||
println!("prune_threshold: {}", store.params.prune_threshold);
|
||||
println!("edge_decay: {}", store.params.edge_decay);
|
||||
println!("max_hops: {}", store.params.max_hops);
|
||||
println!("min_activation: {}", store.params.min_activation);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_query(expr: &[String]) -> Result<(), String> {
|
||||
if expr.is_empty() {
|
||||
return Err("query requires an expression (try: poc-memory query --help)".into());
|
||||
}
|
||||
|
||||
let query_str = expr.join(" ");
|
||||
let store = crate::store::Store::load()?;
|
||||
let graph = store.build_graph();
|
||||
crate::query_parser::run_query(&store, &graph, &query_str)
|
||||
}
|
||||
|
||||
pub fn get_group_content(group: &crate::config::ContextGroup, store: &crate::store::Store, cfg: &crate::config::Config) -> Vec<(String, String)> {
|
||||
match group.source {
|
||||
crate::config::ContextSource::Journal => {
|
||||
let mut entries = Vec::new();
|
||||
let now = crate::store::now_epoch();
|
||||
let window: i64 = cfg.journal_days as i64 * 24 * 3600;
|
||||
let cutoff = now - window;
|
||||
let key_date_re = regex::Regex::new(r"j-(\d{4}-\d{2}-\d{2})").unwrap();
|
||||
|
||||
let journal_ts = |n: &crate::store::Node| -> i64 {
|
||||
if n.created_at > 0 { return n.created_at; }
|
||||
if let Some(caps) = key_date_re.captures(&n.key) {
|
||||
use chrono::{NaiveDate, TimeZone, Local};
|
||||
if let Ok(d) = NaiveDate::parse_from_str(&caps[1], "%Y-%m-%d")
|
||||
&& let Some(dt) = Local.from_local_datetime(&d.and_hms_opt(0, 0, 0).unwrap()).earliest() {
|
||||
return dt.timestamp();
|
||||
}
|
||||
}
|
||||
n.timestamp
|
||||
};
|
||||
|
||||
let mut journal_nodes: Vec<_> = store.nodes.values()
|
||||
.filter(|n| n.node_type == crate::store::NodeType::EpisodicSession && journal_ts(n) >= cutoff)
|
||||
.collect();
|
||||
journal_nodes.sort_by_key(|n| journal_ts(n));
|
||||
|
||||
let max = cfg.journal_max;
|
||||
let skip = journal_nodes.len().saturating_sub(max);
|
||||
for node in journal_nodes.iter().skip(skip) {
|
||||
entries.push((node.key.clone(), node.content.clone()));
|
||||
}
|
||||
entries
|
||||
}
|
||||
crate::config::ContextSource::File => {
|
||||
group.keys.iter().filter_map(|key| {
|
||||
let content = std::fs::read_to_string(cfg.data_dir.join(key)).ok()?;
|
||||
if content.trim().is_empty() { return None; }
|
||||
Some((key.clone(), content.trim().to_string()))
|
||||
}).collect()
|
||||
}
|
||||
crate::config::ContextSource::Store => {
|
||||
group.keys.iter().filter_map(|key| {
|
||||
let content = store.render_file(key)?;
|
||||
if content.trim().is_empty() { return None; }
|
||||
Some((key.clone(), content.trim().to_string()))
|
||||
}).collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cmd_load_context(stats: bool) -> Result<(), String> {
|
||||
let cfg = crate::config::get();
|
||||
let store = crate::store::Store::load()?;
|
||||
|
||||
if stats {
|
||||
let mut total_words = 0;
|
||||
let mut total_entries = 0;
|
||||
println!("{:<25} {:>6} {:>8}", "GROUP", "ITEMS", "WORDS");
|
||||
println!("{}", "-".repeat(42));
|
||||
|
||||
for group in &cfg.context_groups {
|
||||
let entries = get_group_content(group, &store, &cfg);
|
||||
let words: usize = entries.iter()
|
||||
.map(|(_, c)| c.split_whitespace().count())
|
||||
.sum();
|
||||
let count = entries.len();
|
||||
println!("{:<25} {:>6} {:>8}", group.label, count, words);
|
||||
total_words += words;
|
||||
total_entries += count;
|
||||
}
|
||||
|
||||
println!("{}", "-".repeat(42));
|
||||
println!("{:<25} {:>6} {:>8}", "TOTAL", total_entries, total_words);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("=== MEMORY SYSTEM ({}) ===", cfg.assistant_name);
|
||||
println!();
|
||||
|
||||
for group in &cfg.context_groups {
|
||||
let entries = get_group_content(group, &store, &cfg);
|
||||
if !entries.is_empty() && group.source == crate::config::ContextSource::Journal {
|
||||
println!("--- recent journal entries ({}/{}) ---",
|
||||
entries.len(), cfg.journal_max);
|
||||
}
|
||||
for (key, content) in entries {
|
||||
if group.source == crate::config::ContextSource::Journal {
|
||||
println!("## {}", key);
|
||||
} else {
|
||||
println!("--- {} ({}) ---", key, group.label);
|
||||
}
|
||||
println!("{}\n", content);
|
||||
}
|
||||
}
|
||||
|
||||
println!("=== END MEMORY LOAD ===");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
18
src/cli/mod.rs
Normal file
18
src/cli/mod.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// cli/ — command-line interface handlers
|
||||
//
|
||||
// Split from main.rs for readability. Each module handles a group
|
||||
// of related subcommands.
|
||||
|
||||
pub mod graph;
|
||||
pub mod node;
|
||||
pub mod agent;
|
||||
pub mod admin;
|
||||
pub mod journal;
|
||||
pub mod misc;
|
||||
|
||||
/// Exit silently if POC_MEMORY_DRY_RUN=1.
|
||||
pub fn check_dry_run() {
|
||||
if std::env::var("POC_MEMORY_DRY_RUN").is_ok_and(|v| v == "1" || v == "true") {
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
504
src/cli/node.rs
Normal file
504
src/cli/node.rs
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
// cli/node.rs — node subcommand handlers
|
||||
//
|
||||
// render, write, used, wrong, not-relevant, not-useful, gap,
|
||||
// node-delete, node-rename, history, list-keys, list-edges,
|
||||
// dump-json, lookup-bump, lookups.
|
||||
|
||||
use crate::store;
|
||||
|
||||
pub fn cmd_used(key: &[String]) -> Result<(), String> {
|
||||
if key.is_empty() {
|
||||
return Err("used requires a key".into());
|
||||
}
|
||||
super::check_dry_run();
|
||||
let key = key.join(" ");
|
||||
let mut store = store::Store::load()?;
|
||||
let resolved = store.resolve_key(&key)?;
|
||||
store.mark_used(&resolved);
|
||||
|
||||
// Also strengthen edges to this node — conscious-tier delta.
|
||||
const DELTA: f32 = 0.01;
|
||||
let mut strengthened = 0;
|
||||
for rel in &mut store.relations {
|
||||
if rel.deleted { continue; }
|
||||
if rel.source_key == resolved || rel.target_key == resolved {
|
||||
let old = rel.strength;
|
||||
rel.strength = (rel.strength + DELTA).clamp(0.05, 0.95);
|
||||
if (rel.strength - old).abs() > 0.001 {
|
||||
rel.version += 1;
|
||||
strengthened += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
store.save()?;
|
||||
println!("Marked '{}' as used (strengthened {} edges)", resolved, strengthened);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_wrong(key: &str, context: &[String]) -> Result<(), String> {
|
||||
let ctx = if context.is_empty() { None } else { Some(context.join(" ")) };
|
||||
super::check_dry_run();
|
||||
let mut store = store::Store::load()?;
|
||||
let resolved = store.resolve_key(key)?;
|
||||
store.mark_wrong(&resolved, ctx.as_deref());
|
||||
store.save()?;
|
||||
println!("Marked '{}' as wrong", resolved);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_not_relevant(key: &str) -> Result<(), String> {
|
||||
let mut store = store::Store::load()?;
|
||||
let resolved = store.resolve_key(key)?;
|
||||
|
||||
// Weaken all edges to this node — it was routed to incorrectly.
|
||||
// Conscious-tier delta: 0.01 per edge.
|
||||
const DELTA: f32 = -0.01;
|
||||
let mut adjusted = 0;
|
||||
for rel in &mut store.relations {
|
||||
if rel.deleted { continue; }
|
||||
if rel.source_key == resolved || rel.target_key == resolved {
|
||||
let old = rel.strength;
|
||||
rel.strength = (rel.strength + DELTA).clamp(0.05, 0.95);
|
||||
if (rel.strength - old).abs() > 0.001 {
|
||||
rel.version += 1;
|
||||
adjusted += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
store.save()?;
|
||||
println!("Not relevant: '{}' — weakened {} edges by {}", resolved, adjusted, DELTA.abs());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_not_useful(key: &str) -> Result<(), String> {
|
||||
// no args to validate
|
||||
super::check_dry_run();
|
||||
let mut store = store::Store::load()?;
|
||||
let resolved = store.resolve_key(key)?;
|
||||
// Same as wrong but with clearer semantics: node content is bad, edges are fine.
|
||||
store.mark_wrong(&resolved, Some("not-useful"));
|
||||
store.save()?;
|
||||
println!("Not useful: '{}' — node weight reduced", resolved);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_weight_set(key: &str, weight: f32) -> Result<(), String> {
|
||||
super::check_dry_run();
|
||||
let mut store = store::Store::load()?;
|
||||
let resolved = store.resolve_key(key)?;
|
||||
let weight = weight.clamp(0.01, 1.0);
|
||||
if let Some(node) = store.nodes.get_mut(&resolved) {
|
||||
let old = node.weight;
|
||||
node.weight = weight;
|
||||
println!("Weight: {} {:.2} → {:.2}", resolved, old, weight);
|
||||
store.save()?;
|
||||
} else {
|
||||
return Err(format!("Node not found: {}", resolved));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_gap(description: &[String]) -> Result<(), String> {
|
||||
if description.is_empty() {
|
||||
return Err("gap requires a description".into());
|
||||
}
|
||||
super::check_dry_run();
|
||||
let desc = description.join(" ");
|
||||
let mut store = store::Store::load()?;
|
||||
store.record_gap(&desc);
|
||||
store.save()?;
|
||||
println!("Recorded gap: {}", desc);
|
||||
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 mut store = store::Store::load()?;
|
||||
let resolved = store.resolve_key(&key)?;
|
||||
store.delete_node(&resolved)?;
|
||||
store.save()?;
|
||||
println!("Deleted '{}'", resolved);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_node_rename(old_key: &str, new_key: &str) -> Result<(), String> {
|
||||
// args are positional, always valid if present
|
||||
super::check_dry_run();
|
||||
let mut store = store::Store::load()?;
|
||||
let old_resolved = store.resolve_key(old_key)?;
|
||||
store.rename_node(&old_resolved, new_key)?;
|
||||
store.save()?;
|
||||
println!("Renamed '{}' → '{}'", old_resolved, new_key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render a node to a string: content + deduped footer links.
|
||||
/// Used by both the CLI command and agent placeholders.
|
||||
pub fn render_node(store: &store::Store, key: &str) -> Option<String> {
|
||||
let node = store.nodes.get(key)?;
|
||||
let mut out = node.content.clone();
|
||||
|
||||
// Build neighbor lookup: key → strength
|
||||
let mut neighbor_strengths: std::collections::HashMap<&str, f32> = std::collections::HashMap::new();
|
||||
for r in &store.relations {
|
||||
if r.deleted { continue; }
|
||||
if r.source_key == key {
|
||||
let e = neighbor_strengths.entry(&r.target_key).or_insert(0.0);
|
||||
*e = e.max(r.strength);
|
||||
} else if r.target_key == key {
|
||||
let e = neighbor_strengths.entry(&r.source_key).or_insert(0.0);
|
||||
*e = e.max(r.strength);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect which neighbors are already referenced inline in the content.
|
||||
let mut inline_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
for nbr_key in neighbor_strengths.keys() {
|
||||
if node.content.contains(nbr_key) {
|
||||
inline_keys.insert(nbr_key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Footer: only show links NOT already referenced inline
|
||||
let mut footer_neighbors: Vec<(&str, f32)> = neighbor_strengths.iter()
|
||||
.filter(|(k, _)| !inline_keys.contains(**k))
|
||||
.map(|(k, s)| (*k, *s))
|
||||
.collect();
|
||||
|
||||
if !footer_neighbors.is_empty() {
|
||||
footer_neighbors.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
let total = footer_neighbors.len();
|
||||
let shown: Vec<String> = footer_neighbors.iter().take(15)
|
||||
.map(|(k, s)| format!("({:.2}) `poc-memory render {}`", s, k))
|
||||
.collect();
|
||||
out.push_str("\n\n---\nLinks:");
|
||||
for link in &shown {
|
||||
out.push_str(&format!("\n {}", link));
|
||||
}
|
||||
if total > 15 {
|
||||
out.push_str(&format!("\n ... and {} more (`poc-memory graph link {}`)", total - 15, key));
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub fn cmd_render(key: &[String]) -> Result<(), String> {
|
||||
if key.is_empty() {
|
||||
return Err("render requires a key".into());
|
||||
}
|
||||
let key = key.join(" ");
|
||||
let store = store::Store::load()?;
|
||||
let bare = store::strip_md_suffix(&key);
|
||||
|
||||
let rendered = render_node(&store, &bare)
|
||||
.ok_or_else(|| format!("Node not found: {}", bare))?;
|
||||
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 = std::path::PathBuf::from("/tmp/claude-memory-search");
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Check content for common inline reference problems:
|
||||
/// - `poc-memory render key` embedded in content (render artifact, should be just `key`)
|
||||
/// - `→ something` where something doesn't parse as a valid key
|
||||
/// - `key` referencing a node that doesn't exist
|
||||
fn validate_inline_refs(content: &str, store: &store::Store) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
for line in content.lines() {
|
||||
// Check for render commands embedded in content
|
||||
if line.contains("poc-memory render ") && !line.starts_with(" ") {
|
||||
// Skip lines that look like CLI documentation/examples
|
||||
if !line.contains("CLI") && !line.contains("equivalent") && !line.contains("tool") {
|
||||
warnings.push(format!(
|
||||
"render command in content (should be just `key`): {}",
|
||||
line.chars().take(80).collect::<String>(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Check → references
|
||||
if let Some(rest) = line.trim().strip_prefix("→ ") {
|
||||
// Extract the key (may be backtick-quoted)
|
||||
let key = rest.trim().trim_matches('`').trim();
|
||||
if !key.is_empty() && !store.nodes.contains_key(key) {
|
||||
// Might be a poc-memory render artifact
|
||||
if let Some(k) = key.strip_prefix("poc-memory render ") {
|
||||
warnings.push(format!(
|
||||
"render artifact in → reference (use `{}` not `poc-memory render {}`)", k, k,
|
||||
));
|
||||
} else if key.contains(' ') {
|
||||
warnings.push(format!(
|
||||
"→ reference doesn't look like a key: → {}", key,
|
||||
));
|
||||
}
|
||||
// Don't warn about missing keys — the target might be created later
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warnings
|
||||
}
|
||||
|
||||
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 raw_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 mut store = store::Store::load()?;
|
||||
let key = store.resolve_key(&raw_key).unwrap_or(raw_key);
|
||||
|
||||
// Validate inline references: warn about render commands embedded
|
||||
// in content (should be just `key`) and broken references.
|
||||
let warnings = validate_inline_refs(&content, &store);
|
||||
for w in &warnings {
|
||||
eprintln!("warning: {}", w);
|
||||
}
|
||||
|
||||
let result = store.upsert(&key, &content)?;
|
||||
match result {
|
||||
"unchanged" => println!("No change: '{}'", key),
|
||||
"updated" => println!("Updated '{}' (v{})", key, store.nodes[&key].version),
|
||||
_ => println!("Created '{}'", key),
|
||||
}
|
||||
if result != "unchanged" {
|
||||
store.save()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_edit(key: &[String]) -> Result<(), String> {
|
||||
if key.is_empty() {
|
||||
return Err("edit 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.clone());
|
||||
|
||||
let content = store.nodes.get(&key)
|
||||
.map(|n| n.content.clone())
|
||||
.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());
|
||||
}
|
||||
|
||||
drop(store);
|
||||
let mut store = store::Store::load()?;
|
||||
let result = store.upsert(&key, &new_content)?;
|
||||
match result {
|
||||
"unchanged" => println!("No change: '{}'", key),
|
||||
"updated" => println!("Updated '{}' (v{})", key, store.nodes[&key].version),
|
||||
_ => println!("Created '{}'", key),
|
||||
}
|
||||
if result != "unchanged" {
|
||||
store.save()?;
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue