use serde_json::Value;
use std::{
collections::{HashMap, hash_map::Entry},
ffi::OsStr,
path::{Path, PathBuf},
};
use crate::{
apps::normalize_model,
error::AppError,
io::{load, progress::Progress},
model::{AppKind, UsageEntry},
};
const MAX_DEPTH: usize = 5;
fn kimi_base(home: &Path, env: Option<&OsStr>) -> PathBuf {
load::env_abs_path("KIMI_CODE_HOME", env, home).unwrap_or_else(|| home.join(".kimi-code"))
}
pub fn collect(threads: Option<usize>) -> Result<Vec<UsageEntry>, AppError> {
let base = kimi_base(
&load::home_dir()?,
std::env::var_os("KIMI_CODE_HOME").as_deref(),
)
.join("sessions");
if !base.is_dir() {
return Ok(Vec::new());
}
collect_from_with(&base, threads)
}
#[cfg(test)]
pub fn collect_from(base: &Path) -> Result<Vec<UsageEntry>, AppError> {
collect_from_with(base, None)
}
fn collect_from_with(base: &Path, threads: Option<usize>) -> Result<Vec<UsageEntry>, AppError> {
let files: Vec<PathBuf> = load::discover_files(base, "jsonl", MAX_DEPTH)
.into_iter()
.filter(|f| load::file_name_str(f) == "wire.jsonl")
.collect();
let threads = threads.unwrap_or_else(|| load::auto_threads(files.len()));
let progress = Progress::start("kimi", load::total_bytes(&files), files.len());
let per_file: Vec<HashMap<String, UsageEntry>> =
load::map_files(&files, threads, &progress, parse_wire);
progress.finish();
let mut candidates: HashMap<String, UsageEntry> = HashMap::new();
for map in per_file {
for (key, entry) in map {
candidates.entry(key).or_insert(entry);
}
}
Ok(candidates.into_values().collect())
}
fn parse_wire(file: &Path, progress: &Progress) -> HashMap<String, UsageEntry> {
let mut candidates: HashMap<String, UsageEntry> = HashMap::new();
let session_id = file.ancestors().find_map(|p| {
p.file_name()
.and_then(|n| n.to_str())
.filter(|n| n.starts_with("session_"))
.map(str::to_string)
});
if let Err(e) = load::for_each_jsonl_progress(file, &[], progress, |record| {
if load::str_get(&record, &["type"]) != Some("usage.record") {
return true;
}
let scope = load::str_get(&record, &["usageScope"]);
if scope.is_some() && scope != Some("turn") {
return true;
}
let Some(usage) = record.get("usage").filter(|u| u.is_object()) else {
return true;
};
let input = load::u64_get(usage, &["inputOther"]);
let output = load::u64_get(usage, &["output"]);
let cache_read = load::u64_get(usage, &["inputCacheRead"]);
let cache_creation = load::u64_get(usage, &["inputCacheCreation"]);
if input == 0 && output == 0 && cache_read == 0 && cache_creation == 0 {
return true;
}
let model = load::str_get(&record, &["model"])
.map_or_else(|| "unknown".to_string(), normalize_model);
let created_at = record
.get("time")
.and_then(load::timestamp_to_epoch)
.unwrap_or_else(load::now_epoch);
let time_raw = record
.get("time")
.and_then(Value::as_i64)
.unwrap_or(created_at);
let agent_id = load::str_get(&record, &["agentId"]).unwrap_or("main");
let key =
format!("{agent_id}:{time_raw}:{model}:{input}:{output}:{cache_read}:{cache_creation}");
match candidates.entry(key) {
Entry::Vacant(vacant) => {
vacant.insert(UsageEntry::new(
AppKind::Kimi,
model,
session_id.clone(),
created_at,
input,
output,
cache_read,
cache_creation,
None,
));
}
Entry::Occupied(_) => {}
}
true
}) {
load::warn_file(&e);
progress.note_error();
}
candidates
}
#[cfg(test)]
#[path = "tests/kimi_test.rs"]
mod tests;