query by NodeType instead of key prefix

Replace key prefix matching (journal#j-, daily-, weekly-, monthly-)
with NodeType filters (EpisodicSession, EpisodicDaily, EpisodicWeekly,
EpisodicMonthly) for all queries: journal-tail, digest gathering,
digest auto-detection, experience mining dedup, and find_journal_node.

Add EpisodicMonthly to NodeType enum and capnp schema.

Key naming conventions (journal#j-TIMESTAMP-slug, daily-DATE, etc.)
are retained for key generation — the fix is about how we find nodes,
not how we name them.

Co-Authored-By: ProofOfConcept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-03-08 20:14:37 -04:00
parent fd5591653d
commit 804578b977
8 changed files with 43 additions and 47 deletions

View file

@ -45,6 +45,7 @@ enum NodeType {
episodicDaily @1;
episodicWeekly @2;
semantic @3;
episodicMonthly @4;
}
enum Provenance {

View file

@ -145,15 +145,13 @@ fn gather(level: &DigestLevel, store: &Store, arg: &str) -> Result<(String, Vec<
.collect();
load_child_digests(store, child_name, &child_labels)
} else {
// Leaf level: scan store for journal entries matching label
let date_re = Regex::new(&format!(
r"^journal#j-{}", regex::escape(&label)
)).unwrap();
// Leaf level: scan store for episodic entries matching date
let mut entries: Vec<_> = store.nodes.values()
.filter(|n| date_re.is_match(&n.key))
.filter(|n| n.node_type == store::NodeType::EpisodicSession
&& n.timestamp > 0
&& store::format_date(n.timestamp) == label)
.map(|n| {
let ts = n.key.strip_prefix("journal#j-").unwrap_or(&n.key);
(ts.to_string(), n.content.clone())
(store::format_datetime(n.timestamp), n.content.clone())
})
.collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
@ -252,14 +250,10 @@ pub fn generate(store: &mut Store, level_name: &str, arg: &str) -> Result<(), St
pub fn digest_auto(store: &mut Store) -> Result<(), String> {
let today = Local::now().format("%Y-%m-%d").to_string();
// Collect all dates with journal entries
let date_re = Regex::new(r"^\d{4}-\d{2}-\d{2}").unwrap();
let dates: Vec<String> = store.nodes.keys()
.filter_map(|key| {
key.strip_prefix("journal#j-")
.filter(|rest| rest.len() >= 10 && date_re.is_match(rest))
.map(|rest| rest[..10].to_string())
})
// Collect all dates with episodic entries
let dates: Vec<String> = store.nodes.values()
.filter(|n| n.node_type == store::NodeType::EpisodicSession && n.timestamp > 0)
.map(|n| store::format_date(n.timestamp))
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
@ -408,10 +402,12 @@ fn parse_digest_node_links(key: &str, content: &str) -> Vec<DigestLink> {
pub fn parse_all_digest_links(store: &Store) -> Vec<DigestLink> {
let mut all_links = Vec::new();
let mut digest_keys: Vec<&String> = store.nodes.keys()
.filter(|k| k.starts_with("daily-")
|| k.starts_with("weekly-")
|| k.starts_with("monthly-"))
let mut digest_keys: Vec<&String> = store.nodes.iter()
.filter(|(_, n)| matches!(n.node_type,
store::NodeType::EpisodicDaily
| store::NodeType::EpisodicWeekly
| store::NodeType::EpisodicMonthly))
.map(|(k, _)| k)
.collect();
digest_keys.sort();

View file

@ -336,21 +336,11 @@ pub fn experience_mine(
.map(|n| n.content.clone())
.unwrap_or_default();
// Get recent journal entries to avoid duplication
let key_date_re = Regex::new(r"^journal#j-(\d{4}-\d{2}-\d{2}[t-]\d{2}-\d{2})").unwrap();
let date_re = Regex::new(r"(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2})").unwrap();
// Get recent episodic entries to avoid duplication
let mut journal: Vec<_> = store.nodes.values()
.filter(|node| node.key.starts_with("journal#j-"))
.filter(|node| matches!(node.node_type, store::NodeType::EpisodicSession))
.collect();
journal.sort_by(|a, b| {
let ak = key_date_re.captures(&a.key).map(|c| c[1].to_string())
.or_else(|| date_re.captures(&a.content).map(|c| c[1].to_string()))
.unwrap_or_default();
let bk = key_date_re.captures(&b.key).map(|c| c[1].to_string())
.or_else(|| date_re.captures(&b.content).map(|c| c[1].to_string()))
.unwrap_or_default();
ak.cmp(&bk)
});
journal.sort_by_key(|n| n.timestamp);
let recent: String = journal.iter().rev().take(10)
.map(|n| format!("---\n{}\n", n.content))
.collect();

View file

@ -1122,7 +1122,8 @@ fn cmd_trace(args: &[String]) -> Result<(), String> {
episodic_session.push(entry),
store::NodeType::EpisodicDaily =>
episodic_daily.push(entry),
store::NodeType::EpisodicWeekly =>
store::NodeType::EpisodicWeekly
| store::NodeType::EpisodicMonthly =>
episodic_weekly.push(entry),
store::NodeType::Semantic =>
semantic.push(entry),
@ -1855,12 +1856,12 @@ fn cmd_journal_tail(args: &[String]) -> Result<(), String> {
// Original journal-tail behavior
journal_tail_entries(&store, n, full)
} else {
let prefix = match level {
1 => "daily-",
2 => "weekly-",
_ => "monthly-",
let node_type = match level {
1 => store::NodeType::EpisodicDaily,
2 => store::NodeType::EpisodicWeekly,
_ => store::NodeType::EpisodicMonthly,
};
journal_tail_digests(&store, prefix, n, full)
journal_tail_digests(&store, node_type, n, full)
}
}
@ -1916,17 +1917,22 @@ fn journal_tail_entries(store: &store::Store, n: usize, full: bool) -> Result<()
Ok(())
}
fn journal_tail_digests(store: &store::Store, prefix: &str, n: usize, full: bool) -> Result<(), String> {
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.key.starts_with(prefix))
.filter(|node| node.node_type == node_type)
.collect();
// Sort by key — the date/week label sorts lexicographically
digests.sort_by(|a, b| a.key.cmp(&b.key));
// Sort by timestamp, fall back to key for lexicographic ordering
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.strip_prefix(prefix)
.unwrap_or(&node.key);
let label = &node.key;
let title = extract_title(&node.content);
if full {
println!("--- [{}] {} ---\n{}\n", label, title, node.content);

View file

@ -196,6 +196,7 @@ fn node_type_label(nt: NodeType) -> &'static str {
NodeType::EpisodicSession => "episodic_session",
NodeType::EpisodicDaily => "episodic_daily",
NodeType::EpisodicWeekly => "episodic_weekly",
NodeType::EpisodicMonthly => "episodic_monthly",
NodeType::Semantic => "semantic",
}
}

View file

@ -293,7 +293,7 @@ impl Store {
Some(output.trim_end().to_string())
}
/// Find the journal node that best matches the given entry text.
/// Find the episodic node that best matches the given entry text.
pub fn find_journal_node(&self, entry_text: &str) -> Option<String> {
if entry_text.is_empty() {
return None;
@ -308,7 +308,7 @@ impl Store {
let mut best_score = 0;
for (key, node) in &self.nodes {
if !key.starts_with("journal#") {
if node.node_type != NodeType::EpisodicSession {
continue;
}
let content_lower = node.content.to_lowercase();

View file

@ -27,6 +27,7 @@ pub fn classify_filename(filename: &str) -> NodeType {
let bare = filename.strip_suffix(".md").unwrap_or(filename);
if bare.starts_with("daily-") { NodeType::EpisodicDaily }
else if bare.starts_with("weekly-") { NodeType::EpisodicWeekly }
else if bare.starts_with("monthly-") { NodeType::EpisodicMonthly }
else if bare == "journal" { NodeType::EpisodicSession }
else { NodeType::Semantic }
}

View file

@ -226,6 +226,7 @@ pub enum NodeType {
EpisodicDaily,
EpisodicWeekly,
Semantic,
EpisodicMonthly,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
@ -345,7 +346,7 @@ pub enum RelationType {
}
capnp_enum!(NodeType, memory_capnp::NodeType,
[EpisodicSession, EpisodicDaily, EpisodicWeekly, Semantic]);
[EpisodicSession, EpisodicDaily, EpisodicWeekly, Semantic, EpisodicMonthly]);
capnp_enum!(Provenance, memory_capnp::Provenance,
[Manual, Journal, Agent, Dream, Derived,