use normalize_chat_sessions::{ContentBlock, Session};
use normalize_output::OutputFormatter;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema, Deserialize)]
pub struct ToolStats {
pub name: String,
pub calls: usize,
pub errors: usize,
pub output_chars: usize,
}
#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema, Deserialize)]
pub struct LargestToolResult {
pub tool_name: String,
pub chars: usize,
pub turn: usize,
pub preview: String,
}
impl ToolStats {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
calls: 0,
errors: 0,
output_chars: 0,
}
}
pub fn success_rate(&self) -> f64 {
if self.calls == 0 {
0.0
} else {
(self.calls - self.errors) as f64 / self.calls as f64
}
}
}
#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema, Deserialize)]
pub struct TokenStats {
pub total_input: u64,
pub total_output: u64,
pub cache_read: u64,
pub cache_create: u64,
pub min_context: u64,
pub max_context: u64,
pub api_calls: usize,
}
#[derive(Debug, Clone, Copy)]
pub struct ModelPricing {
pub name: &'static str,
pub input_per_mtok: f64,
pub output_per_mtok: f64,
pub cache_write_per_mtok: f64,
pub cache_read_per_mtok: f64,
}
impl ModelPricing {
pub const SONNET_4_5: ModelPricing = ModelPricing {
name: "Claude Sonnet 4.5",
input_per_mtok: 3.0,
output_per_mtok: 15.0,
cache_write_per_mtok: 3.75,
cache_read_per_mtok: 0.30,
};
pub const SONNET_3_7: ModelPricing = ModelPricing {
name: "Claude Sonnet 3.7",
input_per_mtok: 3.0,
output_per_mtok: 15.0,
cache_write_per_mtok: 3.75,
cache_read_per_mtok: 0.30,
};
pub const SONNET_3_5: ModelPricing = ModelPricing {
name: "Claude Sonnet 3.5",
input_per_mtok: 3.0,
output_per_mtok: 15.0,
cache_write_per_mtok: 3.75,
cache_read_per_mtok: 0.30,
};
pub const SONNET_3: ModelPricing = ModelPricing {
name: "Claude Sonnet 3",
input_per_mtok: 3.0,
output_per_mtok: 15.0,
cache_write_per_mtok: 3.75,
cache_read_per_mtok: 0.30,
};
pub const OPUS_4_5: ModelPricing = ModelPricing {
name: "Claude Opus 4.5/4.6",
input_per_mtok: 5.0,
output_per_mtok: 25.0,
cache_write_per_mtok: 6.25,
cache_read_per_mtok: 0.50,
};
pub const OPUS_3: ModelPricing = ModelPricing {
name: "Claude Opus 3/4/4.1",
input_per_mtok: 15.0,
output_per_mtok: 75.0,
cache_write_per_mtok: 18.75,
cache_read_per_mtok: 1.50,
};
pub const HAIKU_4_5: ModelPricing = ModelPricing {
name: "Claude Haiku 4.5",
input_per_mtok: 1.0,
output_per_mtok: 5.0,
cache_write_per_mtok: 1.25,
cache_read_per_mtok: 0.10,
};
pub const HAIKU_3_5: ModelPricing = ModelPricing {
name: "Claude Haiku 3.5",
input_per_mtok: 0.80,
output_per_mtok: 4.0,
cache_write_per_mtok: 1.0,
cache_read_per_mtok: 0.08,
};
pub const HAIKU_3: ModelPricing = ModelPricing {
name: "Claude Haiku 3",
input_per_mtok: 0.25,
output_per_mtok: 1.25,
cache_write_per_mtok: 0.30,
cache_read_per_mtok: 0.03,
};
pub fn from_model_str(model: &str) -> Option<&'static ModelPricing> {
let m = model.to_lowercase();
if m.contains("opus") {
if m.contains("4-5") || m.contains("4.5") || m.contains("4-6") || m.contains("4.6") {
Some(&Self::OPUS_4_5)
} else {
Some(&Self::OPUS_3)
}
} else if m.contains("sonnet") {
if m.contains("4-5") || m.contains("4.5") || m.contains("4-6") || m.contains("4.6") {
Some(&Self::SONNET_4_5)
} else if m.contains("3-7") || m.contains("3.7") {
Some(&Self::SONNET_3_7)
} else if m.contains("3-5") || m.contains("3.5") {
Some(&Self::SONNET_3_5)
} else if m.contains("-3") || m.ends_with("3") {
Some(&Self::SONNET_3)
} else {
Some(&Self::SONNET_4_5)
}
} else if m.contains("haiku") {
if m.contains("4") {
Some(&Self::HAIKU_4_5)
} else if m.contains("3-5") || m.contains("3.5") {
Some(&Self::HAIKU_3_5)
} else {
Some(&Self::HAIKU_3)
}
} else {
None
}
}
pub fn calculate_turn_cost(&self, usage: &normalize_chat_sessions::TokenUsage) -> f64 {
let input_cost = (usage.input as f64 / 1_000_000.0) * self.input_per_mtok;
let output_cost = (usage.output as f64 / 1_000_000.0) * self.output_per_mtok;
let cache_write_cost =
(usage.cache_create.unwrap_or(0) as f64 / 1_000_000.0) * self.cache_write_per_mtok;
let cache_read_cost =
(usage.cache_read.unwrap_or(0) as f64 / 1_000_000.0) * self.cache_read_per_mtok;
input_cost + output_cost + cache_write_cost + cache_read_cost
}
pub fn calculate_cost(&self, stats: &TokenStats) -> CostBreakdown {
let input_cost = (stats.total_input as f64 / 1_000_000.0) * self.input_per_mtok;
let output_cost = (stats.total_output as f64 / 1_000_000.0) * self.output_per_mtok;
let cache_write_cost =
(stats.cache_create as f64 / 1_000_000.0) * self.cache_write_per_mtok;
let cache_read_cost = (stats.cache_read as f64 / 1_000_000.0) * self.cache_read_per_mtok;
let without_cache_input = stats.total_input + stats.cache_read;
let without_cache_cost = (without_cache_input as f64 / 1_000_000.0) * self.input_per_mtok;
let with_cache_cost = input_cost + cache_read_cost;
let cache_savings = without_cache_cost - with_cache_cost;
CostBreakdown {
model: self.name,
input_cost,
output_cost,
cache_write_cost,
cache_read_cost,
total_cost: input_cost + output_cost + cache_write_cost + cache_read_cost,
cache_savings,
}
}
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema, Deserialize)]
pub struct CostBreakdown {
pub model: &'static str,
pub input_cost: f64,
pub output_cost: f64,
pub cache_write_cost: f64,
pub cache_read_cost: f64,
pub total_cost: f64,
pub cache_savings: f64,
}
impl TokenStats {
pub fn avg_context(&self) -> u64 {
if self.api_calls == 0 {
0
} else {
(self.total_input + self.cache_read) / self.api_calls as u64
}
}
pub fn update_context(&mut self, context_size: u64) {
if self.min_context == 0 || context_size < self.min_context {
self.min_context = context_size;
}
if context_size > self.max_context {
self.max_context = context_size;
}
}
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema, Deserialize)]
pub struct ErrorPattern {
pub category: String,
pub count: usize,
pub examples: Vec<String>,
}
impl ErrorPattern {
pub fn new(category: impl Into<String>) -> Self {
Self {
category: category.into(),
count: 0,
examples: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema, Deserialize)]
pub struct ToolChain {
pub tools: Vec<String>,
pub turn_range: (usize, usize),
}
impl ToolChain {
pub fn len(&self) -> usize {
self.tools.len()
}
pub fn is_empty(&self) -> bool {
self.tools.is_empty()
}
pub fn potential_savings(&self) -> usize {
if self.len() <= 1 { 0 } else { self.len() - 1 }
}
pub fn is_safe_parallel(&self) -> bool {
self.tools.iter().all(|tool| {
matches!(
tool.as_str(),
"Read" | "Glob" | "Grep" | "Bash" | "Task" | "WebFetch" | "WebSearch"
)
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CorrectionKind {
Apology,
Mistake,
LetMeFix,
Actually,
}
impl CorrectionKind {
pub fn as_str(&self) -> &'static str {
match self {
CorrectionKind::Apology => "Apology",
CorrectionKind::Mistake => "Mistake",
CorrectionKind::LetMeFix => "Let me fix",
CorrectionKind::Actually => "Actually",
}
}
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema, Deserialize)]
pub struct Correction {
pub turn: usize,
pub text: String,
pub category: CorrectionKind,
}
#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema, Deserialize)]
pub struct FileOperation {
pub path: String,
pub reads: usize,
pub edits: usize,
pub writes: usize,
}
impl FileOperation {
pub fn total(&self) -> usize {
self.reads + self.edits + self.writes
}
}
#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema, Deserialize)]
pub struct CommandStats {
pub category: String,
pub commands: Vec<CommandDetail>,
pub total_calls: usize,
pub total_errors: usize,
pub output_tokens: u64,
}
#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema, Deserialize)]
pub struct CommandDetail {
pub pattern: String,
pub calls: usize,
pub errors: usize,
}
#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema, Deserialize)]
pub struct RetryHotspot {
pub pattern: String,
pub attempts: usize,
pub failures: usize,
pub output_tokens: u64,
pub turn_indices: Vec<usize>,
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema, Deserialize)]
pub struct ToolPattern {
pub tools: Vec<String>,
pub occurrences: usize,
}
impl ToolPattern {
pub fn pattern_str(&self) -> String {
self.tools.join(" → ")
}
}
#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema, Deserialize)]
pub struct DedupTokenStats {
pub unique_input: u64,
pub unique_output: u64,
pub total_billed: u64,
pub uniqueness_ratio: f64,
}
#[derive(Debug, Clone, Default, Serialize, schemars::JsonSchema, Deserialize)]
pub struct SessionAnalysisReport {
pub session_path: PathBuf,
pub format: String,
pub message_counts: HashMap<String, usize>,
pub tool_stats: HashMap<String, ToolStats>,
pub token_stats: TokenStats,
pub error_patterns: Vec<ErrorPattern>,
pub file_tokens: HashMap<String, u64>,
pub parallel_opportunities: usize,
pub total_turns: usize,
pub tool_chains: Vec<ToolChain>,
pub corrections: Vec<Correction>,
pub context_per_turn: Vec<u64>,
pub file_operations: HashMap<String, FileOperation>,
pub tool_patterns: Vec<ToolPattern>,
pub command_stats: Vec<CommandStats>,
pub retry_hotspots: Vec<RetryHotspot>,
pub actual_cost: Option<f64>,
pub dedup_tokens: Option<DedupTokenStats>,
pub largest_tool_results: Vec<LargestToolResult>,
#[serde(skip)]
#[schemars(skip)]
pub tool_sort: Option<String>,
}
impl SessionAnalysisReport {
pub fn new(session_path: PathBuf, format: impl Into<String>) -> Self {
Self {
session_path,
format: format.into(),
..Default::default()
}
}
pub fn total_tool_calls(&self) -> usize {
self.tool_stats.values().map(|t| t.calls).sum()
}
pub fn total_errors(&self) -> usize {
self.tool_stats.values().map(|t| t.errors).sum()
}
pub fn overall_success_rate(&self) -> f64 {
let total = self.total_tool_calls();
if total == 0 {
0.0
} else {
(total - self.total_errors()) as f64 / total as f64
}
}
pub fn format_text(&self) -> String {
let mut lines = vec![
"# Session Analysis".to_string(),
String::new(),
"## Summary".to_string(),
String::new(),
format!("- **Format**: {}", self.format),
format!("- **Tool calls**: {}", self.total_tool_calls()),
format!(
"- **Success rate**: {:.1}%",
self.overall_success_rate() * 100.0
),
format!("- **Total turns**: {}", self.total_turns),
format!(
"- **Parallel opportunities**: {}",
self.parallel_opportunities
),
String::new(),
];
if !self.message_counts.is_empty() {
lines.push("## Message Types".to_string());
lines.push(String::new());
lines.push("| Type | Count |".to_string());
lines.push("|------|-------|".to_string());
let mut counts: Vec<_> = self.message_counts.iter().collect();
counts.sort_by(|a, b| b.1.cmp(a.1));
for (msg_type, count) in counts {
lines.push(format!("| {} | {} |", msg_type, count));
}
lines.push(String::new());
}
if !self.tool_stats.is_empty() {
lines.push("## Tool Usage".to_string());
lines.push(String::new());
lines.push("| Tool | Calls | Errors | Success Rate |".to_string());
lines.push("|------|-------|--------|--------------|".to_string());
let mut tools: Vec<_> = self.tool_stats.values().collect();
sort_tool_stats_by_hint(&mut tools, self.tool_sort.as_deref());
for tool in tools {
lines.push(format!(
"| {} | {} | {} | {:.0}% |",
tool.name,
tool.calls,
tool.errors,
tool.success_rate() * 100.0
));
}
lines.push(String::new());
}
if !self.largest_tool_results.is_empty() {
lines.push("## Largest Tool Results".to_string());
lines.push(String::new());
lines.push("| Tool | Chars | Turn | Preview |".to_string());
lines.push("|------|-------|------|---------|".to_string());
for r in &self.largest_tool_results {
lines.push(format!(
"| {} | {} | {} | {} |",
r.tool_name, r.chars, r.turn, r.preview
));
}
lines.push(String::new());
}
if self.token_stats.api_calls > 0 {
let ts = &self.token_stats;
lines.push("## Token Usage".to_string());
lines.push(String::new());
lines.push(format!("- **API calls**: {}", ts.api_calls));
lines.push(format!("- **Input tokens**: {}", ts.total_input));
lines.push(format!("- **Output tokens**: {}", ts.total_output));
lines.push(format!(
"- **Total tokens**: {}",
ts.total_input + ts.total_output
));
if ts.cache_read > 0 {
lines.push(format!("- **Cache read**: {} tokens", ts.cache_read));
}
if ts.cache_create > 0 {
lines.push(format!("- **Cache create**: {} tokens", ts.cache_create));
}
lines.push(format!("- **Avg context**: {} tokens", ts.avg_context()));
lines.push(format!(
"- **Context range**: {} - {}",
ts.min_context, ts.max_context
));
lines.push(String::new());
lines.push("## Cost Estimate".to_string());
lines.push(String::new());
if let Some(actual) = self.actual_cost {
lines.push(format!("**Actual cost**: ${:.2}", actual));
lines.push(String::new());
let sonnet = ModelPricing::SONNET_4_5.calculate_cost(ts);
let opus = ModelPricing::OPUS_4_5.calculate_cost(ts);
let haiku = ModelPricing::HAIKU_4_5.calculate_cost(ts);
lines.push("**What-if pricing:**".to_string());
lines.push(format!(" - {}: ${:.2}", sonnet.model, sonnet.total_cost));
lines.push(format!(" - {}: ${:.2}", opus.model, opus.total_cost));
lines.push(format!(" - {}: ${:.2}", haiku.model, haiku.total_cost));
} else {
let sonnet = ModelPricing::SONNET_4_5.calculate_cost(ts);
lines.push(format!(
"**{} (default)**: ${:.2}",
sonnet.model, sonnet.total_cost
));
lines.push(format!(" - Input: ${:.2}", sonnet.input_cost));
lines.push(format!(" - Output: ${:.2}", sonnet.output_cost));
if sonnet.cache_write_cost > 0.0 {
lines.push(format!(" - Cache write: ${:.2}", sonnet.cache_write_cost));
}
if sonnet.cache_read_cost > 0.0 {
lines.push(format!(" - Cache read: ${:.2}", sonnet.cache_read_cost));
}
if sonnet.cache_savings > 0.0 {
let savings_pct =
(sonnet.cache_savings / (sonnet.total_cost + sonnet.cache_savings)) * 100.0;
lines.push(format!(
" - Cache savings: ${:.2} ({:.1}%)",
sonnet.cache_savings, savings_pct
));
}
lines.push(String::new());
let opus = ModelPricing::OPUS_4_5.calculate_cost(ts);
let haiku = ModelPricing::HAIKU_4_5.calculate_cost(ts);
lines.push("**Alternative models:**".to_string());
lines.push(format!(
" - {}: ${:.2} ({:.1}x)",
opus.model,
opus.total_cost,
opus.total_cost / sonnet.total_cost
));
lines.push(format!(
" - {}: ${:.2} ({:.1}x)",
haiku.model,
haiku.total_cost,
haiku.total_cost / sonnet.total_cost
));
}
lines.push(String::new());
if let Some(dedup) = &self.dedup_tokens {
lines.push("## Token Efficiency".to_string());
lines.push(String::new());
lines.push(format!(
"- **Unique input**: {}",
format_tokens(dedup.unique_input)
));
lines.push(format!(
"- **Unique output**: {}",
format_tokens(dedup.unique_output)
));
lines.push(format!(
"- **Uniqueness ratio**: {:.1}%",
dedup.uniqueness_ratio * 100.0
));
let redundant = dedup
.total_billed
.saturating_sub(dedup.unique_input + dedup.unique_output);
lines.push(format!(
"- **Redundant context**: {}",
format_tokens(redundant)
));
lines.push(String::new());
}
if !self.context_per_turn.is_empty() && self.context_per_turn.iter().any(|&c| c > 0) {
lines.push("## Context Growth".to_string());
lines.push(String::new());
let intervals = if self.context_per_turn.len() <= 10 {
(0..self.context_per_turn.len()).collect::<Vec<_>>()
} else {
let step = self.context_per_turn.len() / 10;
(0..10)
.map(|i| i * step)
.chain(std::iter::once(self.context_per_turn.len() - 1))
.collect()
};
for idx in intervals {
if idx < self.context_per_turn.len() {
let context = self.context_per_turn[idx];
if context > 0 {
let warning = if context >= 100_000 {
" ⚠️ APPROACHING LIMIT"
} else if context >= 80_000 {
" ⚠️ High"
} else {
""
};
lines.push(format!(
"- Turn {}: {}{}",
idx,
format_tokens(context),
warning
));
}
}
}
lines.push(String::new());
}
}
if !self.command_stats.is_empty() {
lines.push("## Command Breakdown".to_string());
lines.push(String::new());
lines.push("| Category | Calls | Errors | ~Output Tokens |".to_string());
lines.push("|----------|-------|--------|----------------|".to_string());
for stat in &self.command_stats {
lines.push(format!(
"| {} | {} | {} | {} |",
stat.category,
stat.total_calls,
stat.total_errors,
format_tokens(stat.output_tokens)
));
}
lines.push(String::new());
let mut all_commands: Vec<&CommandDetail> = self
.command_stats
.iter()
.flat_map(|s| &s.commands)
.collect();
all_commands.sort_by(|a, b| b.calls.cmp(&a.calls));
if !all_commands.is_empty() {
lines.push("Top commands:".to_string());
for cmd in all_commands.iter().take(10) {
if cmd.errors > 0 {
lines.push(format!(
"- {}: {} calls ({} errors)",
cmd.pattern, cmd.calls, cmd.errors
));
} else {
lines.push(format!("- {}: {} calls", cmd.pattern, cmd.calls));
}
}
lines.push(String::new());
}
}
if !self.retry_hotspots.is_empty() {
lines.push("## Retry Hotspots".to_string());
lines.push(String::new());
for hotspot in &self.retry_hotspots {
lines.push(format!(
"- **{}** — {} failures / {} attempts, ~{} output tokens",
hotspot.pattern,
hotspot.failures,
hotspot.attempts,
format_tokens(hotspot.output_tokens)
));
}
lines.push(String::new());
}
if !self.file_tokens.is_empty() {
lines.push("## Token Hotspots".to_string());
lines.push(String::new());
lines.push("| Path | Tokens |".to_string());
lines.push("|------|--------|".to_string());
let mut paths: Vec<_> = self.file_tokens.iter().collect();
paths.sort_by(|a, b| b.1.cmp(a.1));
for (path, tokens) in paths.iter().take(10) {
lines.push(format!("| {} | {} |", path, tokens));
}
lines.push(String::new());
}
if !self.file_operations.is_empty() {
lines.push("## File Operations".to_string());
lines.push(String::new());
let mut ops: Vec<_> = self.file_operations.values().collect();
ops.sort_by_key(|b| std::cmp::Reverse(b.total()));
lines.push("| File | Reads | Edits | Writes | Total |".to_string());
lines.push("|------|-------|-------|--------|-------|".to_string());
for op in ops.iter().take(20) {
lines.push(format!(
"| {} | {} | {} | {} | {} |",
op.path,
op.reads,
op.edits,
op.writes,
op.total()
));
}
lines.push(String::new());
}
if !self.tool_chains.is_empty() {
let mut sorted_chains = self.tool_chains.clone();
sorted_chains.sort_by_key(|b| std::cmp::Reverse(b.potential_savings()));
let top_opportunities: Vec<_> = sorted_chains
.iter()
.filter(|c| c.potential_savings() >= 2)
.take(5)
.collect();
if !top_opportunities.is_empty() {
lines.push("## Parallelization Opportunities".to_string());
lines.push(String::new());
let total_savings: usize =
self.tool_chains.iter().map(|c| c.potential_savings()).sum();
lines.push(format!(
"**Estimated savings**: {} API calls could be reduced by running tools in parallel",
total_savings
));
lines.push(String::new());
for chain in &top_opportunities {
let tools_str = chain.tools.join(" → ");
let safe_marker = if chain.is_safe_parallel() {
" ✓ Safe"
} else {
""
};
lines.push(format!(
"- **Turns {}-{}**: {} API calls → 1 call (save {}){}",
chain.turn_range.0,
chain.turn_range.1,
chain.len(),
chain.potential_savings(),
safe_marker
));
lines.push(format!(" Tools: {}", tools_str));
}
lines.push(String::new());
}
}
if !self.tool_patterns.is_empty() {
lines.push("## Common Tool Patterns".to_string());
lines.push(String::new());
lines.push("Frequent sequences across all sessions:".to_string());
lines.push(String::new());
for pattern in self.tool_patterns.iter().take(10) {
lines.push(format!(
"- **{}×**: {}",
pattern.occurrences,
pattern.pattern_str()
));
}
lines.push(String::new());
}
if !self.tool_chains.is_empty() {
lines.push("## Tool Chains".to_string());
lines.push(String::new());
lines.push(
"Sequences of consecutive single-tool calls (potential parallelization):"
.to_string(),
);
lines.push(String::new());
for chain in &self.tool_chains {
let tools_str = chain.tools.join(" → ");
lines.push(format!(
"- **Turns {}-{}** ({} tools): {}",
chain.turn_range.0,
chain.turn_range.1,
chain.len(),
tools_str
));
}
lines.push(String::new());
}
if !self.corrections.is_empty() {
lines.push("## Corrections & Apologies".to_string());
lines.push(String::new());
for correction in &self.corrections {
lines.push(format!(
"- **Turn {}** [{}]: {}",
correction.turn,
correction.category.as_str(),
correction.text
));
}
lines.push(String::new());
}
if !self.error_patterns.is_empty() {
lines.push("## Error Patterns".to_string());
lines.push(String::new());
for pattern in &self.error_patterns {
lines.push(format!("### {} ({})", pattern.category, pattern.count));
for ex in &pattern.examples {
lines.push(format!("- {}", ex));
}
lines.push(String::new());
}
}
lines.join("\n")
}
pub fn format_pretty(&self) -> String {
let mut out = String::new();
self.write_pretty(&mut out).unwrap_or_default();
out
}
fn write_pretty(&self, out: &mut String) -> std::fmt::Result {
use std::fmt::Write;
writeln!(out, "\x1b[1;36m━━━ Session Analysis ━━━\x1b[0m")?;
writeln!(out)?;
writeln!(out, "\x1b[1mFormat:\x1b[0m {}", self.format)?;
writeln!(
out,
"\x1b[1mTool calls:\x1b[0m {} ({:.1}% success)",
self.total_tool_calls(),
self.overall_success_rate() * 100.0
)?;
writeln!(out, "\x1b[1mTurns:\x1b[0m {}", self.total_turns)?;
if self.parallel_opportunities > 0 {
writeln!(
out,
"\x1b[1mParallel opportunities:\x1b[0m {}",
self.parallel_opportunities
)?;
}
writeln!(out)?;
if !self.tool_stats.is_empty() {
writeln!(out, "\x1b[1;36m━━━ Tool Usage ━━━\x1b[0m")?;
let mut tools: Vec<_> = self.tool_stats.values().collect();
sort_tool_stats_by_hint(&mut tools, self.tool_sort.as_deref());
let max_calls = tools.first().map(|t| t.calls).unwrap_or(1);
let max_name_len = tools.iter().map(|t| t.name.len()).max().unwrap_or(10);
for tool in tools {
let bar_width = 30;
let filled = (tool.calls as f64 / max_calls as f64 * bar_width as f64) as usize;
let bar: String = "█".repeat(filled) + &"░".repeat(bar_width - filled);
let color = if tool.errors > 0 {
"\x1b[31m"
} else {
"\x1b[32m"
};
writeln!(
out,
"{:>width$} {} {}{:>5}\x1b[0m{}",
tool.name,
bar,
color,
tool.calls,
if tool.errors > 0 {
format!(" ({} errors)", tool.errors)
} else {
String::new()
},
width = max_name_len
)?;
}
writeln!(out)?;
}
if !self.largest_tool_results.is_empty() {
writeln!(out, "\x1b[1;36m━━━ Largest Tool Results ━━━\x1b[0m")?;
for r in &self.largest_tool_results {
writeln!(
out,
"\x1b[33m{:>8}\x1b[0m chars turn {:>4} \x1b[36m{}\x1b[0m {}",
r.chars,
r.turn,
r.tool_name,
r.preview.chars().take(60).collect::<String>()
)?;
}
writeln!(out)?;
}
if self.token_stats.api_calls > 0 {
let ts = &self.token_stats;
writeln!(out, "\x1b[1;36m━━━ Token Usage ━━━\x1b[0m")?;
writeln!(out, "API calls: {}", ts.api_calls)?;
writeln!(out, "Avg context: {} tokens", ts.avg_context())?;
writeln!(
out,
"Context range: {} - {}",
ts.min_context, ts.max_context
)?;
if ts.cache_read > 0 {
writeln!(out, "Cache read: {} tokens", format_tokens(ts.cache_read))?;
}
if ts.cache_create > 0 {
writeln!(
out,
"Cache create: {} tokens",
format_tokens(ts.cache_create)
)?;
}
writeln!(out)?;
writeln!(out, "\x1b[1;36m━━━ Cost Estimate ━━━\x1b[0m")?;
if let Some(actual) = self.actual_cost {
writeln!(
out,
"\x1b[1mActual cost:\x1b[0m \x1b[32m${:.2}\x1b[0m",
actual
)?;
let sonnet = ModelPricing::SONNET_4_5.calculate_cost(ts);
let opus = ModelPricing::OPUS_4_5.calculate_cost(ts);
let haiku = ModelPricing::HAIKU_4_5.calculate_cost(ts);
writeln!(
out,
"What-if: {} ${:.2} | {} ${:.2} | {} ${:.2}",
sonnet.model,
sonnet.total_cost,
opus.model,
opus.total_cost,
haiku.model,
haiku.total_cost
)?;
} else {
let sonnet = ModelPricing::SONNET_4_5.calculate_cost(ts);
writeln!(
out,
"\x1b[1m{}\x1b[0m: \x1b[32m${:.2}\x1b[0m",
sonnet.model, sonnet.total_cost
)?;
if sonnet.cache_savings > 0.0 {
let savings_pct =
(sonnet.cache_savings / (sonnet.total_cost + sonnet.cache_savings)) * 100.0;
writeln!(
out,
" Cache savings: \x1b[33m${:.2}\x1b[0m ({:.1}%)",
sonnet.cache_savings, savings_pct
)?;
}
writeln!(
out,
" Input: ${:.2} | Output: ${:.2}",
sonnet.input_cost, sonnet.output_cost
)?;
let opus = ModelPricing::OPUS_4_5.calculate_cost(ts);
let haiku = ModelPricing::HAIKU_4_5.calculate_cost(ts);
writeln!(
out,
"If {}: ${:.2} (\x1b[31m{:.1}x\x1b[0m) | If {}: ${:.2} (\x1b[32m{:.1}x\x1b[0m)",
opus.model,
opus.total_cost,
opus.total_cost / sonnet.total_cost,
haiku.model,
haiku.total_cost,
haiku.total_cost / sonnet.total_cost
)?;
}
if let Some(dedup) = &self.dedup_tokens {
writeln!(out)?;
writeln!(out, "\x1b[1;36m━━━ Token Efficiency ━━━\x1b[0m")?;
writeln!(
out,
"Unique input: {} | Unique output: {}",
format_tokens(dedup.unique_input),
format_tokens(dedup.unique_output)
)?;
writeln!(
out,
"Uniqueness: \x1b[33m{:.1}%\x1b[0m",
dedup.uniqueness_ratio * 100.0
)?;
let redundant = dedup
.total_billed
.saturating_sub(dedup.unique_input + dedup.unique_output);
writeln!(out, "Redundant context: {}", format_tokens(redundant))?;
}
writeln!(out)?;
if !self.context_per_turn.is_empty() && self.context_per_turn.iter().any(|&c| c > 0) {
writeln!(out, "\x1b[1;36m━━━ Context Growth ━━━\x1b[0m")?;
for line in token_growth_chart(&self.context_per_turn, 20) {
writeln!(out, "{}", line)?;
}
writeln!(out)?;
}
}
if !self.command_stats.is_empty() {
writeln!(out, "\x1b[1;36m━━━ Command Breakdown ━━━\x1b[0m")?;
let max_calls = self
.command_stats
.first()
.map(|s| s.total_calls)
.unwrap_or(1);
let max_cat_len = self
.command_stats
.iter()
.map(|s| s.category.len())
.max()
.unwrap_or(8);
for stat in &self.command_stats {
let bar_width = 20;
let filled =
(stat.total_calls as f64 / max_calls as f64 * bar_width as f64) as usize;
let bar: String = "█".repeat(filled) + &"░".repeat(bar_width - filled);
let error_str = if stat.total_errors > 0 {
format!(
" (\x1b[31m{} error{}\x1b[0m)",
stat.total_errors,
if stat.total_errors == 1 { "" } else { "s" }
)
} else {
String::new()
};
writeln!(
out,
"{:>width$} {} {:>3} calls{} ~{}",
stat.category,
bar,
stat.total_calls,
error_str,
format_tokens(stat.output_tokens),
width = max_cat_len
)?;
}
writeln!(out)?;
}
if !self.retry_hotspots.is_empty() {
writeln!(out, "\x1b[1;36m━━━ Retry Hotspots ━━━\x1b[0m")?;
for hotspot in &self.retry_hotspots {
writeln!(
out,
"\x1b[33m⚠\x1b[0m {} — {}/{} failed, ~{} output tokens burned",
hotspot.pattern,
hotspot.failures,
hotspot.attempts,
format_tokens(hotspot.output_tokens)
)?;
}
writeln!(out)?;
}
if !self.file_operations.is_empty() {
writeln!(out, "\x1b[1;36m━━━ File Operations ━━━\x1b[0m")?;
let mut ops: Vec<_> = self.file_operations.values().collect();
ops.sort_by_key(|b| std::cmp::Reverse(b.total()));
for op in ops.iter().take(15) {
let bar_width = 20;
let max_total = ops.first().map(|o| o.total()).unwrap_or(1);
let filled = (op.total() as f64 / max_total as f64 * bar_width as f64) as usize;
let bar: String = "█".repeat(filled) + &"░".repeat(bar_width - filled);
let mut parts = Vec::new();
if op.reads > 0 {
parts.push(format!(
"\x1b[36m{} read{}\x1b[0m",
op.reads,
if op.reads == 1 { "" } else { "s" }
));
}
if op.edits > 0 {
parts.push(format!(
"\x1b[33m{} edit{}\x1b[0m",
op.edits,
if op.edits == 1 { "" } else { "s" }
));
}
if op.writes > 0 {
parts.push(format!(
"\x1b[32m{} write{}\x1b[0m",
op.writes,
if op.writes == 1 { "" } else { "s" }
));
}
let ops_str = parts.join(", ");
writeln!(out, "{} {} {}", bar, ops_str, op.path)?;
}
writeln!(out)?;
}
if !self.file_tokens.is_empty() {
writeln!(out, "\x1b[1;36m━━━ Token Hotspots ━━━\x1b[0m")?;
let mut paths: Vec<_> = self.file_tokens.iter().collect();
paths.sort_by(|a, b| b.1.cmp(a.1));
let max_tokens = paths.first().map(|(_, t)| **t).unwrap_or(1);
for (path, tokens) in paths.iter().take(10) {
let bar_width = 20;
let filled = (**tokens as f64 / max_tokens as f64 * bar_width as f64) as usize;
let bar: String = "█".repeat(filled) + &"░".repeat(bar_width - filled);
writeln!(out, "{} {:>8} {}", bar, format_tokens(**tokens), path)?;
}
writeln!(out)?;
}
if !self.message_counts.is_empty() {
writeln!(out, "\x1b[1;36m━━━ Message Types ━━━\x1b[0m")?;
let mut counts: Vec<_> = self.message_counts.iter().collect();
counts.sort_by(|a, b| b.1.cmp(a.1));
let items: Vec<String> = counts
.iter()
.take(8)
.map(|(k, v)| format!("{}:{}", k, v))
.collect();
writeln!(out, "{}", items.join(" "))?;
}
if !self.tool_chains.is_empty() {
let mut sorted_chains = self.tool_chains.clone();
sorted_chains.sort_by_key(|b| std::cmp::Reverse(b.potential_savings()));
let top_opportunities: Vec<_> = sorted_chains
.iter()
.filter(|c| c.potential_savings() >= 2)
.take(5)
.collect();
if !top_opportunities.is_empty() {
writeln!(out)?;
writeln!(out, "\x1b[1;36m━━━ Parallelization Hints ━━━\x1b[0m")?;
let total_savings: usize =
self.tool_chains.iter().map(|c| c.potential_savings()).sum();
writeln!(
out,
"Potential savings: \x1b[33m{} API calls\x1b[0m",
total_savings
)?;
for chain in &top_opportunities {
let safe_marker = if chain.is_safe_parallel() {
"\x1b[32m✓\x1b[0m"
} else {
"\x1b[33m⚠\x1b[0m"
};
writeln!(
out,
"{} Turns {}-{}: \x1b[33m{} → 1\x1b[0m (save {})",
safe_marker,
chain.turn_range.0,
chain.turn_range.1,
chain.len(),
chain.potential_savings()
)?;
let tools_str = chain.tools.join(" → ");
writeln!(out, " {}", tools_str)?;
}
}
}
if !self.tool_patterns.is_empty() {
writeln!(out)?;
writeln!(out, "\x1b[1;36m━━━ Common Tool Patterns ━━━\x1b[0m")?;
writeln!(out, "Frequent sequences across all sessions:")?;
writeln!(out)?;
for pattern in self.tool_patterns.iter().take(10) {
writeln!(
out,
"\x1b[33m{:>3}×\x1b[0m {}",
pattern.occurrences,
pattern.pattern_str()
)?;
}
}
if !self.tool_chains.is_empty() {
writeln!(out)?;
writeln!(out, "\x1b[1;36m━━━ Tool Chains ━━━\x1b[0m")?;
writeln!(
out,
"Found {} sequences of consecutive single-tool calls:",
self.tool_chains.len()
)?;
for chain in self.tool_chains.iter().take(10) {
let tools_str = chain.tools.join(" → ");
writeln!(
out,
"\x1b[33m▸\x1b[0m Turns {}-{} ({}): {}",
chain.turn_range.0,
chain.turn_range.1,
chain.len(),
tools_str
)?;
}
}
if !self.corrections.is_empty() {
writeln!(out)?;
writeln!(out, "\x1b[1;36m━━━ Corrections & Apologies ━━━\x1b[0m")?;
for correction in &self.corrections {
writeln!(
out,
"\x1b[31m⚠\x1b[0m Turn {} [{}]: {}",
correction.turn,
correction.category.as_str(),
correction.text.chars().take(60).collect::<String>()
)?;
}
}
Ok(())
}
}
impl OutputFormatter for SessionAnalysisReport {
fn format_text(&self) -> String {
SessionAnalysisReport::format_text(self)
}
fn format_pretty(&self) -> String {
SessionAnalysisReport::format_pretty(self)
}
}
impl std::fmt::Display for SessionAnalysisReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", OutputFormatter::format_text(self))
}
}
fn format_tokens(tokens: u64) -> String {
if tokens >= 1_000_000 {
format!("{:.1}M", tokens as f64 / 1_000_000.0)
} else if tokens >= 1_000 {
format!("{:.1}K", tokens as f64 / 1_000.0)
} else {
tokens.to_string()
}
}
fn token_growth_chart(context_per_turn: &[u64], width: usize) -> Vec<String> {
if context_per_turn.is_empty() {
return vec![];
}
let max_context = *context_per_turn.iter().max().unwrap_or(&1);
let threshold_80k = 80_000;
let threshold_100k = 100_000;
let mut lines = Vec::new();
let sample_rate = if context_per_turn.len() > 20 {
context_per_turn.len() / 20
} else {
1
};
for (idx, &context) in context_per_turn.iter().enumerate() {
if context == 0 {
continue; }
if idx % sample_rate != 0 && idx != context_per_turn.len() - 1 {
continue; }
let filled = ((context as f64 / max_context as f64) * width as f64) as usize;
let bar = "▓".repeat(filled) + &"░".repeat(width.saturating_sub(filled));
let color = if context >= threshold_100k {
"\x1b[31m" } else if context >= threshold_80k {
"\x1b[33m" } else {
"\x1b[32m" };
let warning = if context >= threshold_100k {
" [!] APPROACHING LIMIT"
} else if context >= threshold_80k {
" [!] High context"
} else {
""
};
lines.push(format!(
"Turn {:>3}: {}{}{}\x1b[0m {}{}",
idx,
color,
bar,
" ",
format_tokens(context),
warning
));
}
lines
}
pub fn categorize_error(error_text: &str) -> &'static str {
let text = error_text.to_lowercase();
if text.contains("exit code") {
"Command failure"
} else if text.contains("not found") {
"File not found"
} else if text.contains("permission") {
"Permission error"
} else if text.contains("timeout") {
"Timeout"
} else if text.contains("syntax") {
"Syntax error"
} else if text.contains("import") {
"Import error"
} else {
"Other"
}
}
fn extract_file_path(tool_name: &str, input: &serde_json::Value) -> Option<String> {
match tool_name {
"Read" | "Write" | "Edit" => {
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
return Some(normalize_path(path));
}
}
_ => {}
}
None
}
pub fn detect_correction(text: &str) -> Option<(CorrectionKind, String)> {
let lower = text.to_lowercase();
let apology_phrases = ["i apologize", "i'm sorry", "sorry about", "my apologies"];
for phrase in &apology_phrases {
if let Some(pos) = lower.find(phrase) {
let excerpt = text.chars().skip(pos).take(80).collect();
return Some((CorrectionKind::Apology, excerpt));
}
}
let mistake_phrases = [
"i made a mistake",
"i was wrong",
"that was incorrect",
"my mistake",
];
for phrase in &mistake_phrases {
if let Some(pos) = lower.find(phrase) {
let excerpt = text.chars().skip(pos).take(80).collect();
return Some((CorrectionKind::Mistake, excerpt));
}
}
let fix_phrases = ["let me fix", "i'll fix", "let me correct"];
for phrase in &fix_phrases {
if let Some(pos) = lower.find(phrase) {
let excerpt = text.chars().skip(pos).take(80).collect();
return Some((CorrectionKind::LetMeFix, excerpt));
}
}
let actually_phrases = ["actually,", "actually i", "actually that"];
for phrase in &actually_phrases {
if let Some(pos) = lower.find(phrase) {
let excerpt = text.chars().skip(pos).take(80).collect();
return Some((CorrectionKind::Actually, excerpt));
}
}
None
}
pub fn normalize_path(path: &str) -> String {
if !path.starts_with('/') {
return path.to_string();
}
let parts: Vec<&str> = path.split('/').collect();
for (i, part) in parts.iter().enumerate() {
if matches!(
*part,
"src" | "lib" | "crates" | "tests" | "docs" | "packages"
) {
return parts[i..].join("/");
}
}
path.to_string()
}
fn split_command_chain(cmd: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut start = 0;
let bytes = cmd.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if bytes[i] == b';' {
let part = cmd[start..i].trim();
if !part.is_empty() {
parts.push(part);
}
start = i + 1;
} else if i + 1 < len && bytes[i] == b'&' && bytes[i + 1] == b'&' {
let part = cmd[start..i].trim();
if !part.is_empty() {
parts.push(part);
}
start = i + 2;
i += 1; } else if i + 1 < len && bytes[i] == b'|' && bytes[i + 1] == b'|' {
let part = cmd[start..i].trim();
if !part.is_empty() {
parts.push(part);
}
start = i + 2;
i += 1;
}
i += 1;
}
let part = cmd[start..].trim();
if !part.is_empty() {
parts.push(part);
}
parts.into_iter().filter(|p| !p.starts_with('#')).collect()
}
pub struct CommandCategory {
pub category: &'static str,
pub pattern: String,
}
fn categorize_cargo(sub: &str) -> CommandCategory {
let (category, pattern) = match sub {
"build" | "b" => ("build", "cargo build".to_string()),
"test" | "t" | "nextest" => ("test", "cargo test".to_string()),
"clippy" => ("lint", "cargo clippy".to_string()),
"fmt" => ("lint", "cargo fmt".to_string()),
"add" | "install" => ("install", format!("cargo {}", sub)),
_ => ("build", format!("cargo {}", sub)),
};
CommandCategory { category, pattern }
}
fn categorize_npm_run(runner: &str, script: &str) -> CommandCategory {
let (category, pattern) = if script.contains("build") {
("build", format!("{} run build", runner))
} else if script.contains("test") {
("test", format!("{} run test", runner))
} else if script.contains("lint") {
("lint", format!("{} run lint", runner))
} else if script.contains("format") || script.contains("fmt") {
("lint", format!("{} run {}", runner, script))
} else {
("other", format!("{} run {}", runner, script))
};
CommandCategory { category, pattern }
}
fn categorize_js_runner(base_name: &str, sub: &str, effective: &[&str]) -> CommandCategory {
match sub {
"run" => {
let script = effective.get(2).copied().unwrap_or("?");
categorize_npm_run(base_name, script)
}
"build" => CommandCategory {
category: "build",
pattern: format!("{} build", base_name),
},
"test" => CommandCategory {
category: "test",
pattern: format!("{} test", base_name),
},
"install" | "i" | "add" | "ci" => CommandCategory {
category: "install",
pattern: format!("{} install", base_name),
},
_ => CommandCategory {
category: "other",
pattern: format!("{} {}", base_name, sub),
},
}
}
pub fn categorize_command(cmd: &str) -> CommandCategory {
let cmd = cmd.trim();
let effective = cmd
.split_whitespace()
.skip_while(|w| w.contains('=') && !w.starts_with('-'))
.collect::<Vec<_>>();
if effective.is_empty() {
return CommandCategory {
category: "other",
pattern: cmd.to_string(),
};
}
let base = effective[0];
let sub = effective.get(1).copied().unwrap_or("");
let base_name = base.rsplit('/').next().unwrap_or(base);
match base_name {
"cargo" => categorize_cargo(sub),
"npm" | "npx" | "yarn" | "pnpm" => categorize_js_runner(base_name, sub, &effective),
"make" | "cmake" | "ninja" => CommandCategory {
category: "build",
pattern: base_name.to_string(),
},
"tsc" => CommandCategory {
category: "build",
pattern: "tsc".to_string(),
},
"webpack" | "vite" | "esbuild" | "rollup" | "parcel" => CommandCategory {
category: "build",
pattern: base_name.to_string(),
},
"pytest" | "jest" | "vitest" | "mocha" => CommandCategory {
category: "test",
pattern: base_name.to_string(),
},
"go" if sub == "test" => CommandCategory {
category: "test",
pattern: "go test".to_string(),
},
"ruby" if sub == "-e" || sub == "test" => CommandCategory {
category: "test",
pattern: "ruby test".to_string(),
},
"rspec" | "phpunit" => CommandCategory {
category: "test",
pattern: base_name.to_string(),
},
"eslint" | "prettier" | "ruff" | "black" | "flake8" | "mypy" | "pylint" | "rubocop"
| "biome" | "oxlint" => CommandCategory {
category: "lint",
pattern: base_name.to_string(),
},
"git" | "gh" => {
let git_sub = if sub.is_empty() { "git" } else { sub };
CommandCategory {
category: "git",
pattern: format!("{} {}", base_name, git_sub),
}
}
"pip" | "pip3" if sub == "install" => CommandCategory {
category: "install",
pattern: "pip install".to_string(),
},
"apt" | "apt-get" | "brew" | "dnf" | "pacman" | "nix" => CommandCategory {
category: "install",
pattern: format!("{} {}", base_name, sub),
},
"find" | "grep" | "rg" | "ag" | "fd" => CommandCategory {
category: "search",
pattern: base_name.to_string(),
},
"ls" | "cat" | "head" | "tail" | "wc" | "file" | "stat" | "tree" | "less" => {
CommandCategory {
category: "search",
pattern: base_name.to_string(),
}
}
_ => CommandCategory {
category: "other",
pattern: base_name.to_string(),
},
}
}
fn detect_retry_hotspots(
invocations: &[(usize, String, bool)],
output_tokens_per_turn: &[u64],
) -> Vec<RetryHotspot> {
let mut by_pattern: HashMap<String, Vec<(usize, bool)>> = HashMap::new();
for (turn_idx, pattern, was_error) in invocations {
by_pattern
.entry(pattern.clone())
.or_default()
.push((*turn_idx, *was_error));
}
let mut hotspots = Vec::new();
for (pattern, entries) in &by_pattern {
let attempts = entries.len();
let failures = entries.iter().filter(|(_, err)| *err).count();
if failures >= 2 && attempts >= 3 {
let turn_indices: Vec<usize> = entries.iter().map(|(idx, _)| *idx).collect();
let output_tokens: u64 = turn_indices
.iter()
.filter_map(|&idx| output_tokens_per_turn.get(idx))
.sum();
hotspots.push(RetryHotspot {
pattern: pattern.clone(),
attempts,
failures,
output_tokens,
turn_indices,
});
}
}
hotspots.sort_by(|a, b| {
b.failures
.cmp(&a.failures)
.then(b.output_tokens.cmp(&a.output_tokens))
});
hotspots
}
fn sort_tool_stats_by_hint(tools: &mut Vec<&ToolStats>, hint: Option<&str>) {
let (field, descending) = match hint {
None | Some("") | Some("calls") | Some("-calls") => ("calls", true),
Some("+calls") => ("calls", false),
Some("name") | Some("+name") => ("name", false),
Some("-name") => ("name", true),
Some("errors") | Some("-errors") => ("errors", true),
Some("+errors") => ("errors", false),
_ => ("calls", true), };
match field {
"name" => {
if descending {
tools.sort_by(|a, b| b.name.cmp(&a.name));
} else {
tools.sort_by(|a, b| a.name.cmp(&b.name));
}
}
"errors" => {
if descending {
tools.sort_by(|a, b| b.errors.cmp(&a.errors).then(b.calls.cmp(&a.calls)));
} else {
tools.sort_by(|a, b| a.errors.cmp(&b.errors).then(a.calls.cmp(&b.calls)));
}
}
_ => {
if descending {
tools.sort_by(|a, b| b.calls.cmp(&a.calls));
} else {
tools.sort_by(|a, b| a.calls.cmp(&b.calls));
}
}
}
}
fn build_command_stats(
invocations: &[(usize, String, bool, &'static str)],
output_tokens_per_turn: &[u64],
) -> Vec<CommandStats> {
let mut by_category: HashMap<&str, HashMap<String, (usize, usize)>> = HashMap::new();
let mut category_turns: HashMap<&str, Vec<usize>> = HashMap::new();
for (turn_idx, pattern, was_error, category) in invocations {
let commands = by_category.entry(category).or_default();
let entry = commands.entry(pattern.clone()).or_insert((0, 0));
entry.0 += 1;
if *was_error {
entry.1 += 1;
}
category_turns.entry(category).or_default().push(*turn_idx);
}
let mut stats: Vec<CommandStats> = by_category
.into_iter()
.map(|(category, commands)| {
let total_calls: usize = commands.values().map(|(c, _)| c).sum();
let total_errors: usize = commands.values().map(|(_, e)| e).sum();
let mut turns: Vec<usize> = category_turns.get(category).cloned().unwrap_or_default();
turns.sort_unstable();
turns.dedup();
let output_tokens: u64 = turns
.iter()
.filter_map(|&idx| output_tokens_per_turn.get(idx))
.sum();
let mut details: Vec<CommandDetail> = commands
.into_iter()
.map(|(pattern, (calls, errors))| CommandDetail {
pattern,
calls,
errors,
})
.collect();
details.sort_by(|a, b| b.calls.cmp(&a.calls));
CommandStats {
category: category.to_string(),
commands: details,
total_calls,
total_errors,
output_tokens,
}
})
.collect();
stats.sort_by(|a, b| b.total_calls.cmp(&a.total_calls));
stats
}
pub fn extract_tool_patterns(chains: &[ToolChain]) -> Vec<ToolPattern> {
let mut pattern_counts: HashMap<Vec<String>, usize> = HashMap::new();
for chain in chains {
for len in 2..=5.min(chain.tools.len()) {
for start in 0..=chain.tools.len().saturating_sub(len) {
let subsequence: Vec<String> = chain.tools[start..start + len].to_vec();
*pattern_counts.entry(subsequence).or_insert(0) += 1;
}
}
}
let mut patterns: Vec<ToolPattern> = pattern_counts
.into_iter()
.filter(|(_, count)| *count >= 2) .map(|(tools, occurrences)| ToolPattern { tools, occurrences })
.collect();
patterns.sort_by(|a, b| {
b.occurrences
.cmp(&a.occurrences)
.then(b.tools.len().cmp(&a.tools.len()))
});
patterns
}
pub fn analyze_session(session: &Session) -> SessionAnalysisReport {
let mut analysis = SessionAnalysisReport::new(session.path.clone(), &session.format);
for turn in &session.turns {
for msg in &turn.messages {
*analysis
.message_counts
.entry(msg.role.to_string())
.or_insert(0) += 1;
}
}
let mut current_chain: Option<Vec<(usize, String)>> = None;
let mut command_invocations: Vec<(usize, String, bool, &'static str)> = Vec::new();
let mut retry_candidates: Vec<(usize, String, bool)> = Vec::new();
let mut output_tokens_per_turn: Vec<u64> = Vec::new();
let mut tool_result_candidates: Vec<(usize, usize, String, String)> = Vec::new();
for (turn_idx, turn) in session.turns.iter().enumerate() {
let mut tool_uses_in_turn = 0;
let mut tool_name_in_turn: Option<String> = None;
let mut bash_commands: HashMap<String, Vec<(String, &'static str)>> = HashMap::new();
let mut tool_errors: HashMap<String, bool> = HashMap::new();
let mut tool_id_to_name: HashMap<String, String> = HashMap::new();
for msg in &turn.messages {
if msg.role == normalize_chat_sessions::Role::Assistant {
for block in &msg.content {
if let ContentBlock::Text { text } = block
&& let Some((category, excerpt)) = detect_correction(text)
{
analysis.corrections.push(Correction {
turn: turn_idx,
text: excerpt,
category,
});
}
}
}
for block in &msg.content {
match block {
ContentBlock::ToolUse { id, name, input } => {
let stat = analysis
.tool_stats
.entry(name.clone())
.or_insert_with(|| ToolStats::new(name));
stat.calls += 1;
tool_uses_in_turn += 1;
tool_name_in_turn = Some(name.clone());
tool_id_to_name.insert(id.clone(), name.clone());
if let Some(file_path) = extract_file_path(name, input) {
let op = analysis
.file_operations
.entry(file_path.clone())
.or_insert_with(|| FileOperation {
path: file_path.clone(),
..Default::default()
});
match name.as_str() {
"Read" => op.reads += 1,
"Edit" => op.edits += 1,
"Write" => op.writes += 1,
_ => {}
}
}
if name == "Bash"
&& let Some(cmd) = input.get("command").and_then(|v| v.as_str())
{
let subcmds = split_command_chain(cmd);
let mut entries = Vec::new();
for subcmd in subcmds {
let cc = categorize_command(subcmd);
entries.push((cc.pattern, cc.category));
}
bash_commands.insert(id.clone(), entries);
}
}
ContentBlock::ToolResult {
tool_use_id,
is_error,
content,
..
} => {
tool_errors.insert(tool_use_id.clone(), *is_error);
let content_chars = content.chars().count();
if let Some(tool_name) = tool_id_to_name.get(tool_use_id) {
if let Some(stat) = analysis.tool_stats.get_mut(tool_name) {
stat.output_chars += content_chars;
}
let preview: String = content
.chars()
.take(100)
.collect::<String>()
.trim()
.to_string();
tool_result_candidates.push((
content_chars,
turn_idx,
tool_name.clone(),
preview,
));
}
if *is_error {
for m in &turn.messages {
for b in &m.content {
if let ContentBlock::ToolUse { id, name, .. } = b
&& id == tool_use_id
&& let Some(stat) = analysis.tool_stats.get_mut(name)
{
stat.errors += 1;
}
}
}
let category = categorize_error(content);
let pattern = analysis
.error_patterns
.iter_mut()
.find(|p| p.category == category);
if let Some(p) = pattern {
p.count += 1;
if p.examples.len() < 3 {
p.examples.push(content.chars().take(100).collect());
}
} else {
let mut p = ErrorPattern::new(category);
p.count = 1;
p.examples.push(content.chars().take(100).collect());
analysis.error_patterns.push(p);
}
}
}
_ => {}
}
}
}
for (tool_id, entries) in &bash_commands {
let was_error = tool_errors.get(tool_id).copied().unwrap_or(false);
for (pattern, category) in entries {
command_invocations.push((turn_idx, pattern.clone(), was_error, category));
retry_candidates.push((turn_idx, pattern.clone(), was_error));
}
}
if tool_uses_in_turn == 1 {
analysis.parallel_opportunities += 1;
if let Some(tool_name) = tool_name_in_turn {
match &mut current_chain {
Some(chain) => {
chain.push((turn_idx, tool_name));
}
None => {
current_chain = Some(vec![(turn_idx, tool_name)]);
}
}
}
} else {
if let Some(chain) = current_chain.take()
&& chain.len() >= 3
{
let tools: Vec<String> = chain.iter().map(|(_, name)| name.clone()).collect();
let turn_range = (chain[0].0, chain[chain.len() - 1].0);
analysis.tool_chains.push(ToolChain { tools, turn_range });
}
}
}
if let Some(chain) = current_chain
&& chain.len() >= 3
{
let tools: Vec<String> = chain.iter().map(|(_, name)| name.clone()).collect();
let turn_range = (chain[0].0, chain[chain.len() - 1].0);
analysis.tool_chains.push(ToolChain { tools, turn_range });
}
tool_result_candidates.sort_by(|a, b| b.0.cmp(&a.0));
analysis.largest_tool_results = tool_result_candidates
.into_iter()
.take(10)
.map(|(chars, turn, tool_name, preview)| LargestToolResult {
tool_name,
chars,
turn,
preview,
})
.collect();
analysis.total_turns = session.turns.len();
let mut actual_cost_sum: f64 = 0.0;
let mut has_model_pricing = false;
let mut prev_context = 0u64;
let mut unique_input = 0u64;
for turn in &session.turns {
if let Some(usage) = &turn.token_usage {
analysis.token_stats.api_calls += 1;
analysis.token_stats.total_input += usage.input;
analysis.token_stats.total_output += usage.output;
if let Some(cr) = usage.cache_read {
analysis.token_stats.cache_read += cr;
}
if let Some(cc) = usage.cache_create {
analysis.token_stats.cache_create += cc;
}
let context = usage.input + usage.cache_read.unwrap_or(0);
analysis.token_stats.update_context(context);
analysis.context_per_turn.push(context);
output_tokens_per_turn.push(usage.output);
if let Some(model_str) = &usage.model
&& let Some(pricing) = ModelPricing::from_model_str(model_str)
{
actual_cost_sum += pricing.calculate_turn_cost(usage);
has_model_pricing = true;
}
unique_input += context.saturating_sub(prev_context);
prev_context = context;
} else {
analysis.context_per_turn.push(0);
output_tokens_per_turn.push(0);
}
}
if has_model_pricing {
analysis.actual_cost = Some(actual_cost_sum);
}
let total_billed = analysis.token_stats.total_input
+ analysis.token_stats.cache_read
+ analysis.token_stats.total_output;
if total_billed > 0 {
let unique_output = analysis.token_stats.total_output;
let unique_total = unique_input + unique_output;
analysis.dedup_tokens = Some(DedupTokenStats {
unique_input,
unique_output,
total_billed,
uniqueness_ratio: unique_total as f64 / total_billed as f64,
});
}
analysis.command_stats = build_command_stats(&command_invocations, &output_tokens_per_turn);
analysis.retry_hotspots = detect_retry_hotspots(&retry_candidates, &output_tokens_per_turn);
analysis
.error_patterns
.sort_by(|a, b| b.count.cmp(&a.count));
analysis
}