journal_tail: return typed Vec<JournalEntry>, remove Store::load from agent

- journal_tail returns Vec<JournalEntry> with key, content, created_at
- load_startup_journal uses typed API, no more direct Store access
- CLI does formatting, hippocampus returns data

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-13 15:23:10 -04:00
parent 419bb222b5
commit 063cf031d3
4 changed files with 62 additions and 47 deletions

View file

@ -497,42 +497,33 @@ impl Agent {
} }
async fn load_startup_journal(&self) { async fn load_startup_journal(&self) {
use crate::agent::tools::memory::journal_tail;
let oldest_msg_ts = { let oldest_msg_ts = {
let ctx = self.context.lock().await; let ctx = self.context.lock().await;
ctx.conversation_log.as_ref().and_then(|log| log.oldest_timestamp()) ctx.conversation_log.as_ref().and_then(|log| log.oldest_timestamp())
}; };
let store = match crate::store::Store::load() { // Get recent journal entries (newest first)
Ok(s) => s, let journal_entries = match journal_tail(None, Some(100), Some(0), None).await {
Ok(e) => e,
Err(_) => return, Err(_) => return,
}; };
let mut journal_nodes: Vec<_> = store.nodes.values() // Filter to entries before the conversation started
.filter(|n| n.node_type == crate::store::NodeType::EpisodicSession) let cutoff_ts = oldest_msg_ts.map(|t| t.timestamp());
let filtered: Vec<_> = journal_entries.into_iter()
.filter(|e| cutoff_ts.map(|ts| e.created_at < ts).unwrap_or(true))
.collect(); .collect();
journal_nodes.sort_by_key(|n| n.created_at);
let cutoff_idx = if let Some(cutoff) = oldest_msg_ts {
let cutoff_ts = cutoff.timestamp();
let mut idx = journal_nodes.len();
for (i, node) in journal_nodes.iter().enumerate() {
if node.created_at >= cutoff_ts {
idx = i + 1;
break;
}
}
idx
} else {
journal_nodes.len()
};
let journal_budget = context::context_window() * 15 / 100; let journal_budget = context::context_window() * 15 / 100;
let mut entries = Vec::new(); let mut entries = Vec::new();
let mut total_tokens = 0; let mut total_tokens = 0;
for node in journal_nodes[..cutoff_idx].iter().rev() { // Take entries within budget (they're newest-first, so reverse for display)
let ts = chrono::DateTime::from_timestamp(node.created_at, 0); for entry in filtered.iter() {
let ast = AstNode::memory(&node.key, &node.content) let ts = chrono::DateTime::from_timestamp(entry.created_at, 0);
let ast = AstNode::memory(&entry.key, &entry.content)
.with_timestamp(ts.unwrap_or_else(chrono::Utc::now)); .with_timestamp(ts.unwrap_or_else(chrono::Utc::now));
let tok = ast.tokens(); let tok = ast.tokens();
if total_tokens + tok > journal_budget && !entries.is_empty() { if total_tokens + tok > journal_budget && !entries.is_empty() {

View file

@ -332,7 +332,9 @@ memory_tool!(memory_links, ref -> Vec<LinkInfo>, key: [str]);
// ── Journal tools ────────────────────────────────────────────── // ── Journal tools ──────────────────────────────────────────────
memory_tool!(journal_tail, ref, count: [Option<u64>], level: [Option<u64>], format: [Option<&str>], after: [Option<&str>]); pub use crate::hippocampus::JournalEntry;
memory_tool!(journal_tail, ref -> Vec<JournalEntry>, count: [Option<u64>], level: [Option<u64>], after: [Option<&str>]);
memory_tool!(journal_new, mut, name: [str], title: [str], body: [str], level: [Option<i64>]); memory_tool!(journal_new, mut, name: [str], title: [str], body: [str], level: [Option<i64>]);
memory_tool!(journal_update, mut, body: [str], level: [Option<i64>]); memory_tool!(journal_update, mut, body: [str], level: [Option<i64>]);

View file

@ -68,10 +68,17 @@ pub fn cmd_tail(n: usize, full: bool, provenance: Option<&str>, dedup: bool) ->
} }
pub async fn cmd_journal_tail(n: usize, full: bool, level: u8) -> Result<(), String> { pub async fn cmd_journal_tail(n: usize, full: bool, level: u8) -> Result<(), String> {
let format = if full { Some("full") } else { Some("compact") }; let entries = memory::journal_tail(None, Some(n as u64), Some(level as u64), None).await
let result = memory::journal_tail(None, Some(n as u64), Some(level as u64), format, None).await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
print!("{}", result); for entry in entries {
if full {
println!("--- {} ---", entry.key);
println!("{}\n", entry.content);
} else {
let first_line = entry.content.lines().next().unwrap_or("(empty)");
println!("{}: {}", entry.key, first_line);
}
}
Ok(()) Ok(())
} }

View file

@ -257,31 +257,46 @@ pub fn memory_query(store: &Store, _provenance: &str, query_str: &str, format: O
// ── Journal tools ────────────────────────────────────────────── // ── Journal tools ──────────────────────────────────────────────
pub fn journal_tail(store: &Store, _provenance: &str, count: Option<u64>, level: Option<u64>, format: Option<&str>, after: Option<&str>) -> Result<String> { /// A journal entry with key, content, and timestamp.
let count = count.unwrap_or(1); #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
let level = level.unwrap_or(0); pub struct JournalEntry {
let format = format.unwrap_or("full"); pub key: String,
pub content: String,
pub created_at: i64,
}
let type_name = match level { /// Get journal entries, sorted by timestamp (newest first).
0 => "episodic", /// level: 0=session, 1=daily, 2=weekly, 3=monthly
1 => "daily", /// after: only entries after this date (YYYY-MM-DD)
2 => "weekly", pub fn journal_tail(store: &Store, _provenance: &str, count: Option<u64>, level: Option<u64>, after: Option<&str>) -> Result<Vec<JournalEntry>> {
3 => "monthly", let count = count.unwrap_or(10) as usize;
_ => return Err(anyhow::anyhow!("invalid level: {} (0=journal, 1=daily, 2=weekly, 3=monthly)", level)), let level = level.unwrap_or(0);
let node_type = match level {
0 => crate::store::NodeType::EpisodicSession,
1 => crate::store::NodeType::EpisodicDaily,
2 => crate::store::NodeType::EpisodicWeekly,
3 => crate::store::NodeType::EpisodicMonthly,
_ => return Err(anyhow::anyhow!("invalid level: {}", level)),
}; };
let mut q = std::format!("all | type:{} | sort:timestamp", type_name); let after_ts = after.and_then(|date| {
if let Some(date) = after { chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").ok()
// Convert date to age in seconds .and_then(|nd| nd.and_hms_opt(0, 0, 0))
if let Ok(nd) = chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") { .map(|dt| dt.and_utc().timestamp())
let ts = nd.and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp(); });
let age = chrono::Utc::now().timestamp() - ts;
q.push_str(&std::format!(" | age:<{}", age));
}
}
q.push_str(&std::format!(" | limit:{}", count));
memory_query(store, _provenance, &q, Some(format)) let mut entries: Vec<_> = store.nodes.values()
.filter(|n| n.node_type == node_type)
.filter(|n| after_ts.map(|ts| n.created_at >= ts).unwrap_or(true))
.map(|n| JournalEntry {
key: n.key.clone(),
content: n.content.clone(),
created_at: n.created_at,
})
.collect();
entries.sort_by_key(|e| std::cmp::Reverse(e.created_at));
entries.truncate(count);
Ok(entries)
} }
fn level_to_node_type(level: i64) -> crate::store::NodeType { fn level_to_node_type(level: i64) -> crate::store::NodeType {