The hook's Session is not the same as poc-agent's session concept. Rename to avoid confusion now that poc-agent will create HookSessions to call into the agent cycle. Co-Authored-By: Proof of Concept <poc@bcachefs.org>
359 lines
14 KiB
Rust
359 lines
14 KiB
Rust
// 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::HookSession::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.identity_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()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// MCP tool schema with CLI routing info.
|
|
///
|
|
/// Each tool definition includes:
|
|
/// - name, description, inputSchema (standard MCP)
|
|
/// - cli: the CLI args prefix to invoke this tool
|
|
/// - stdin_param: which parameter (if any) should be sent via stdin
|
|
///
|
|
/// Tools with cli=null are agent-internal (not exposed via MCP CLI bridge).
|
|
pub fn cmd_mcp_schema() -> Result<(), String> {
|
|
use serde_json::json;
|
|
|
|
// Map tool names to CLI args + stdin param.
|
|
// Tools not listed here are skipped (agent-internal).
|
|
let cli_map: std::collections::HashMap<&str, (Vec<&str>, Option<&str>)> = [
|
|
("memory_render", (vec!["render"], None)),
|
|
("memory_write", (vec!["write"], Some("content"))),
|
|
("memory_search", (vec!["graph", "spread"], None)),
|
|
("memory_links", (vec!["graph", "link"], None)),
|
|
("memory_link_set", (vec!["graph", "link-set"], None)),
|
|
("memory_link_add", (vec!["graph", "link-add"], None)),
|
|
("memory_used", (vec!["used"], None)),
|
|
("memory_weight_set", (vec!["weight-set"], None)),
|
|
("memory_rename", (vec!["node", "rename"], None)),
|
|
("memory_query", (vec!["query"], None)),
|
|
].into_iter().collect();
|
|
|
|
let defs = crate::thought::memory::definitions();
|
|
let json_out: Vec<_> = defs.iter().filter_map(|d| {
|
|
let name = &d.function.name;
|
|
let (cli, stdin_param) = cli_map.get(name.as_str())?;
|
|
Some(json!({
|
|
"name": name,
|
|
"description": d.function.description,
|
|
"inputSchema": d.function.parameters,
|
|
"cli": cli,
|
|
"stdin_param": stdin_param,
|
|
}))
|
|
}).collect();
|
|
|
|
println!("{}", serde_json::to_string_pretty(&json_out)
|
|
.map_err(|e| e.to_string())?);
|
|
Ok(())
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|