use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use llmtrim_core::cache_zone;
use llmtrim_core::ir::{ProviderKind, Request};
use llmtrim_core::provider::{self, Provider, Role};
use llmtrim_core::tokenizer::{self, TokenCounter};
use serde::Serialize;
use serde_json::Value;
use crate::ui;
#[derive(Default)]
struct Counts {
tokens: u64,
bytes: u64,
live_tokens: u64,
}
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
struct BucketKey {
kind: String,
tool: Option<String>,
}
#[derive(Default, Clone, Serialize)]
struct BucketStat {
residual_tokens: u64,
residual_bytes: u64,
live_tokens: u64,
before_tokens: u64,
captures: u64,
}
#[derive(Serialize)]
struct Report {
captures: u64,
skipped: u64,
input_before_tokens: u64,
input_after_tokens: u64,
realized_savings_pct: f64,
total_residual_tokens: u64,
live_residual_tokens: u64,
rows: Vec<ReportRow>,
}
#[derive(Serialize)]
struct ReportRow {
kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
tool: Option<String>,
residual_tokens: u64,
residual_bytes: u64,
residual_share_pct: f64,
live_tokens: u64,
live_pct: f64,
compressed_pct: f64,
captures: u64,
}
pub fn run(dir: Option<PathBuf>, json: bool, by_tool: bool, limit: Option<usize>) -> Result<()> {
let dir = resolve_dir(dir)?;
let report = scan(&dir, by_tool, limit)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&report).context("serialize discover report")?
);
} else {
print!("{}", render(&report, by_tool, ui::color_stdout()));
}
Ok(())
}
fn resolve_dir(dir: Option<PathBuf>) -> Result<PathBuf> {
if let Some(d) = dir {
return Ok(d);
}
if let Some(d) = llmtrim_core::config::RuntimeConfig::get()
.capture_dir
.clone()
{
return Ok(d);
}
Ok(crate::daemon::home_dir()?.join("capture"))
}
fn scan(dir: &Path, by_tool: bool, limit: Option<usize>) -> Result<Report> {
let entries =
std::fs::read_dir(dir).with_context(|| format!("read capture dir {}", dir.display()))?;
let mut paths: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "json"))
.collect();
paths.sort(); if let Some(n) = limit {
paths.truncate(n);
}
let mut buckets: BTreeMap<BucketKey, BucketStat> = BTreeMap::new();
let mut captures = 0u64;
let mut skipped = 0u64;
let mut input_before = 0u64;
let mut input_after = 0u64;
for path in &paths {
match accumulate(path, by_tool, &mut buckets) {
Ok(Some((before, after))) => {
captures += 1;
input_before += before;
input_after += after;
}
Ok(None) => skipped += 1,
Err(e) => {
eprintln!("llmtrim discover: skipping {}: {e:#}", path.display());
skipped += 1;
}
}
}
let total_residual: u64 = buckets.values().map(|s| s.residual_tokens).sum();
let live_residual: u64 = buckets.values().map(|s| s.live_tokens).sum();
let mut rows: Vec<ReportRow> = buckets
.into_iter()
.map(|(k, s)| ReportRow {
kind: k.kind,
tool: k.tool,
residual_tokens: s.residual_tokens,
residual_bytes: s.residual_bytes,
residual_share_pct: pct(s.residual_tokens, total_residual),
live_tokens: s.live_tokens,
live_pct: pct(s.live_tokens, s.residual_tokens),
compressed_pct: ui::saved_pct(s.before_tokens as f64, s.residual_tokens as f64),
captures: s.captures,
})
.collect();
rows.sort_by(|a, b| {
b.residual_tokens
.cmp(&a.residual_tokens)
.then_with(|| a.kind.cmp(&b.kind))
.then_with(|| a.tool.cmp(&b.tool))
});
Ok(Report {
captures,
skipped,
input_before_tokens: input_before,
input_after_tokens: input_after,
realized_savings_pct: ui::saved_pct(input_before as f64, input_after as f64),
total_residual_tokens: total_residual,
live_residual_tokens: live_residual,
rows,
})
}
fn accumulate(
path: &Path,
by_tool: bool,
buckets: &mut BTreeMap<BucketKey, BucketStat>,
) -> Result<Option<(u64, u64)>> {
let raw = std::fs::read_to_string(path)?;
if raw.trim().is_empty() {
return Ok(None);
}
let env: Value = serde_json::from_str(&raw)?;
let (Some(before_str), Some(after_str)) = (
env.get("before").and_then(Value::as_str),
env.get("after").and_then(Value::as_str),
) else {
return Ok(None);
};
let before: Value = serde_json::from_str(before_str)?;
let after: Value = serde_json::from_str(after_str)?;
let kind = provider_kind(&env, &after);
let counter = tokenizer::counter_for(kind, env.get("model").and_then(Value::as_str))
.context("build token counter")?;
let after_b = bucketize(kind, &after, counter.as_ref(), by_tool, true);
let before_b = bucketize(kind, &before, counter.as_ref(), by_tool, false);
for (key, c) in &after_b {
let stat = buckets.entry(key.clone()).or_default();
stat.residual_tokens += c.tokens;
stat.residual_bytes += c.bytes;
stat.live_tokens += c.live_tokens;
stat.captures += 1;
}
for (key, c) in &before_b {
buckets.entry(key.clone()).or_default().before_tokens += c.tokens;
}
let input_before = env.get("input_before").and_then(Value::as_u64).unwrap_or(0);
let input_after = env.get("input_after").and_then(Value::as_u64).unwrap_or(0);
Ok(Some((input_before, input_after)))
}
fn provider_kind(env: &Value, body: &Value) -> ProviderKind {
env.get("provider")
.and_then(Value::as_str)
.and_then(|s| s.parse::<ProviderKind>().ok())
.or_else(|| provider::detect(body))
.unwrap_or(ProviderKind::OpenAi)
}
fn bucketize(
kind: ProviderKind,
body: &Value,
counter: &dyn TokenCounter,
by_tool: bool,
track_live: bool,
) -> BTreeMap<BucketKey, Counts> {
let req = Request::from_value(kind, body.clone());
let prov = provider::for_kind(kind);
let id2name = tool_id_names(body);
let frozen = track_live.then(|| cache_zone::frozen_pointers(&req, prov.as_ref()));
let mut out: BTreeMap<BucketKey, Counts> = BTreeMap::new();
for ptr in prov.content_text_pointers(&req) {
let Some(text) = req.get_str(&ptr) else {
continue;
};
let (kind_label, tool) = classify(&req, prov.as_ref(), &ptr, &id2name);
let key = bucket_key(kind_label, tool, by_tool);
let tokens = counter.count(text) as u64;
let slot = out.entry(key).or_default();
slot.tokens += tokens;
slot.bytes += text.len() as u64;
if let Some(frozen) = &frozen
&& !frozen.contains(&ptr)
{
slot.live_tokens += tokens;
}
}
if let Some(tools) = body.get("tools").filter(|t| !t.is_null()) {
let s = tools.to_string();
let tokens = counter.count(&s) as u64;
let slot = out
.entry(BucketKey {
kind: "tool_schema".to_string(),
tool: None,
})
.or_default();
slot.tokens += tokens;
slot.bytes += s.len() as u64;
if let Some(frozen) = &frozen
&& frozen.is_empty()
{
slot.live_tokens += tokens;
}
}
out
}
fn bucket_key(kind: &str, tool: Option<String>, by_tool: bool) -> BucketKey {
let tool = if by_tool && kind == "tool_result" {
tool.or_else(|| Some("unknown".to_string()))
} else {
None
};
BucketKey {
kind: kind.to_string(),
tool,
}
}
fn classify(
req: &Request,
prov: &dyn Provider,
ptr: &str,
id2name: &BTreeMap<String, String>,
) -> (&'static str, Option<String>) {
let raw = req.raw();
let segs: Vec<&str> = ptr.split('/').collect();
if segs.get(1) == Some(&"messages")
&& let Some(i) = segs.get(2)
{
if segs.get(3) == Some(&"tool_calls") {
let name = segs.get(4).and_then(|j| {
raw.pointer(&format!("/messages/{i}/tool_calls/{j}/function/name"))
.and_then(Value::as_str)
.map(str::to_string)
});
return ("tool_call_args", name);
}
if segs.get(3) == Some(&"content") {
if segs.len() == 4 {
if prov.role_at(req, ptr) == Some(Role::Tool) {
let name = raw
.pointer(&format!("/messages/{i}/tool_call_id"))
.and_then(Value::as_str)
.and_then(|id| id2name.get(id))
.cloned();
return ("tool_result", name);
}
} else if let Some(j) = segs.get(4) {
let block = raw.pointer(&format!("/messages/{i}/content/{j}"));
match block.and_then(|b| b.get("type")).and_then(Value::as_str) {
Some("tool_result") => {
let name = block
.and_then(|b| b.get("tool_use_id"))
.and_then(Value::as_str)
.and_then(|id| id2name.get(id))
.cloned();
return ("tool_result", name);
}
Some("tool_use") => {
let name = block
.and_then(|b| b.get("name"))
.and_then(Value::as_str)
.map(str::to_string);
return ("tool_call_args", name);
}
Some("document") => return ("document", None),
_ => {}
}
}
}
}
(role_label(prov, req, ptr), None)
}
fn role_label(prov: &dyn Provider, req: &Request, ptr: &str) -> &'static str {
match prov.role_at(req, ptr) {
None | Some(Role::System) => "system",
Some(Role::User) => "user",
Some(Role::Assistant) => "assistant",
Some(Role::Tool) => "tool_result",
}
}
fn tool_id_names(body: &Value) -> BTreeMap<String, String> {
let mut map = BTreeMap::new();
let Some(messages) = body.get("messages").and_then(Value::as_array) else {
return map;
};
for msg in messages {
if let Some(blocks) = msg.get("content").and_then(Value::as_array) {
for b in blocks {
if b.get("type").and_then(Value::as_str) == Some("tool_use")
&& let (Some(id), Some(name)) = (
b.get("id").and_then(Value::as_str),
b.get("name").and_then(Value::as_str),
)
{
map.insert(id.to_string(), name.to_string());
}
}
}
if let Some(calls) = msg.get("tool_calls").and_then(Value::as_array) {
for c in calls {
if let (Some(id), Some(name)) = (
c.get("id").and_then(Value::as_str),
c.pointer("/function/name").and_then(Value::as_str),
) {
map.insert(id.to_string(), name.to_string());
}
}
}
}
map
}
fn pct(part: u64, whole: u64) -> f64 {
if whole == 0 {
0.0
} else {
part as f64 / whole as f64 * 100.0
}
}
fn render(report: &Report, by_tool: bool, color: bool) -> String {
let mut out = String::new();
out.push_str(&ui::panel(
color,
"discover",
&[format!(
"{} captures ({} skipped) input {} → {} tokens realized savings {:.1}% residual {} tokens ({} live / {:.1}% addressable)",
ui::commas(report.captures as i64),
ui::commas(report.skipped as i64),
ui::commas(report.input_before_tokens as i64),
ui::commas(report.input_after_tokens as i64),
report.realized_savings_pct,
ui::commas(report.total_residual_tokens as i64),
ui::commas(report.live_residual_tokens as i64),
pct(report.live_residual_tokens, report.total_residual_tokens),
)],
));
out.push('\n');
let label = if by_tool { "bucket" } else { "kind" };
let mut t = ui::table(
color,
&[
label,
"residual tok",
"share%",
"live tok",
"live%",
"compressed%",
"bytes",
"captures",
],
);
for r in &report.rows {
let name = match &r.tool {
Some(tool) => format!("{}:{tool}", r.kind),
None => r.kind.clone(),
};
t.add_row(vec![
name,
ui::commas(r.residual_tokens as i64),
format!("{:.1}", r.residual_share_pct),
ui::commas(r.live_tokens as i64),
format!("{:.1}", r.live_pct),
format!("{:.1}", r.compressed_pct),
ui::human(r.residual_bytes as i64),
ui::commas(r.captures as i64),
]);
}
out.push_str(&t.to_string());
out.push('\n');
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn fixtures() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/discover")
}
#[test]
fn skips_empty_and_malformed_captures() {
let report = scan(&fixtures(), false, None).unwrap();
assert_eq!(report.captures, 3);
assert_eq!(report.skipped, 2);
}
#[test]
fn buckets_reconstruct_stored_input_after() {
let report = scan(&fixtures(), false, None).unwrap();
let after = report.input_after_tokens as i64;
let residual = report.total_residual_tokens as i64;
assert!(
(after - residual).abs() <= after / 10,
"residual {residual} should track input_after {after}"
);
}
#[test]
fn attributes_tool_results_to_their_tool() {
let report = scan(&fixtures(), true, None).unwrap();
let bash = report
.rows
.iter()
.find(|r| r.kind == "tool_result" && r.tool.as_deref() == Some("Bash"));
assert!(bash.is_some(), "expected a tool_result:Bash bucket");
assert!(bash.unwrap().residual_tokens > 0);
assert!(
!report
.rows
.iter()
.any(|r| r.tool.as_deref() == Some("unknown")),
"no tool_result should be left unattributed"
);
}
#[test]
fn compressed_pct_reflects_before_to_after() {
let report = scan(&fixtures(), false, None).unwrap();
let system = report.rows.iter().find(|r| r.kind == "system").unwrap();
assert!(
system.compressed_pct > 0.0,
"system should show compression, got {}",
system.compressed_pct
);
}
#[test]
fn renders_ranked_human_table() {
let report = scan(&fixtures(), true, None).unwrap();
let out = render(&report, true, false); assert!(out.contains("3 captures"), "header totals:\n{out}");
assert!(out.contains("realized savings"), "header savings:\n{out}");
assert!(out.contains("addressable"), "header live split:\n{out}");
assert!(out.contains("tool_result:Bash"), "by-tool row:\n{out}");
assert!(out.contains("residual tok"), "table header:\n{out}");
assert!(out.contains("live tok"), "live column:\n{out}");
let first = report.rows.first().unwrap();
assert!(
report
.rows
.iter()
.all(|r| r.residual_tokens <= first.residual_tokens),
"rows must be sorted by residual descending"
);
}
#[test]
fn cache_control_freezes_residual_from_live_count() {
let report = scan(&fixtures(), false, None).unwrap();
assert!(
report.live_residual_tokens < report.total_residual_tokens,
"the cache_control fixture should leave some residual frozen: live {} vs total {}",
report.live_residual_tokens,
report.total_residual_tokens
);
let user = report.rows.iter().find(|r| r.kind == "user").unwrap();
assert!(
user.live_tokens < user.residual_tokens,
"user bucket should have frozen residual: live {} vs residual {}",
user.live_tokens,
user.residual_tokens
);
}
#[test]
fn no_cache_control_means_all_residual_is_live() {
let report = scan(&fixtures(), false, Some(2)).unwrap();
assert_eq!(report.captures, 2);
assert_eq!(report.live_residual_tokens, report.total_residual_tokens);
}
}