Journal: store as structured Vec<JournalEntry>, not String

Keep journal entries as structured data in ContextState. Render
to text only when building the context message. Debug screen reads
the structured entries directly — no parsing ## headers back out.

Compaction paths temporarily parse the string from build_context_window
back to entries (to be cleaned up when compaction is reworked).

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-02 02:21:45 -04:00
parent 42f1e888c4
commit 5526a26d4c
3 changed files with 52 additions and 50 deletions

View file

@ -66,7 +66,7 @@ pub fn parse_journal_tail(path: &Path, max_bytes: u64) -> Vec<JournalEntry> {
} }
/// Parse journal entries from text (separated for testing). /// Parse journal entries from text (separated for testing).
fn parse_journal_text(text: &str) -> Vec<JournalEntry> { pub fn parse_journal_text(text: &str) -> Vec<JournalEntry> {
let mut entries = Vec::new(); let mut entries = Vec::new();
let mut current_timestamp: Option<DateTime<Utc>> = None; let mut current_timestamp: Option<DateTime<Utc>> = None;
let mut current_content = String::new(); let mut current_content = String::new();

View file

@ -78,6 +78,15 @@ pub struct Agent {
pub agent_cycles: crate::subconscious::subconscious::AgentCycleState, pub agent_cycles: crate::subconscious::subconscious::AgentCycleState,
} }
fn render_journal(entries: &[journal::JournalEntry]) -> String {
let mut text = String::from("[Earlier — from your journal]\n\n");
for entry in entries {
use std::fmt::Write;
writeln!(text, "## {}\n{}\n", entry.timestamp.format("%Y-%m-%dT%H:%M"), entry.content).ok();
}
text
}
impl Agent { impl Agent {
pub fn new( pub fn new(
client: ApiClient, client: ApiClient,
@ -93,7 +102,7 @@ impl Agent {
let context = ContextState { let context = ContextState {
system_prompt: system_prompt.clone(), system_prompt: system_prompt.clone(),
personality, personality,
journal: String::new(), journal: Vec::new(),
working_stack: Vec::new(), working_stack: Vec::new(),
loaded_nodes: Vec::new(), loaded_nodes: Vec::new(),
}; };
@ -125,7 +134,7 @@ impl Agent {
agent.push_context(Message::user(rendered)); agent.push_context(Message::user(rendered));
} }
if !agent.context.journal.is_empty() { if !agent.context.journal.is_empty() {
agent.push_context(Message::user(agent.context.journal.clone())); agent.push_context(Message::user(render_journal(&agent.context.journal)));
} }
agent.measure_budget(); agent.measure_budget();
agent.publish_context_state(); agent.publish_context_state();
@ -638,41 +647,21 @@ impl Agent {
children: personality_children, children: personality_children,
}); });
// Journal — split into per-entry children // Journal
{ {
let mut journal_children = Vec::new(); let journal_children: Vec<ContextSection> = self.context.journal.iter()
let mut current_header = String::new(); .map(|entry| {
let mut current_body = String::new(); let preview: String = entry.content.lines()
for line in self.context.journal.lines() { .find(|l| !l.trim().is_empty())
if line.starts_with("## ") { .unwrap_or("").chars().take(60).collect();
if !current_header.is_empty() { ContextSection {
let body = std::mem::take(&mut current_body); name: format!("{}: {}", entry.timestamp.format("%Y-%m-%dT%H:%M"), preview),
let preview: String = body.lines().next().unwrap_or("").chars().take(60).collect(); tokens: count(&entry.content),
journal_children.push(ContextSection { content: entry.content.clone(),
name: format!("{}: {}", current_header, preview),
tokens: count(&body),
content: body,
children: Vec::new(), children: Vec::new(),
});
}
current_header = line.trim_start_matches("## ").to_string();
current_body.clear();
} else {
if !current_body.is_empty() || !line.is_empty() {
current_body.push_str(line);
current_body.push('\n');
}
}
}
if !current_header.is_empty() {
let preview: String = current_body.lines().next().unwrap_or("").chars().take(60).collect();
journal_children.push(ContextSection {
name: format!("{}: {}", current_header, preview),
tokens: count(&current_body),
content: current_body,
children: Vec::new(),
});
} }
})
.collect();
let journal_tokens: usize = journal_children.iter().map(|c| c.tokens).sum(); let journal_tokens: usize = journal_children.iter().map(|c| c.tokens).sum();
sections.push(ContextSection { sections.push(ContextSection {
name: format!("Journal ({} entries)", journal_children.len()), name: format!("Journal ({} entries)", journal_children.len()),
@ -798,16 +787,31 @@ impl Agent {
let mut journal_nodes: Vec<_> = store.nodes.values() let mut journal_nodes: Vec<_> = store.nodes.values()
.filter(|n| n.node_type == crate::store::NodeType::EpisodicSession) .filter(|n| n.node_type == crate::store::NodeType::EpisodicSession)
.collect(); .collect();
let mut dbg = std::fs::OpenOptions::new().create(true).append(true)
.open("/tmp/poc-journal-debug.log").ok();
macro_rules! dbg_log {
($($arg:tt)*) => {
if let Some(ref mut f) = dbg { use std::io::Write; let _ = writeln!(f, $($arg)*); }
}
}
dbg_log!("[journal] {} nodes, oldest_msg={:?}", journal_nodes.len(), oldest_msg_ts);
journal_nodes.sort_by_key(|n| n.created_at); journal_nodes.sort_by_key(|n| n.created_at);
if let Some(first) = journal_nodes.first() {
dbg_log!("[journal] first created_at={}", first.created_at);
}
if let Some(last) = journal_nodes.last() {
dbg_log!("[journal] last created_at={}", last.created_at);
}
// Find the cutoff index — entries older than conversation, plus one overlap // Find the cutoff index — entries older than conversation, plus one overlap
let cutoff_idx = if let Some(cutoff) = oldest_msg_ts { let cutoff_idx = if let Some(cutoff) = oldest_msg_ts {
let cutoff_ts = cutoff.timestamp();
dbg_log!("[journal] cutoff timestamp={}", cutoff_ts);
let mut idx = journal_nodes.len(); let mut idx = journal_nodes.len();
for (i, node) in journal_nodes.iter().enumerate() { for (i, node) in journal_nodes.iter().enumerate() {
let ts = chrono::DateTime::from_timestamp(node.created_at, 0) if node.created_at >= cutoff_ts {
.unwrap_or_default(); idx = i + 1;
if ts >= cutoff {
idx = i + 1; // include this overlapping entry
break; break;
} }
} }
@ -815,11 +819,13 @@ impl Agent {
} else { } else {
journal_nodes.len() journal_nodes.len()
}; };
dbg_log!("[journal] cutoff_idx={}", cutoff_idx);
// Walk backwards from cutoff, accumulating entries within 5% of context // Walk backwards from cutoff, accumulating entries within 5% of context
let count = |s: &str| self.tokenizer.encode_with_special_tokens(s).len(); let count = |s: &str| self.tokenizer.encode_with_special_tokens(s).len();
let context_window = crate::agent::context::model_context_window(&self.client.model); let context_window = crate::agent::context::model_context_window(&self.client.model);
let journal_budget = context_window * 5 / 100; let journal_budget = context_window * 5 / 100;
dbg_log!("[journal] budget={} tokens ({}*5%)", journal_budget, context_window);
let mut entries = Vec::new(); let mut entries = Vec::new();
let mut total_tokens = 0; let mut total_tokens = 0;
@ -837,18 +843,14 @@ impl Agent {
total_tokens += tokens; total_tokens += tokens;
} }
entries.reverse(); entries.reverse();
dbg_log!("[journal] loaded {} entries, {} tokens", entries.len(), total_tokens);
if entries.is_empty() { if entries.is_empty() {
dbg_log!("[journal] no entries!");
return; return;
} }
// Render directly — no plan_context needed self.context.journal = entries;
let mut text = String::from("[Earlier — from your journal]\n\n");
for entry in &entries {
use std::fmt::Write;
writeln!(text, "## {}\n{}\n", entry.timestamp.format("%Y-%m-%dT%H:%M"), entry.content).ok();
}
self.context.journal = text;
} }
/// Re-render the context message in self.messages from live ContextState. /// Re-render the context message in self.messages from live ContextState.
@ -1014,7 +1016,7 @@ impl Agent {
&self.client.model, &self.client.model,
&self.tokenizer, &self.tokenizer,
); );
self.context.journal = journal; self.context.journal = journal::parse_journal_text(&journal);
self.messages = messages; self.messages = messages;
self.last_prompt_tokens = 0; self.last_prompt_tokens = 0;
self.measure_budget(); self.measure_budget();
@ -1076,7 +1078,7 @@ impl Agent {
); );
dbglog!("[restore] journal text: {} chars, {} lines", dbglog!("[restore] journal text: {} chars, {} lines",
journal.len(), journal.lines().count()); journal.len(), journal.lines().count());
self.context.journal = journal; self.context.journal = journal::parse_journal_text(&journal);
self.messages = messages; self.messages = messages;
dbglog!("[restore] built context window: {} messages", self.messages.len()); dbglog!("[restore] built context window: {} messages", self.messages.len());
self.last_prompt_tokens = 0; self.last_prompt_tokens = 0;

View file

@ -326,7 +326,7 @@ impl ToolDef {
pub struct ContextState { pub struct ContextState {
pub system_prompt: String, pub system_prompt: String,
pub personality: Vec<(String, String)>, pub personality: Vec<(String, String)>,
pub journal: String, pub journal: Vec<crate::agent::journal::JournalEntry>,
pub working_stack: Vec<String>, pub working_stack: Vec<String>,
/// Memory nodes currently loaded in the context window. /// Memory nodes currently loaded in the context window.
/// Tracked so the agent knows what it's "seeing" and can /// Tracked so the agent knows what it's "seeing" and can