journal-tail: add --level= for digest hierarchy

Support viewing daily, weekly, and monthly digests through the same
journal-tail interface:

  poc-memory journal-tail --level=daily 3
  poc-memory journal-tail --level=weekly --full
  poc-memory journal-tail --level=2 1

Levels: 0/journal (default), 1/daily, 2/weekly, 3/monthly.
Accepts both names and integer indices.

Refactored title extraction into shared extract_title() and split
the journal vs digest display paths for clarity.
This commit is contained in:
ProofOfConcept 2026-03-06 15:08:02 -05:00
parent 9e52fd5b95
commit a77609c025

View file

@ -195,7 +195,9 @@ Commands:
import FILE [FILE...] Import markdown file(s) into the store import FILE [FILE...] Import markdown file(s) into the store
export [FILE|--all] Export store nodes to markdown file(s) export [FILE|--all] Export store nodes to markdown file(s)
journal-write TEXT Write a journal entry to the store journal-write TEXT Write a journal entry to the store
journal-tail [N] [--full] Show last N journal entries (default 20, --full for content) journal-tail [N] [--level=L] [--full]
Show last N entries (default 20, --full for content)
--level: 0/journal, 1/daily, 2/weekly, 3/monthly
query 'EXPR | stages' Query the memory graph query 'EXPR | stages' Query the memory graph
Stages: sort F [asc], limit N, select F,F, count Stages: sort F [asc], limit N, select F,F, count
Ex: \"degree > 15 | sort degree | limit 10\" Ex: \"degree > 15 | sort degree | limit 10\"
@ -1696,9 +1698,19 @@ fn cmd_journal_write(args: &[String]) -> Result<(), String> {
fn cmd_journal_tail(args: &[String]) -> Result<(), String> { fn cmd_journal_tail(args: &[String]) -> Result<(), String> {
let mut n: usize = 20; let mut n: usize = 20;
let mut full = false; let mut full = false;
let mut level: u8 = 0; // 0=journal, 1=daily, 2=weekly, 3=monthly
for arg in args { for arg in args {
if arg == "--full" || arg == "-f" { if arg == "--full" || arg == "-f" {
full = true; full = true;
} else if let Some(val) = arg.strip_prefix("--level=") {
level = match val {
"0" | "journal" => 0,
"1" | "daily" => 1,
"2" | "weekly" => 2,
"3" | "monthly" => 3,
_ => return Err(format!("unknown level '{}': use 0-3 or journal/daily/weekly/monthly", val)),
};
} else if let Ok(num) = arg.parse::<usize>() { } else if let Ok(num) = arg.parse::<usize>() {
n = num; n = num;
} }
@ -1706,8 +1718,20 @@ fn cmd_journal_tail(args: &[String]) -> Result<(), String> {
let store = store::Store::load()?; let store = store::Store::load()?;
// Collect journal nodes (EpisodicSession), sorted by created_at. if level == 0 {
// Legacy nodes (created_at == 0) fall back to key/content parsing. // Original journal-tail behavior
journal_tail_entries(&store, n, full)
} else {
let prefix = match level {
1 => "daily-",
2 => "weekly-",
_ => "monthly-",
};
journal_tail_digests(&store, prefix, 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 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 key_date_re = regex::Regex::new(r"j-(\d{4}-\d{2}-\d{2}[t-]\d{2}-\d{2})").unwrap();
@ -1720,13 +1744,10 @@ fn cmd_journal_tail(args: &[String]) -> Result<(), String> {
} }
}; };
// Returns (sort_key, display_string) for a journal node.
// Prefer created_at (stable, rename-safe); fall back to key/content.
let extract_sort = |node: &store::Node| -> (i64, String) { let extract_sort = |node: &store::Node| -> (i64, String) {
if node.created_at > 0 { if node.created_at > 0 {
return (node.created_at, store::format_datetime(node.created_at)); return (node.created_at, store::format_datetime(node.created_at));
} }
// Legacy: parse from key or content
if let Some(caps) = key_date_re.captures(&node.key) { if let Some(caps) = key_date_re.captures(&node.key) {
return (0, normalize_date(&caps[1])); return (0, normalize_date(&caps[1]));
} }
@ -1749,48 +1770,64 @@ fn cmd_journal_tail(args: &[String]) -> Result<(), String> {
} }
}); });
// Show last N — each entry: [timestamp] ## Title
let skip = if journal.len() > n { journal.len() - n } else { 0 }; let skip = if journal.len() > n { journal.len() - n } else { 0 };
for node in journal.iter().skip(skip) { for node in journal.iter().skip(skip) {
let (_, ts) = extract_sort(node); let (_, ts) = extract_sort(node);
// Find a meaningful title: first ## header, or first non-date non-empty line let title = extract_title(&node.content);
let mut title = String::new();
for line in node.content.lines() {
let stripped = line.trim();
if stripped.is_empty() { continue; }
// Skip date-only lines like "## 2026-03-01T01:22"
if date_re.is_match(stripped) && stripped.len() < 25 { continue; }
if stripped.starts_with("## ") {
title = stripped[3..].to_string();
break;
} else if stripped.starts_with("# ") {
title = stripped[2..].to_string();
break;
} else {
// Use first content line, truncated
title = if stripped.len() > 70 {
let mut end = 67;
while !stripped.is_char_boundary(end) { end -= 1; }
format!("{}...", &stripped[..end])
} else {
stripped.to_string()
};
break;
}
}
if title.is_empty() {
title = node.key.clone();
}
if full { if full {
println!("--- [{}] {} ---\n{}\n", ts, title, node.content); println!("--- [{}] {} ---\n{}\n", ts, title, node.content);
} else { } else {
println!("[{}] {}", ts, title); println!("[{}] {}", ts, title);
} }
} }
Ok(()) Ok(())
} }
fn journal_tail_digests(store: &store::Store, prefix: &str, n: usize, full: bool) -> Result<(), String> {
let mut digests: Vec<_> = store.nodes.values()
.filter(|node| node.key.starts_with(prefix))
.collect();
// Sort by key — the date/week label sorts lexicographically
digests.sort_by(|a, b| 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.strip_prefix(prefix)
.and_then(|s| s.strip_suffix(".md"))
.unwrap_or(&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 stripped.starts_with("## ") {
return stripped[3..].to_string();
} else if stripped.starts_with("# ") {
return stripped[2..].to_string();
} else {
return if stripped.len() > 70 {
let mut end = 67;
while !stripped.is_char_boundary(end) { end -= 1; }
format!("{}...", &stripped[..end])
} else {
stripped.to_string()
};
}
}
String::from("(untitled)")
}
fn cmd_interference(args: &[String]) -> Result<(), String> { fn cmd_interference(args: &[String]) -> Result<(), String> {
let mut threshold = 0.4f32; let mut threshold = 0.4f32;
let mut i = 0; let mut i = 0;