cli: move helpers to cli modules, main.rs under 1100 lines
Move CLI-specific helpers to their cli/ modules: - journal_tail_entries, journal_tail_digests, extract_title, find_current_transcript → cli/journal.rs - get_group_content → cli/misc.rs - cmd_journal_write, cmd_journal_tail, cmd_load_context follow These are presentation/session helpers, not library code — they belong in the CLI layer per Kent's guidance. main.rs: 3130 → 1054 lines (66% reduction). Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
parent
8640d50990
commit
99db511403
3 changed files with 282 additions and 281 deletions
|
|
@ -53,3 +53,181 @@ pub fn cmd_tail(n: usize, full: bool) -> Result<(), String> {
|
||||||
Ok(())
|
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) {
|
||||||
|
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::<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)")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -208,3 +208,103 @@ pub fn cmd_query(expr: &[String]) -> Result<(), String> {
|
||||||
crate::query_parser::run_query(&store, &graph, &query_str)
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,33 +22,6 @@ use clap::{Parser, Subcommand};
|
||||||
use std::process;
|
use std::process;
|
||||||
|
|
||||||
/// Find the most recently modified .jsonl transcript in the Claude projects dir.
|
/// Find the most recently modified .jsonl transcript in the Claude projects dir.
|
||||||
fn find_current_transcript() -> Option<String> {
|
|
||||||
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)]
|
#[derive(Parser)]
|
||||||
#[command(name = "poc-memory", version = "0.4.0", about = "Graph-structured memory store")]
|
#[command(name = "poc-memory", version = "0.4.0", about = "Graph-structured memory store")]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
|
|
@ -755,8 +728,8 @@ fn main() {
|
||||||
|
|
||||||
// Journal
|
// Journal
|
||||||
Command::Journal(sub) => match sub {
|
Command::Journal(sub) => match sub {
|
||||||
JournalCmd::Write { text } => cmd_journal_write(&text),
|
JournalCmd::Write { text } => cli::journal::cmd_journal_write(&text),
|
||||||
JournalCmd::Tail { n, full, level } => cmd_journal_tail(n, full, level),
|
JournalCmd::Tail { n, full, level } => cli::journal::cmd_journal_tail(n, full, level),
|
||||||
JournalCmd::Enrich { jsonl_path, entry_text, grep_line }
|
JournalCmd::Enrich { jsonl_path, entry_text, grep_line }
|
||||||
=> cli::agent::cmd_journal_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::DailyCheck => cli::admin::cmd_daily_check(),
|
||||||
AdminCmd::Import { files } => cli::admin::cmd_import(&files),
|
AdminCmd::Import { files } => cli::admin::cmd_import(&files),
|
||||||
AdminCmd::Export { files, all } => cli::admin::cmd_export(&files, all),
|
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::Log => cli::misc::cmd_log(),
|
||||||
AdminCmd::Params => cli::misc::cmd_params(),
|
AdminCmd::Params => cli::misc::cmd_params(),
|
||||||
AdminCmd::LookupBump { keys } => cli::node::cmd_lookup_bump(&keys),
|
AdminCmd::LookupBump { keys } => cli::node::cmd_lookup_bump(&keys),
|
||||||
|
|
@ -931,151 +904,6 @@ fn apply_agent_file(
|
||||||
(applied, errors)
|
(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> {
|
fn cmd_apply_agent(process_all: bool) -> Result<(), String> {
|
||||||
let results_dir = store::memory_dir().join("agent-results");
|
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<String>) -> Result<(), String> {
|
fn cmd_experience_mine(jsonl_path: Option<String>) -> Result<(), String> {
|
||||||
let jsonl_path = match jsonl_path {
|
let jsonl_path = match jsonl_path {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => find_current_transcript()
|
None => cli::journal::find_current_transcript()
|
||||||
.ok_or("no JSONL transcripts found")?,
|
.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::<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 = 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(())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue