use anyhow::Result;
use chrono::DateTime;
use indicatif::MultiProgress;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::report::Usage;
use crate::sources::SourceOut;
use crate::sources::filecache::{self, CachedCall, Dict, Entry, Jsonl};
#[derive(Deserialize)]
struct Line {
#[serde(rename = "type")]
kind: Option<String>,
timestamp: Option<String>,
payload: Option<Payload>,
}
#[derive(Deserialize)]
struct Payload {
#[serde(rename = "type")]
kind: Option<String>,
model: Option<String>,
id: Option<String>,
session_id: Option<String>,
message: Option<String>,
role: Option<String>,
content: Option<serde_json::Value>,
cwd: Option<String>,
info: Option<Info>,
}
#[derive(Deserialize)]
struct Info {
last_token_usage: Option<Tokens>,
total_token_usage: Option<Tokens>,
}
#[derive(Deserialize)]
struct Tokens {
#[serde(default)]
input_tokens: u64,
#[serde(default)]
cached_input_tokens: u64,
#[serde(default)]
output_tokens: u64,
#[serde(default)]
reasoning_output_tokens: u64,
#[serde(default)]
total_tokens: u64,
}
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct State {
model: String,
sid: String,
name: Option<String>,
cwd: Option<String>,
}
pub fn dirs_for(root: &Path) -> Vec<PathBuf> {
vec![root.join("sessions"), root.join("archived_sessions")]
}
fn session_id_of(path: &Path) -> String {
let stem = path.file_stem().unwrap_or_default().to_string_lossy();
stem.rsplit('-')
.take(5) .collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("-")
}
pub struct Codex;
type Entries = Vec<CachedCall>;
fn set_name(state: &mut State, text: &str) {
let t = super::titleize(text);
if !t.is_empty() && !t.starts_with('<') && !t.starts_with('#') {
state.name.get_or_insert(t);
}
}
fn parse_file(
path: &Path,
offset: u64,
mut state: State,
dict: Vec<String>,
) -> std::io::Result<(u64, State, Vec<String>, Entries)> {
let mut dict = Dict::from_vec(dict);
let mut entries = Vec::new();
let consumed = filecache::read_lines(
path,
offset,
|s| serde_json::from_str::<Line>(s).is_ok(),
|line| {
if !line.contains("\"model\"")
&& !line.contains("token_count")
&& !line.contains("session_meta")
&& !line.contains("user_message")
&& !line.contains("response_item")
{
return;
}
let Ok(l) = serde_json::from_str::<Line>(line) else {
return;
};
let Some(p) = l.payload else { return };
match l.kind.as_deref() {
Some("session_meta") | Some("turn_context") => {
if let Some(m) = &p.model {
state.model = m.clone();
}
if let Some(id) = p.session_id.as_ref().or(p.id.as_ref()) {
state.sid = id.clone();
}
if let Some(c) = &p.cwd {
state.cwd.get_or_insert(c.clone());
}
}
Some("event_msg") if p.kind.as_deref() == Some("user_message") => {
if let Some(m) = &p.message {
set_name(&mut state, m);
}
}
Some("response_item")
if p.kind.as_deref() == Some("message")
&& p.role.as_deref() == Some("user")
&& state.name.is_none() =>
{
let text = match &p.content {
Some(serde_json::Value::String(s)) => Some(s.as_str()),
Some(serde_json::Value::Array(items)) => items
.iter()
.find_map(|it| it.get("text").and_then(|t| t.as_str())),
_ => None,
};
if let Some(t) = text {
set_name(&mut state, t);
}
}
Some("event_msg") if p.kind.as_deref() == Some("token_count") => {
let Some(info) = p.info else { return };
let (Some(last), Some(total)) = (info.last_token_usage, info.total_token_usage)
else {
return;
};
if last.input_tokens + last.output_tokens + last.reasoning_output_tokens == 0 {
return;
}
let ts = l.timestamp.clone().unwrap_or_default();
let total_b = total.total_tokens.to_le_bytes();
let key = filecache::key_of(&[state.sid.as_bytes(), ts.as_bytes(), &total_b]);
entries.push(CachedCall {
key,
session: dict.intern(&state.sid),
session_name: None,
model: dict.intern(&state.model),
ts: DateTime::parse_from_rfc3339(&ts)
.ok()
.map(|t| t.timestamp()),
usage: Usage {
input: last.input_tokens.saturating_sub(last.cached_input_tokens),
cached: last.cached_input_tokens,
output: last.output_tokens + last.reasoning_output_tokens,
},
estimated: false,
});
}
_ => {}
}
},
)?;
Ok((consumed, state, dict.into_strings(), entries))
}
impl Jsonl for Codex {
type State = State;
const SOURCE: &'static str = "codex";
fn fresh(path: &Path) -> State {
State {
model: "unknown".into(),
sid: session_id_of(path),
name: None,
cwd: None,
}
}
fn parse(
path: &Path,
offset: u64,
state: State,
dict: Vec<String>,
) -> std::io::Result<(u64, State, Vec<String>, Entries)> {
parse_file(path, offset, state, dict)
}
fn fixup(e: &mut Entry<State>) {
if e.state.model != "unknown"
&& let Some(u) = e.dict.iter().position(|s| s == "unknown")
{
let m = filecache::dict_get_or_push(&mut e.dict, &e.state.model);
for c in &mut e.entries {
if c.model == u as u32 {
c.model = m;
}
}
}
let name = e.state.name.clone().or_else(|| {
e.state
.cwd
.as_deref()
.and_then(|c| c.rsplit('/').next())
.map(str::to_string)
});
if let Some(name) = name {
let i = filecache::dict_get_or_push(&mut e.dict, &name);
for c in &mut e.entries {
c.session_name = Some(i);
}
}
}
}
pub type Scanner = filecache::Scanner<Codex>;
pub fn walk(dirs: &[PathBuf]) -> Vec<(PathBuf, u64)> {
let mut files = Vec::new();
for dir in dirs {
if !dir.exists() {
continue;
}
files.extend(
walkdir::WalkDir::new(dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.file_name()
.is_some_and(|n| n.to_string_lossy().starts_with("rollout-"))
&& e.path().extension().is_some_and(|x| x == "jsonl")
})
.filter_map(|e| e.metadata().ok().map(|m| (e.into_path(), m.len()))),
);
}
files
}
pub fn load(dirs: &[PathBuf], mp: &MultiProgress) -> Result<SourceOut> {
let t0 = std::time::Instant::now();
let mut sc = Scanner::open(dirs.to_vec());
let found = walk(dirs);
let t = sc.tick(&found, mp);
sc.save();
tracing::debug!(
files = t.files,
parsed = t.parsed,
bytes = t.bytes,
calls = t.calls.len(),
dupes = t.dupes,
elapsed = ?t0.elapsed(),
"codex source"
);
let mut note = format!("codex: {} rollout files · {} reparsed", t.files, t.parsed);
if t.dupes > 0 {
note.push_str(&format!(" · {} dupes skipped", t.dupes));
}
Ok(SourceOut {
calls: t.calls,
note,
})
}