use serde_json::Value;
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use crate::{
apps::normalize_model,
error::AppError,
io::load,
model::{AppKind, UsageEntry},
};
const MAX_DEPTH: usize = 4;
pub fn collect() -> Result<Vec<UsageEntry>, AppError> {
let home = load::home_dir()?;
let mut roots: Vec<PathBuf> = Vec::new();
if let Some(raw) = std::env::var_os("PI_CODING_AGENT_SESSION_DIR") {
let s = raw.to_string_lossy();
let path = if let Some(suffix) = s.strip_prefix("~/") {
home.join(suffix)
} else if s == "~" {
home.clone()
} else {
PathBuf::from(s.as_ref())
};
if path.is_absolute() {
roots.push(path);
}
}
roots.push(home.join(".pi").join("agent").join("sessions"));
roots.push(home.join(".pi").join("sessions"));
collect_from(&roots)
}
pub fn collect_from(roots: &[PathBuf]) -> Result<Vec<UsageEntry>, AppError> {
let mut candidates: HashMap<String, UsageEntry> = HashMap::new();
let mut seen_files = std::collections::HashSet::new();
for root in roots {
for file in load::discover_files(root, "jsonl", MAX_DEPTH) {
if !seen_files.insert(file.clone()) {
continue;
}
parse_session(&file, &mut candidates);
}
}
Ok(candidates.into_values().collect())
}
fn parse_session(file: &Path, candidates: &mut HashMap<String, UsageEntry>) {
let Ok(records) = load::read_jsonl(file) else {
return;
};
let Some(first) = records.first() else {
return;
};
if load::str_get(first, &["type"]) != Some("session") {
return;
}
let session_id = load::str_get(first, &["id"]).unwrap_or("unknown");
let header_ts = first.get("timestamp").and_then(load::timestamp_to_epoch);
for entry in records.iter().skip(1) {
if let Some((key, record)) = parse_entry(entry, session_id, header_ts) {
candidates.insert(key, record);
}
}
}
fn parse_entry(
entry: &Value,
session_id: &str,
header_ts: Option<i64>,
) -> Option<(String, UsageEntry)> {
let entry_type = load::str_get(entry, &["type"])?;
let (kind, message, usage) = match entry_type {
"message" => {
let message = entry.get("message").filter(|m| m.is_object())?;
let usage = message.get("usage").filter(|u| u.is_object())?;
match load::str_get(message, &["role"]) {
Some("assistant") => ("assistant", Some(message), usage),
Some("toolResult") => ("tool_result", Some(message), usage),
_ => return None,
}
}
"compaction" | "branch_summary" => (
entry_type,
None,
entry.get("usage").filter(|u| u.is_object())?,
),
_ => return None,
};
let input = load::u64_get(usage, &["input"]);
let output = load::u64_get(usage, &["output"]);
let cache_read = load::u64_get(usage, &["cacheRead"]);
let cache_write = load::u64_get(usage, &["cacheWrite"]);
let self_cost = load::cost_get(usage, &["cost", "total"]);
if input == 0 && output == 0 && cache_read == 0 && cache_write == 0 && self_cost.is_none() {
return None;
}
let model = if let Some(message) = message.filter(|_| kind == "assistant") {
nonempty_str(message, &["responseModel"])
.or_else(|| nonempty_str(message, &["model"]))
.map_or_else(|| "unknown".to_string(), normalize_model)
} else {
"unknown".to_string()
};
let created_at = entry
.get("timestamp")
.and_then(load::timestamp_to_epoch)
.or_else(|| {
message
.and_then(|m| m.get("timestamp"))
.and_then(load::timestamp_to_epoch)
})
.or(header_ts)
.unwrap_or_else(load::now_epoch);
let key = match load::str_get(entry, &["id"]).filter(|s| !s.is_empty()) {
Some(id) => format!("id:{kind}:{id}"),
None => format!("hash:{kind}:{}", content_hash(entry)),
};
Some((
key,
UsageEntry::new(
AppKind::Pi,
model,
Some(session_id.to_string()),
created_at,
input,
output,
cache_read,
cache_write,
self_cost,
),
))
}
fn nonempty_str<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a str> {
load::str_get(value, keys).filter(|s| !s.trim().is_empty())
}
fn content_hash(entry: &Value) -> u64 {
let mut hasher = DefaultHasher::new();
entry.hash(&mut hasher);
hasher.finish()
}
#[cfg(test)]
#[path = "tests/pi_test.rs"]
mod tests;