use anyhow::{Context, Result}; use std::fs::{File, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; use crate::agent::context::AstNode; use crate::hippocampus::transcript::JsonlBackwardIter; use memmap2::Mmap; pub struct ConversationLog { path: PathBuf, } impl ConversationLog { pub fn new(path: PathBuf) -> Result { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("creating log dir {}", parent.display()))?; } Ok(Self { path }) } pub fn append_node(&self, node: &AstNode) -> Result<()> { let mut file = OpenOptions::new() .create(true) .append(true) .open(&self.path) .with_context(|| format!("opening log {}", self.path.display()))?; let line = serde_json::to_string(node) .context("serializing node for log")?; writeln!(file, "{}", line) .context("writing to conversation log")?; file.sync_all() .context("syncing conversation log")?; Ok(()) } /// Read nodes from the tail of the log, newest first. /// Caller decides when to stop (budget, count, etc). pub fn read_tail(&self) -> Result { if !self.path.exists() { anyhow::bail!("log does not exist"); } let file = File::open(&self.path) .with_context(|| format!("opening log {}", self.path.display()))?; if file.metadata()?.len() == 0 { anyhow::bail!("log is empty"); } let mmap = unsafe { Mmap::map(&file)? }; Ok(TailNodes { _file: file, mmap }) } pub fn path(&self) -> &Path { &self.path } pub fn oldest_timestamp(&self) -> Option> { // Read forward from the start to find first timestamp let file = File::open(&self.path).ok()?; let mmap = unsafe { Mmap::map(&file).ok()? }; // Find first { ... } and parse for line in mmap.split(|&b| b == b'\n') { if line.is_empty() { continue; } if let Ok(node) = serde_json::from_slice::(line) { if let Some(leaf) = node.leaf() { if let Some(ts) = leaf.timestamp() { return Some(ts); } } } } None } } /// Iterates over conversation log nodes newest-first, using mmap + backward scan. pub struct TailNodes { _file: File, mmap: Mmap, } impl TailNodes { pub fn iter(&self) -> impl Iterator + '_ { JsonlBackwardIter::new(&self.mmap) .filter_map(|bytes| serde_json::from_slice::(bytes).ok()) } }