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:
parent
9e52fd5b95
commit
a77609c025
1 changed files with 73 additions and 36 deletions
109
src/main.rs
109
src/main.rs
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue