use serde_json::Value;
use std::{collections::HashMap, path::Path};
use crate::{
apps::fresh_input,
error::AppError,
io::load,
model::{AppKind, UsageEntry},
};
const MAX_DEPTH: usize = 4;
pub fn collect() -> Result<Vec<UsageEntry>, AppError> {
let base = load::home_dir()?.join(".grok");
if !base.is_dir() {
return Ok(Vec::new());
}
collect_from(&base)
}
pub fn collect_from(base: &Path) -> Result<Vec<UsageEntry>, AppError> {
let mut candidates: HashMap<String, UsageEntry> = HashMap::new();
for root in ["sessions", "archived_sessions"] {
for file in load::discover_files(&base.join(root), "jsonl", MAX_DEPTH) {
if file.file_name().and_then(|n| n.to_str()) != Some("updates.jsonl") {
continue;
}
parse_updates(&file, &mut candidates);
}
}
Ok(candidates.into_values().collect())
}
fn parse_updates(file: &Path, candidates: &mut HashMap<String, UsageEntry>) {
let Ok(records) = load::read_jsonl(file) else {
return;
};
let session_id = file
.parent()
.and_then(|d| d.file_name())
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let mut event_index = 0usize;
for record in records {
if load::str_get(&record, &["method"]) != Some("_x.ai/session/update") {
continue;
}
let Some(update) = record.get("params").and_then(|p| p.get("update")) else {
continue;
};
let kind = load::str_get(update, &["sessionUpdate"]);
if kind.is_some() && kind != Some("turn_completed") {
continue;
}
let Some(usage) = update.get("usage").filter(|u| u.is_object()) else {
continue;
};
let Some(created_at) = record.get("timestamp").and_then(load::timestamp_to_epoch) else {
continue;
};
let prompt_id = load::str_get(update, &["prompt_id"]).unwrap_or("");
let turn_key = if prompt_id.is_empty() {
format!("idx{event_index}")
} else {
prompt_id.to_string()
};
event_index += 1;
let event_partial = load::bool_get(usage, &["costIsPartial"]);
for (model, counters) in per_model(usage) {
let input = load::u64_get(counters, &["inputTokens"]);
let output = load::u64_get(counters, &["outputTokens"]);
let cached = load::u64_get(counters, &["cachedReadTokens"]);
let ticks = load::u64_get(counters, &["costUsdTicks"]);
let partial = event_partial || load::bool_get(counters, &["costIsPartial"]);
let self_cost = (ticks > 0 && !partial).then(|| ticks as f64 / 1e10);
if input == 0 && output == 0 && cached == 0 && self_cost.is_none() {
continue;
}
let input = fresh_input(input, cached, 0);
candidates.insert(
format!("{session_id}:{turn_key}:{model}"),
UsageEntry::new(
AppKind::Grok,
model.to_string(),
Some(session_id.to_string()),
created_at,
input,
output,
cached,
0,
self_cost,
),
);
}
}
}
fn per_model(usage: &Value) -> Vec<(&str, &Value)> {
match usage.get("modelUsage").and_then(Value::as_object) {
Some(map) if !map.is_empty() => {
let mut pairs: Vec<(&str, &Value)> = map.iter().map(|(k, v)| (k.as_str(), v)).collect();
pairs.sort_unstable_by_key(|(k, _)| *k);
pairs
}
_ => vec![("unknown", usage)],
}
}
#[cfg(test)]
#[path = "tests/grok_test.rs"]
mod tests;