diff --git a/poc-memory/src/cli/journal.rs b/poc-memory/src/cli/journal.rs index a621a4a..182f331 100644 --- a/poc-memory/src/cli/journal.rs +++ b/poc-memory/src/cli/journal.rs @@ -53,3 +53,181 @@ pub fn cmd_tail(n: usize, full: bool) -> Result<(), String> { Ok(()) } +pub fn find_current_transcript() -> Option { + 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) { + if let Ok(meta) = p.metadata() { + if let Ok(mtime) = meta.modified() { + if 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()); + } + 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::()) + .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 = 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)") +} + diff --git a/poc-memory/src/cli/misc.rs b/poc-memory/src/cli/misc.rs index f21b0f6..10771a7 100644 --- a/poc-memory/src/cli/misc.rs +++ b/poc-memory/src/cli/misc.rs @@ -208,3 +208,103 @@ pub fn cmd_query(expr: &[String]) -> Result<(), String> { crate::query_parser::run_query(&store, &graph, &query_str) } +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") { + if 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(()) +} + diff --git a/poc-memory/src/main.rs b/poc-memory/src/main.rs index a1b36fa..d0029e3 100644 --- a/poc-memory/src/main.rs +++ b/poc-memory/src/main.rs @@ -22,33 +22,6 @@ use clap::{Parser, Subcommand}; use std::process; /// Find the most recently modified .jsonl transcript in the Claude projects dir. -fn find_current_transcript() -> Option { - let projects = 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) { - if let Ok(meta) = p.metadata() { - if let Ok(mtime) = meta.modified() { - if newest.as_ref().is_none_or(|(t, _)| mtime > *t) { - newest = Some((mtime, p)); - } - } - } - } - } - } - } - } - newest.map(|(_, p)| p.to_string_lossy().to_string()) -} - #[derive(Parser)] #[command(name = "poc-memory", version = "0.4.0", about = "Graph-structured memory store")] struct Cli { @@ -755,8 +728,8 @@ fn main() { // Journal Command::Journal(sub) => match sub { - JournalCmd::Write { text } => cmd_journal_write(&text), - JournalCmd::Tail { n, full, level } => cmd_journal_tail(n, full, level), + JournalCmd::Write { text } => cli::journal::cmd_journal_write(&text), + JournalCmd::Tail { n, full, level } => cli::journal::cmd_journal_tail(n, full, level), JournalCmd::Enrich { jsonl_path, entry_text, grep_line } => cli::agent::cmd_journal_enrich(&jsonl_path, &entry_text, grep_line), }, @@ -824,7 +797,7 @@ fn main() { AdminCmd::DailyCheck => cli::admin::cmd_daily_check(), 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::LoadContext { stats } => cli::misc::cmd_load_context(stats), AdminCmd::Log => cli::misc::cmd_log(), AdminCmd::Params => cli::misc::cmd_params(), AdminCmd::LookupBump { keys } => cli::node::cmd_lookup_bump(&keys), @@ -931,151 +904,6 @@ fn apply_agent_file( (applied, errors) } -fn get_group_content(group: &config::ContextGroup, store: &store::Store, cfg: &config::Config) -> Vec<(String, String)> { - match group.source { - config::ContextSource::Journal => { - let mut entries = Vec::new(); - let now = 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: &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") { - if 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 == 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 - } - 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() - } - 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() - } - } -} - -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(); - - 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: &store::Node| -> (i64, String) { - if node.created_at > 0 { - return (node.created_at, 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, store::format_datetime(node.timestamp)) - }; - - let mut journal: Vec<_> = store.nodes.values() - .filter(|node| node.node_type == 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: &store::Store, node_type: 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(()) -} - -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 util::truncate(stripped, 67, "..."); - } - } - String::from("(untitled)") -} - fn cmd_apply_agent(process_all: bool) -> Result<(), String> { let results_dir = store::memory_dir().join("agent-results"); @@ -1159,7 +987,7 @@ fn cmd_digest(level: DigestLevel) -> Result<(), String> { fn cmd_experience_mine(jsonl_path: Option) -> Result<(), String> { let jsonl_path = match jsonl_path { Some(p) => p, - None => find_current_transcript() + None => cli::journal::find_current_transcript() .ok_or("no JSONL transcripts found")?, }; @@ -1224,108 +1052,3 @@ fn cmd_cursor(sub: CursorCmd) -> Result<(), String> { } } -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(()) -} -