journal: remove all stringly-typed key patterns, use NodeType

- journal_new: key is slugified title (agent names things properly)
- journal_tail: sort by created_at (immutable), not timestamp (mutable)
- journal_update: find latest by created_at
- {{latest_journal}}: query by NodeType::EpisodicSession, not "journal" key
- poc-memory journal write: requires a name argument
- Removed all journal#j-{timestamp}-{slug} patterns from:
  - prompts.rs (rename candidates)
  - graph.rs (date extraction, organize skip list)
  - cursor.rs (date extraction)
  - store/mod.rs (doc comment)
- graph.rs organize: filter by NodeType::Semantic instead of key prefix
- cursor.rs: use created_at for date extraction instead of key parsing

Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
ProofOfConcept 2026-03-26 19:11:17 -04:00
parent 85fa54cba9
commit eac59b423e
9 changed files with 63 additions and 67 deletions

View file

@ -156,7 +156,8 @@ pub fn dispatch(name: &str, args: &serde_json::Value, provenance: Option<&str>)
let mut entries: Vec<&crate::store::Node> = store.nodes.values() let mut entries: Vec<&crate::store::Node> = store.nodes.values()
.filter(|n| n.node_type == crate::store::NodeType::EpisodicSession) .filter(|n| n.node_type == crate::store::NodeType::EpisodicSession)
.collect(); .collect();
entries.sort_by_key(|n| n.timestamp); // Sort by creation time (immutable), not update time
entries.sort_by_key(|n| n.created_at);
let start = entries.len().saturating_sub(count); let start = entries.len().saturating_sub(count);
if entries[start..].is_empty() { if entries[start..].is_empty() {
Ok("(no journal entries)".into()) Ok("(no journal entries)".into())
@ -173,19 +174,18 @@ pub fn dispatch(name: &str, args: &serde_json::Value, provenance: Option<&str>)
let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M"); let ts = chrono::Local::now().format("%Y-%m-%dT%H:%M");
let content = format!("## {}{}\n\n{}", ts, title, body); let content = format!("## {}{}\n\n{}", ts, title, body);
let slug: String = title.split_whitespace() // Key from title — the agent names things, not a placeholder slug
.take(6) let key: String = title.split_whitespace()
.map(|w| w.to_lowercase() .map(|w| w.to_lowercase()
.chars().filter(|c| c.is_alphanumeric() || *c == '-') .chars().filter(|c| c.is_alphanumeric() || *c == '-')
.collect::<String>()) .collect::<String>())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("-"); .join("-");
let slug = if slug.len() > 50 { &slug[..50] } else { &slug }; let key = if key.len() > 80 { &key[..80] } else { &key };
let key = format!("journal-j-{}-{}",
ts.to_string().to_lowercase().replace(':', "-"), slug);
let mut store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?; let mut store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?;
let mut node = crate::store::new_node(&key, &content); let mut node = crate::store::new_node(key, &content);
node.node_type = crate::store::NodeType::EpisodicSession; node.node_type = crate::store::NodeType::EpisodicSession;
node.provenance = prov.to_string(); node.provenance = prov.to_string();
store.upsert_node(node).map_err(|e| anyhow::anyhow!("{}", e))?; store.upsert_node(node).map_err(|e| anyhow::anyhow!("{}", e))?;
@ -196,10 +196,10 @@ pub fn dispatch(name: &str, args: &serde_json::Value, provenance: Option<&str>)
"journal_update" => { "journal_update" => {
let body = get_str(args, "body")?; let body = get_str(args, "body")?;
let mut store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?; let mut store = Store::load().map_err(|e| anyhow::anyhow!("{}", e))?;
// Find most recent EpisodicSession node // Find most recent EpisodicSession by creation time
let latest_key = store.nodes.values() let latest_key = store.nodes.values()
.filter(|n| n.node_type == crate::store::NodeType::EpisodicSession) .filter(|n| n.node_type == crate::store::NodeType::EpisodicSession)
.max_by_key(|n| n.timestamp) .max_by_key(|n| n.created_at)
.map(|n| n.key.clone()); .map(|n| n.key.clone());
let Some(key) = latest_key else { let Some(key) = latest_key else {
anyhow::bail!("no journal entry to update — use journal_new first"); anyhow::bail!("no journal entry to update — use journal_new first");

View file

@ -480,12 +480,12 @@ pub fn cmd_organize(term: &str, threshold: f32, key_only: bool, create_anchor: b
let term_lower = term.to_lowercase(); let term_lower = term.to_lowercase();
let mut topic_nodes: Vec<(String, String)> = Vec::new(); // (key, content) let mut topic_nodes: Vec<(String, String)> = Vec::new(); // (key, content)
// Prefixes that indicate ephemeral/generated nodes to skip let skip_prefixes = ["_", "deep-index#", "facts-", "irc-history#"];
let skip_prefixes = ["journal#", "daily-", "weekly-", "monthly-", "_",
"deep-index#", "facts-", "irc-history#"];
for (key, node) in &store.nodes { for (key, node) in &store.nodes {
if node.deleted { continue; } if node.deleted { continue; }
// Skip episodic/digest nodes — use NodeType, not key prefix
if node.node_type != crate::store::NodeType::Semantic { continue; }
let key_matches = key.to_lowercase().contains(&term_lower); let key_matches = key.to_lowercase().contains(&term_lower);
let content_matches = !key_only && node.content.to_lowercase().contains(&term_lower); let content_matches = !key_only && node.content.to_lowercase().contains(&term_lower);
if !key_matches && !content_matches { continue; } if !key_matches && !content_matches { continue; }

View file

@ -1,7 +1,7 @@
// cli/journal.rs — journal subcommand handlers // cli/journal.rs — journal subcommand handlers
pub fn cmd_tail(n: usize, full: bool, provenance: Option<&str>) -> Result<(), String> { pub fn cmd_tail(n: usize, full: bool, provenance: Option<&str>, dedup: bool) -> Result<(), String> {
let path = crate::store::nodes_path(); let path = crate::store::nodes_path();
if !path.exists() { if !path.exists() {
return Err("No node log found".into()); return Err("No node log found".into());
@ -24,11 +24,21 @@ pub fn cmd_tail(n: usize, full: bool, provenance: Option<&str>) -> Result<(), St
} }
} }
// Filter by provenance if specified (prefix match) // Filter by provenance if specified (substring match)
if let Some(prov) = provenance { if let Some(prov) = provenance {
entries.retain(|n| n.provenance.contains(prov)); entries.retain(|n| n.provenance.contains(prov));
} }
// Dedup: keep only the latest version of each key
if dedup {
let mut seen = std::collections::HashSet::new();
// Walk backwards so we keep the latest
entries = entries.into_iter().rev()
.filter(|n| seen.insert(n.key.clone()))
.collect();
entries.reverse();
}
let start = entries.len().saturating_sub(n); let start = entries.len().saturating_sub(n);
for node in &entries[start..] { for node in &entries[start..] {
let ts = if node.timestamp > 0 && node.timestamp < 4_000_000_000 { let ts = if node.timestamp > 0 && node.timestamp < 4_000_000_000 {
@ -172,27 +182,23 @@ pub fn cmd_journal_tail(n: usize, full: bool, level: u8) -> Result<(), String> {
} }
} }
pub fn cmd_journal_write(text: &[String]) -> Result<(), String> { pub fn cmd_journal_write(name: &str, text: &[String]) -> Result<(), String> {
if text.is_empty() { if text.is_empty() {
return Err("journal-write requires text".into()); return Err("journal write requires text".into());
} }
super::check_dry_run(); super::check_dry_run();
let text = text.join(" "); let text = text.join(" ");
let timestamp = crate::store::format_datetime(crate::store::now_epoch()); let timestamp = crate::store::format_datetime(crate::store::now_epoch());
let content = format!("## {}{}\n\n{}", timestamp, name, text);
let slug: String = text.split_whitespace() let key: String = name.split_whitespace()
.take(6)
.map(|w| w.to_lowercase() .map(|w| w.to_lowercase()
.chars().filter(|c| c.is_alphanumeric() || *c == '-') .chars().filter(|c| c.is_alphanumeric() || *c == '-')
.collect::<String>()) .collect::<String>())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("-"); .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 source_ref = find_current_transcript();

View file

@ -89,15 +89,11 @@ pub fn digest_parent(store: &Store, key: &str) -> Option<String> {
if node.timestamp > 0 { if node.timestamp > 0 {
dates.push(store::format_date(node.timestamp)); dates.push(store::format_date(node.timestamp));
} }
// Extract date from key patterns like "journal#2026-03-03-..." or "journal#j-2026-03-13t..." // Extract date from created_at timestamp
if let Some(rest) = key.strip_prefix("journal#j-").or_else(|| key.strip_prefix("journal#")) if node.created_at > 0 {
&& rest.len() >= 10 { let created_date = store::format_date(node.created_at);
let candidate = &rest[..10]; if !dates.contains(&created_date) {
if candidate.chars().nth(4) == Some('-') { dates.push(created_date);
let date = candidate.to_string();
if !dates.contains(&date) {
dates.push(date);
}
} }
} }
for date in &dates { for date in &dates {

View file

@ -566,20 +566,15 @@ fn add_implicit_temporal_edges(
use chrono::{Datelike, DateTime, NaiveDate}; use chrono::{Datelike, DateTime, NaiveDate};
// Extract the covered date from a key name. // Extract the covered date from a key name.
// Patterns: "daily-2026-03-06", "daily-2026-03-06-identity", // Patterns: "daily-2026-03-06", "daily-2026-03-06-identity"
// "weekly-2026-W09", "monthly-2026-02"
// "journal#j-2026-03-13t...", "journal#2026-03-13-..."
fn date_from_key(key: &str) -> Option<NaiveDate> { fn date_from_key(key: &str) -> Option<NaiveDate> {
// Try extracting YYYY-MM-DD after known prefixes let rest = key.strip_prefix("daily-")?;
for prefix in ["daily-", "journal#j-", "journal#"] { if rest.len() >= 10 {
if let Some(rest) = key.strip_prefix(prefix) NaiveDate::parse_from_str(&rest[..10], "%Y-%m-%d").ok()
&& rest.len() >= 10 } else {
&& let Ok(d) = NaiveDate::parse_from_str(&rest[..10], "%Y-%m-%d") {
return Some(d);
}
}
None None
} }
}
fn week_from_key(key: &str) -> Option<(i32, u32)> { fn week_from_key(key: &str) -> Option<(i32, u32)> {
// "weekly-2026-W09" → (2026, 9) // "weekly-2026-W09" → (2026, 9)

View file

@ -48,7 +48,7 @@ use std::path::Path;
use parse::classify_filename; use parse::classify_filename;
/// Strip .md suffix from a key, handling both bare keys and section keys. /// Strip .md suffix from a key, handling both bare keys and section keys.
/// "journal.md#j-2026" → "journal#j-2026", "identity.md" → "identity", "identity" → "identity" /// "identity.md" → "identity", "foo.md#section" → "foo#section", "identity" → "identity"
pub fn strip_md_suffix(key: &str) -> String { pub fn strip_md_suffix(key: &str) -> String {
if let Some((file, section)) = key.split_once('#') { if let Some((file, section)) = key.split_once('#') {
let bare = file.strip_suffix(".md").unwrap_or(file); let bare = file.strip_suffix(".md").unwrap_or(file);

View file

@ -93,6 +93,9 @@ enum Command {
/// Filter by provenance (substring match, e.g. "surface-observe") /// Filter by provenance (substring match, e.g. "surface-observe")
#[arg(long, short)] #[arg(long, short)]
provenance: Option<String>, provenance: Option<String>,
/// Show all versions (default: dedup to latest per key)
#[arg(long)]
all_versions: bool,
}, },
/// Summary of memory state /// Summary of memory state
Status, Status,
@ -271,6 +274,8 @@ enum CursorCmd {
enum JournalCmd { enum JournalCmd {
/// Write a journal entry to the store /// Write a journal entry to the store
Write { Write {
/// Entry name (becomes the node key)
name: String,
/// Entry text /// Entry text
text: Vec<String>, text: Vec<String>,
}, },
@ -785,8 +790,8 @@ impl Run for Command {
Self::Write { key } => cli::node::cmd_write(&key), Self::Write { key } => cli::node::cmd_write(&key),
Self::Edit { key } => cli::node::cmd_edit(&key), Self::Edit { key } => cli::node::cmd_edit(&key),
Self::History { full, key } => cli::node::cmd_history(&key, full), Self::History { full, key } => cli::node::cmd_history(&key, full),
Self::Tail { n, full, provenance } Self::Tail { n, full, provenance, all_versions }
=> cli::journal::cmd_tail(n, full, provenance.as_deref()), => cli::journal::cmd_tail(n, full, provenance.as_deref(), !all_versions),
Self::Status => cli::misc::cmd_status(), Self::Status => cli::misc::cmd_status(),
Self::Query { expr } => cli::misc::cmd_query(&expr), Self::Query { expr } => cli::misc::cmd_query(&expr),
Self::Used { key } => cli::node::cmd_used(&key), Self::Used { key } => cli::node::cmd_used(&key),
@ -820,7 +825,7 @@ impl Run for NodeCmd {
impl Run for JournalCmd { impl Run for JournalCmd {
fn run(self) -> Result<(), String> { fn run(self) -> Result<(), String> {
match self { match self {
Self::Write { text } => cli::journal::cmd_journal_write(&text), Self::Write { name, text } => cli::journal::cmd_journal_write(&name, &text),
Self::Tail { n, full, level } => cli::journal::cmd_journal_tail(n, full, level), Self::Tail { n, full, level } => cli::journal::cmd_journal_tail(n, full, level),
Self::Enrich { jsonl_path, entry_text, grep_line } Self::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),

View file

@ -552,22 +552,16 @@ fn resolve(
Some(Resolved { text, keys: vec![] }) Some(Resolved { text, keys: vec![] })
} }
// latest_journal — the most recent journal entry for the journal agent // latest_journal — the most recent EpisodicSession entry
"latest_journal" => { "latest_journal" => {
let text = store.nodes.get("journal") let latest = store.nodes.values()
.map(|n| { .filter(|n| n.node_type == crate::store::NodeType::EpisodicSession)
// Get the last entry (last ## section) .max_by_key(|n| n.created_at);
let content = &n.content; let (text, keys) = match latest {
content.rfind("\n## ") Some(n) => (n.content.clone(), vec![n.key.clone()]),
.map(|pos| content[pos..].to_string()) None => ("(no previous journal entry)".to_string(), vec![]),
.unwrap_or_else(|| { };
// Take the last 2000 chars if no ## found Some(Resolved { text, keys })
let start = content.len().saturating_sub(2000);
content[start..].to_string()
})
})
.unwrap_or_else(|| "(no previous journal entry)".to_string());
Some(Resolved { text, keys: vec!["journal".to_string()] })
} }
_ => None, _ => None,

View file

@ -243,10 +243,10 @@ pub fn format_pairs_section(
pub fn format_rename_candidates(store: &Store, count: usize) -> (Vec<String>, String) { pub fn format_rename_candidates(store: &Store, count: usize) -> (Vec<String>, String) {
let mut candidates: Vec<(&str, &crate::store::Node)> = store.nodes.iter() let mut candidates: Vec<(&str, &crate::store::Node)> = store.nodes.iter()
.filter(|(key, _)| { .filter(|(key, node)| {
if key.starts_with("_facts-") { return true; } if key.starts_with("_facts-") { return true; }
if key.len() < 60 { return false; } if key.len() < 60 { return false; }
if key.starts_with("journal#j-") { return true; } if node.node_type == crate::store::NodeType::EpisodicSession { return true; }
if key.starts_with("_mined-transcripts#f-") { return true; } if key.starts_with("_mined-transcripts#f-") { return true; }
false false
}) })
@ -271,9 +271,9 @@ pub fn format_rename_candidates(store: &Store, count: usize) -> (Vec<String>, St
let mut out = String::new(); let mut out = String::new();
out.push_str(&format!("## Nodes to rename ({} of {} candidates)\n\n", out.push_str(&format!("## Nodes to rename ({} of {} candidates)\n\n",
candidates.len(), candidates.len(),
store.nodes.keys().filter(|k| k.starts_with("_facts-") || store.nodes.iter().filter(|(k, n)| k.starts_with("_facts-") ||
(k.len() >= 60 && (k.len() >= 60 &&
(k.starts_with("journal#j-") || k.starts_with("_mined-transcripts#f-")))).count())); (n.node_type == crate::store::NodeType::EpisodicSession || k.starts_with("_mined-transcripts#f-")))).count()));
for (key, node) in &candidates { for (key, node) in &candidates {
out.push_str(&format!("### {}\n", key)); out.push_str(&format!("### {}\n", key));