diff --git a/poc-memory/src/cli/journal.rs b/poc-memory/src/cli/journal.rs new file mode 100644 index 0000000..a621a4a --- /dev/null +++ b/poc-memory/src/cli/journal.rs @@ -0,0 +1,55 @@ +// cli/journal.rs — journal subcommand handlers + +use crate::store; +use crate::store::StoreView; + +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 = Vec::new(); + while let Ok(msg) = capnp::serialize::read_message(&mut reader, capnp::message::ReaderOptions::new()) { + let log = msg.get_root::() + .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(()) +} + diff --git a/poc-memory/src/cli/misc.rs b/poc-memory/src/cli/misc.rs new file mode 100644 index 0000000..f21b0f6 --- /dev/null +++ b/poc-memory/src/cli/misc.rs @@ -0,0 +1,210 @@ +// cli/misc.rs — misc subcommand handlers + +use crate::store; +use crate::store::StoreView; + +pub fn cmd_search(terms: &[String], pipeline_args: &[String], expand: bool, full: bool, debug: bool, fuzzy: bool, content: bool) -> Result<(), String> { + use crate::store::StoreView; + use std::collections::BTreeMap; + + // Parse pipeline stages (unified: algorithms, filters, transforms, generators) + let stages: Vec = 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::, _>>()? + }; + + // 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 = 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 = 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); + + 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 { + if let Some(node) = store.nodes.get(key) { + println!(); + for line in node.content.lines() { + println!(" {}", line); + } + println!(); + } + } + } + } else { + // Fast MmapView path — algorithm-only pipeline + let view = crate::store::AnyView::load()?; + let graph = crate::graph::build_graph_fast(&view); + + let terms_map: BTreeMap = 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 = algo_stages.into_iter().cloned().collect(); + + let raw = crate::search::run_pipeline(&algo_owned, seeds, &graph, &view, debug, max_results); + + let results: Vec = raw.into_iter() + .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::>()); + + 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 { + if 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) +} + diff --git a/poc-memory/src/cli/mod.rs b/poc-memory/src/cli/mod.rs index 8d180ce..4d111d8 100644 --- a/poc-memory/src/cli/mod.rs +++ b/poc-memory/src/cli/mod.rs @@ -7,3 +7,5 @@ pub mod graph; pub mod node; pub mod agent; pub mod admin; +pub mod journal; +pub mod misc; diff --git a/poc-memory/src/main.rs b/poc-memory/src/main.rs index 5d5cd47..a1b36fa 100644 --- a/poc-memory/src/main.rs +++ b/poc-memory/src/main.rs @@ -731,13 +731,13 @@ fn main() { let result = match cli.command { // Core Command::Search { query, pipeline, expand, full, debug, fuzzy, content } - => cmd_search(&query, &pipeline, expand, full, debug, fuzzy, content), + => cli::misc::cmd_search(&query, &pipeline, expand, full, debug, fuzzy, content), Command::Render { key } => cli::node::cmd_render(&key), Command::Write { key } => cli::node::cmd_write(&key), Command::History { full, key } => cli::node::cmd_history(&key, full), - Command::Tail { n, full } => cmd_tail(n, full), - Command::Status => cmd_status(), - Command::Query { expr } => cmd_query(&expr), + Command::Tail { n, full } => cli::journal::cmd_tail(n, full), + Command::Status => cli::misc::cmd_status(), + Command::Query { expr } => cli::misc::cmd_query(&expr), Command::Used { key } => cli::node::cmd_used(&key), Command::Wrong { key, context } => cli::node::cmd_wrong(&key, &context), Command::NotRelevant { key } => cli::node::cmd_not_relevant(&key), @@ -825,8 +825,8 @@ fn main() { AdminCmd::Import { files } => cli::admin::cmd_import(&files), AdminCmd::Export { files, all } => cli::admin::cmd_export(&files, all), AdminCmd::LoadContext { stats } => cmd_load_context(stats), - AdminCmd::Log => cmd_log(), - AdminCmd::Params => cmd_params(), + AdminCmd::Log => cli::misc::cmd_log(), + AdminCmd::Params => cli::misc::cmd_params(), AdminCmd::LookupBump { keys } => cli::node::cmd_lookup_bump(&keys), AdminCmd::Lookups { date } => cli::node::cmd_lookups(date.as_deref()), }, @@ -840,140 +840,6 @@ fn main() { // ── Command implementations ───────────────────────────────────────── -fn cmd_search(terms: &[String], pipeline_args: &[String], expand: bool, full: bool, debug: bool, fuzzy: bool, content: bool) -> Result<(), String> { - use store::StoreView; - use std::collections::BTreeMap; - - // Parse pipeline stages (unified: algorithms, filters, transforms, generators) - let stages: Vec = if pipeline_args.is_empty() { - vec![search::Stage::Algorithm(search::AlgoStage::parse("spread").unwrap())] - } else { - pipeline_args.iter() - .map(|a| search::Stage::parse(a)) - .collect::, _>>()? - }; - - // Check if pipeline needs full Store (has filters/transforms/generators) - let needs_store = stages.iter().any(|s| !matches!(s, search::Stage::Algorithm(_))); - // Check if pipeline starts with a generator (doesn't need seed terms) - let has_generator = stages.first().map(|s| matches!(s, 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 = 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 = 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 = query.split_whitespace() - .map(|t| (t.to_lowercase(), 1.0)) - .collect(); - let (seeds, _) = search::match_seeds_opts(&terms_map, &store, fuzzy, content); - seeds - }; - - let raw = search::run_query(&stages, seeds, &graph, &store, debug, max_results); - - 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 { - if let Some(node) = store.nodes.get(key) { - println!(); - for line in node.content.lines() { - println!(" {}", line); - } - println!(); - } - } - } - } else { - // Fast MmapView path — algorithm-only pipeline - let view = store::AnyView::load()?; - let graph = graph::build_graph_fast(&view); - - let terms_map: BTreeMap = query.split_whitespace() - .map(|t| (t.to_lowercase(), 1.0)) - .collect(); - let (seeds, direct_hits) = 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<&search::AlgoStage> = stages.iter() - .filter_map(|s| match s { - search::Stage::Algorithm(a) => Some(a), - _ => None, - }) - .collect(); - let algo_owned: Vec = algo_stages.into_iter().cloned().collect(); - - let raw = search::run_pipeline(&algo_owned, seeds, &graph, &view, debug, max_results); - - let results: Vec = raw.into_iter() - .map(|(key, activation)| { - let is_direct = direct_hits.contains(&key); - search::SearchResult { key, activation, is_direct, snippet: None } - }) - .collect(); - - if results.is_empty() { - eprintln!("No results for '{}'", query); - return Ok(()); - } - - // Log retrieval - store::Store::log_retrieval_static(&query, - &results.iter().map(|r| r.key.clone()).collect::>()); - - let bump_keys: Vec<&str> = results.iter().take(max_results).map(|r| r.key.as_str()).collect(); - let _ = 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 { - if let Some(content) = view.node_content(&r.key) { - println!(); - for line in content.lines() { - println!(" {}", line); - } - println!(); - } - } - } - } - - Ok(()) -} - fn install_default_file(data_dir: &std::path::Path, name: &str, content: &str) -> Result<(), String> { let path = data_dir.join(name); if !path.exists() { @@ -984,66 +850,6 @@ fn install_default_file(data_dir: &std::path::Path, name: &str, content: &str) - Ok(()) } -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 tui::run_tui() { - Ok(()) => return Ok(()), - Err(_) => {} // fall through to text output - } - } - - let store = 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(()) -} - -fn cmd_log() -> Result<(), String> { - let store = 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(()) -} - -fn cmd_params() -> Result<(), String> { - let store = 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(()) -} - /// Apply links from a single agent result JSON file. /// Returns (links_applied, errors). fn apply_agent_file( @@ -1176,200 +982,6 @@ fn get_group_content(group: &config::ContextGroup, store: &store::Store, cfg: &c } } -fn cmd_load_context(stats: bool) -> Result<(), String> { - let cfg = config::get(); - let store = 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 == config::ContextSource::Journal { - println!("--- recent journal entries ({}/{}) ---", - entries.len(), cfg.journal_max); - } - for (key, content) in entries { - if group.source == config::ContextSource::Journal { - println!("## {}", key); - } else { - println!("--- {} ({}) ---", key, group.label); - } - println!("{}\n", content); - } - } - - println!("=== END MEMORY LOAD ==="); - Ok(()) -} - -fn cmd_cursor(sub: CursorCmd) -> Result<(), String> { - match sub { - CursorCmd::Show => { - let store = store::Store::load()?; - cursor::show(&store) - } - CursorCmd::Set { key } => { - if key.is_empty() { - return Err("cursor set requires a key".into()); - } - let key = key.join(" "); - let store = store::Store::load()?; - let bare = store::strip_md_suffix(&key); - if !store.nodes.contains_key(&bare) { - return Err(format!("Node not found: {}", bare)); - } - cursor::set(&bare)?; - cursor::show(&store) - } - CursorCmd::Forward => { - let store = store::Store::load()?; - cursor::move_temporal(&store, true) - } - CursorCmd::Back => { - let store = store::Store::load()?; - cursor::move_temporal(&store, false) - } - CursorCmd::Up => { - let store = store::Store::load()?; - cursor::move_up(&store) - } - CursorCmd::Down => { - let store = store::Store::load()?; - cursor::move_down(&store) - } - CursorCmd::Clear => cursor::clear(), - } -} - -fn cmd_tail(n: usize, full: bool) -> Result<(), String> { - 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); - - // Read all entries, keep last N - let mut entries: Vec = Vec::new(); - while let Ok(msg) = capnp::serialize::read_message(&mut reader, capnp::message::ReaderOptions::new()) { - let log = msg.get_root::() - .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)?; - 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 { - 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 = 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(()) -} - -fn cmd_journal_write(text: &[String]) -> Result<(), String> { - if text.is_empty() { - return Err("journal-write requires text".into()); - } - let text = text.join(" "); - - let timestamp = store::format_datetime(store::now_epoch()); - - let slug: String = text.split_whitespace() - .take(6) - .map(|w| w.to_lowercase() - .chars().filter(|c| c.is_alphanumeric() || *c == '-') - .collect::()) - .collect::>() - .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 = store::Store::load()?; - - let mut node = store::new_node(&key, &content); - node.node_type = 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 cmd_journal_tail(n: usize, full: bool, level: u8) -> Result<(), String> { - let store = store::Store::load()?; - - if level == 0 { - journal_tail_entries(&store, n, full) - } else { - let node_type = match level { - 1 => store::NodeType::EpisodicDaily, - 2 => store::NodeType::EpisodicWeekly, - _ => store::NodeType::EpisodicMonthly, - }; - journal_tail_digests(&store, node_type, n, full) - } -} - fn journal_tail_entries(store: &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(); @@ -1464,17 +1076,6 @@ fn extract_title(content: &str) -> String { String::from("(untitled)") } -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 = store::Store::load()?; - let graph = store.build_graph(); - query::run_query(&store, &graph, &query_str) -} - fn cmd_apply_agent(process_all: bool) -> Result<(), String> { let results_dir = store::memory_dir().join("agent-results"); @@ -1584,3 +1185,147 @@ fn cmd_daemon(sub: DaemonCmd) -> Result<(), String> { } } +fn cmd_cursor(sub: CursorCmd) -> Result<(), String> { + match sub { + CursorCmd::Show => { + let store = crate::store::Store::load()?; + cursor::show(&store) + } + CursorCmd::Set { key } => { + if key.is_empty() { + return Err("cursor set requires a key".into()); + } + let key = key.join(" "); + let store = crate::store::Store::load()?; + let bare = crate::store::strip_md_suffix(&key); + if !store.nodes.contains_key(&bare) { + return Err(format!("Node not found: {}", bare)); + } + cursor::set(&bare)?; + cursor::show(&store) + } + CursorCmd::Forward => { + let store = crate::store::Store::load()?; + cursor::move_temporal(&store, true) + } + CursorCmd::Back => { + let store = crate::store::Store::load()?; + cursor::move_temporal(&store, false) + } + CursorCmd::Up => { + let store = crate::store::Store::load()?; + cursor::move_up(&store) + } + CursorCmd::Down => { + let store = crate::store::Store::load()?; + cursor::move_down(&store) + } + CursorCmd::Clear => cursor::clear(), + } +} + +fn cmd_journal_tail(n: usize, full: bool, level: u8) -> Result<(), String> { + let store = store::Store::load()?; + + if level == 0 { + journal_tail_entries(&store, n, full) + } else { + let node_type = match level { + 1 => store::NodeType::EpisodicDaily, + 2 => store::NodeType::EpisodicWeekly, + _ => store::NodeType::EpisodicMonthly, + }; + journal_tail_digests(&store, node_type, n, full) + } +} + +fn cmd_journal_write(text: &[String]) -> Result<(), String> { + if text.is_empty() { + return Err("journal-write requires text".into()); + } + let text = text.join(" "); + + let timestamp = store::format_datetime(store::now_epoch()); + + let slug: String = text.split_whitespace() + .take(6) + .map(|w| w.to_lowercase() + .chars().filter(|c| c.is_alphanumeric() || *c == '-') + .collect::()) + .collect::>() + .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 = store::Store::load()?; + + let mut node = store::new_node(&key, &content); + node.node_type = 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 cmd_load_context(stats: bool) -> Result<(), String> { + let cfg = config::get(); + let store = 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 == config::ContextSource::Journal { + println!("--- recent journal entries ({}/{}) ---", + entries.len(), cfg.journal_max); + } + for (key, content) in entries { + if group.source == config::ContextSource::Journal { + println!("## {}", key); + } else { + println!("--- {} ({}) ---", key, group.label); + } + println!("{}\n", content); + } + } + + println!("=== END MEMORY LOAD ==="); + Ok(()) +} +