Skip to main content

cc_token_usage/analysis/
session.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::data::models::SessionData;
5use crate::pricing::calculator::PricingCalculator;
6
7use super::{
8    AgentDetail, AgentSummary, AggregatedTokens, SessionResult, SubagentSummary, TurnCostBreakdown,
9    TurnDetail, WorkflowPhaseSummary, WorkflowSummary,
10};
11
12/// Agent metadata loaded from .meta.json files.
13#[derive(Debug, Clone, Default)]
14pub struct AgentMeta {
15    pub agent_type: String,
16    pub description: String,
17}
18
19pub fn analyze_session(
20    session: &SessionData,
21    calc: &PricingCalculator,
22    agent_meta: &std::collections::HashMap<String, AgentMeta>,
23    claude_home: &Path,
24) -> SessionResult {
25    let all_turns = session.all_responses();
26
27    let mut turn_details = Vec::new();
28    let mut total_tokens = AggregatedTokens::default();
29    let mut total_cost = 0.0;
30    let mut stop_reason_counts: HashMap<String, usize> = HashMap::new();
31    let mut agent_summary = AgentSummary::default();
32    let mut model_counts: HashMap<&str, usize> = HashMap::new();
33    let mut max_context: u64 = 0;
34    let mut prev_context_size: Option<u64> = None;
35    let mut agent_acc: HashMap<String, (usize, u64, f64)> = HashMap::new();
36
37    // Phase 1: new accumulators
38    let mut tool_error_count: usize = 0;
39    let mut truncated_count: usize = 0;
40    let mut service_tiers: HashMap<String, usize> = HashMap::new();
41    let mut speeds: HashMap<String, usize> = HashMap::new();
42    let mut inference_geos: HashMap<String, usize> = HashMap::new();
43    let mut git_branches: HashMap<String, usize> = HashMap::new();
44
45    for (i, turn) in all_turns.iter().enumerate() {
46        let input = turn.usage.input_tokens.unwrap_or(0);
47        let output = turn.usage.output_tokens.unwrap_or(0);
48        let cache_create = turn.usage.cache_creation_input_tokens.unwrap_or(0);
49        let cache_read = turn.usage.cache_read_input_tokens.unwrap_or(0);
50
51        // Extract 5m/1h TTL breakdown
52        let (cache_write_5m, cache_write_1h) = if let Some(ref detail) = turn.usage.cache_creation {
53            (
54                detail.ephemeral_5m_input_tokens.unwrap_or(0),
55                detail.ephemeral_1h_input_tokens.unwrap_or(0),
56            )
57        } else {
58            (0, 0)
59        };
60
61        let context_size = input + cache_create + cache_read;
62        let cache_hit_rate = if context_size > 0 {
63            (cache_read as f64 / context_size as f64) * 100.0
64        } else {
65            0.0
66        };
67
68        // Track max context
69        if context_size > max_context {
70            max_context = context_size;
71        }
72
73        // Compaction detection and context delta
74        let is_compaction = match prev_context_size {
75            Some(prev) => prev > 0 && (context_size as f64) < (prev as f64 * 0.9),
76            None => false,
77        };
78        let context_delta = match prev_context_size {
79            Some(prev) => context_size as i64 - prev as i64,
80            None => 0,
81        };
82        prev_context_size = Some(context_size);
83
84        let pricing_cost = calc.calculate_turn_cost(&turn.model, &turn.usage);
85
86        let cost_breakdown = TurnCostBreakdown {
87            input_cost: pricing_cost.input_cost,
88            output_cost: pricing_cost.output_cost,
89            cache_write_5m_cost: pricing_cost.cache_write_5m_cost,
90            cache_write_1h_cost: pricing_cost.cache_write_1h_cost,
91            cache_read_cost: pricing_cost.cache_read_cost,
92            total: pricing_cost.total,
93        };
94
95        // Track stop reasons
96        if let Some(ref reason) = turn.stop_reason {
97            *stop_reason_counts.entry(reason.clone()).or_insert(0) += 1;
98            if reason == "max_tokens" {
99                truncated_count += 1;
100            }
101        }
102
103        // Phase 1: accumulate new metrics
104        tool_error_count += turn.tool_error_count;
105        if let Some(ref tier) = turn.service_tier {
106            if !tier.is_empty() {
107                *service_tiers.entry(tier.clone()).or_insert(0) += 1;
108            }
109        }
110        if let Some(ref spd) = turn.speed {
111            if !spd.is_empty() {
112                *speeds.entry(spd.clone()).or_insert(0) += 1;
113            }
114        }
115        if let Some(ref geo) = turn.inference_geo {
116            if !geo.is_empty() {
117                *inference_geos.entry(geo.clone()).or_insert(0) += 1;
118            }
119        }
120        if let Some(ref branch) = turn.git_branch {
121            if !branch.is_empty() {
122                *git_branches.entry(branch.clone()).or_insert(0) += 1;
123            }
124        }
125
126        // Aggregate tokens
127        total_tokens.add_usage(&turn.usage);
128        total_cost += pricing_cost.total;
129
130        // Model frequency
131        *model_counts.entry(&turn.model).or_insert(0) += 1;
132
133        // Agent summary
134        let is_agent = turn.is_agent;
135        if is_agent {
136            agent_summary.total_agent_turns += 1;
137            agent_summary.agent_output_tokens += output;
138            agent_summary.agent_cost += pricing_cost.total;
139
140            // Per-agent accumulation
141            let aid = turn.agent_id.clone().unwrap_or_default();
142            if !aid.is_empty() {
143                let entry = agent_acc.entry(aid).or_insert((0usize, 0u64, 0.0f64));
144                entry.0 += 1;
145                entry.1 += output;
146                entry.2 += pricing_cost.total;
147            }
148        }
149
150        turn_details.push(TurnDetail {
151            turn_number: i + 1,
152            timestamp: turn.timestamp,
153            model: turn.model.clone(),
154            input_tokens: input,
155            output_tokens: output,
156            cache_write_5m_tokens: cache_write_5m,
157            cache_write_1h_tokens: cache_write_1h,
158            cache_read_tokens: cache_read,
159            context_size,
160            cache_hit_rate,
161            cost: pricing_cost.total,
162            cost_breakdown,
163            stop_reason: turn.stop_reason.clone(),
164            is_agent,
165            is_compaction,
166            context_delta,
167            user_text: turn.user_text.clone(),
168            assistant_text: turn.assistant_text.clone(),
169            tool_names: turn.tool_names.clone(),
170        });
171    }
172
173    // Duration
174    let duration_minutes = match (session.first_timestamp, session.last_timestamp) {
175        (Some(first), Some(last)) => (last - first).num_seconds() as f64 / 60.0,
176        _ => 0.0,
177    };
178
179    // Compaction count
180    let compaction_count = turn_details.iter().filter(|t| t.is_compaction).count();
181
182    // Cache write percentages from total tokens
183    let total_5m = total_tokens.cache_write_5m_tokens;
184    let total_1h = total_tokens.cache_write_1h_tokens;
185    let total_cache_write = total_5m + total_1h;
186    let cache_write_5m_pct = if total_cache_write > 0 {
187        (total_5m as f64 / total_cache_write as f64) * 100.0
188    } else {
189        0.0
190    };
191    let cache_write_1h_pct = if total_cache_write > 0 {
192        (total_1h as f64 / total_cache_write as f64) * 100.0
193    } else {
194        0.0
195    };
196
197    // Per-agent details
198    let mut agents: Vec<AgentDetail> = agent_acc
199        .into_iter()
200        .map(|(aid, (turns, output, cost))| {
201            let meta = agent_meta.get(&aid);
202            AgentDetail {
203                agent_id: aid,
204                agent_type: meta.map_or_else(|| "unknown".into(), |m| m.agent_type.clone()),
205                description: meta.map_or_else(|| "".into(), |m| m.description.clone()),
206                turns,
207                output_tokens: output,
208                cost,
209            }
210        })
211        .collect();
212    agents.sort_by(|a, b| {
213        b.cost
214            .partial_cmp(&a.cost)
215            .unwrap_or(std::cmp::Ordering::Equal)
216    });
217    agent_summary.agents = agents;
218
219    // Primary model
220    let model = model_counts
221        .into_iter()
222        .max_by_key(|(_, count)| *count)
223        .map(|(m, _)| m.to_string())
224        .unwrap_or_default();
225
226    // Autonomy ratio
227    let total_turn_count = session.total_turn_count();
228    let user_prompt_count = session.metadata.user_prompt_count;
229    let autonomy_ratio = if user_prompt_count > 0 {
230        total_turn_count as f64 / user_prompt_count as f64
231    } else {
232        0.0
233    };
234
235    SessionResult {
236        session_id: session.session_id.clone(),
237        project: session
238            .project
239            .clone()
240            .unwrap_or_else(|| "(unknown)".to_string()),
241        turn_details,
242        agent_summary,
243        total_tokens,
244        total_cost,
245        stop_reason_counts,
246        duration_minutes,
247        max_context,
248        compaction_count,
249        cache_write_5m_pct,
250        cache_write_1h_pct,
251        model,
252        // Phase 1: metadata
253        title: session.metadata.title.clone(),
254        tags: session.metadata.tags.clone(),
255        mode: session.metadata.mode.clone(),
256        pr_links: session.metadata.pr_links.clone(),
257        // Autonomy
258        user_prompt_count,
259        autonomy_ratio,
260        // Errors
261        api_error_count: session.metadata.api_error_count,
262        tool_error_count,
263        truncated_count,
264        // Speculation
265        speculation_accepts: session.metadata.speculation_accepts,
266        speculation_time_saved_ms: session.metadata.speculation_time_saved_ms,
267        // Service info
268        service_tiers,
269        speeds,
270        inference_geos,
271        // Git
272        git_branches,
273        // Context Collapse
274        collapse_count: session.metadata.collapse_commits.len(),
275        collapse_summaries: session
276            .metadata
277            .collapse_commits
278            .iter()
279            .map(|c| c.summary.clone())
280            .collect(),
281        collapse_avg_risk: session
282            .metadata
283            .collapse_snapshot
284            .as_ref()
285            .map_or(0.0, |s| s.avg_risk),
286        collapse_max_risk: session
287            .metadata
288            .collapse_snapshot
289            .as_ref()
290            .map_or(0.0, |s| s.max_risk),
291        // Attribution
292        attribution: session.metadata.attribution.clone(),
293        // ── Phase 2: per-session capability inventory ──
294        subagents: build_subagent_summaries(session, calc),
295        plugins: session.plugins.clone(),
296        skills: session.skills.clone(),
297        hooks: session.hooks.clone(),
298        subagent_types: session.subagent_type_aggregates(calc),
299        workflows: build_workflow_summaries(session, calc, claude_home),
300        is_orphan: session.is_orphan,
301    }
302}
303
304/// Build one `SubagentSummary` per `Subagent`, costed via the pricing
305/// calculator. Sort order: by cost descending (most expensive first), matching
306/// the existing `agent_summary.agents` ordering convention.
307fn build_subagent_summaries(
308    session: &SessionData,
309    calc: &PricingCalculator,
310) -> Vec<SubagentSummary> {
311    let mut out: Vec<SubagentSummary> = session
312        .subagents
313        .iter()
314        .map(|sa| {
315            let mut output_tokens: u64 = 0;
316            let mut cost = 0.0f64;
317            for t in &sa.turns {
318                output_tokens += t.usage.output_tokens.unwrap_or(0);
319                cost += calc.calculate_turn_cost(&t.model, &t.usage).total;
320            }
321            SubagentSummary {
322                agent_id: sa.agent_id.clone(),
323                agent_type: sa.agent_type.clone(),
324                description: sa.description.clone(),
325                turns: sa.turns.len(),
326                output_tokens,
327                cost,
328            }
329        })
330        .collect();
331    out.sort_by(|a, b| {
332        b.cost
333            .partial_cmp(&a.cost)
334            .unwrap_or(std::cmp::Ordering::Equal)
335    });
336    out
337}
338
339/// Build one [`WorkflowSummary`] per discovered workflow run for this session.
340///
341/// Snapshot metadata (`workflowName`, `status`, `durationMs`, `agentCount`,
342/// `totalTokens`, `phases`) comes from `scan_session_workflows`, which reads the
343/// `wf_<runId>.json` files. The *measured* totals (`parsed_*`) are re-aggregated
344/// from this session's own `subagents` whose `workflow_run_id == run_id`, so
345/// they reflect exactly what flows into the session/overview cost totals.
346///
347/// Runs are sorted by `run_id` for deterministic output. Returns an empty Vec
348/// when the session has no workflow runs (the common, pre-2.1.159 case).
349///
350/// Public so the HTML payload builder (`output/json.rs`) can reuse the identical
351/// contract instead of duplicating the aggregation.
352pub fn build_workflow_summaries(
353    session: &SessionData,
354    calc: &PricingCalculator,
355    claude_home: &Path,
356) -> Vec<WorkflowSummary> {
357    let runs =
358        match cc_session_jsonl::scanner::scan_session_workflows(&session.session_id, claude_home) {
359            Ok(r) => r,
360            Err(_) => return Vec::new(),
361        };
362    if runs.is_empty() {
363        return Vec::new();
364    }
365
366    let mut out: Vec<WorkflowSummary> = runs
367        .into_iter()
368        .map(|run| {
369            // Measured totals: aggregate this session's parsed subagents that
370            // belong to this run (by workflow_run_id).
371            let mut parsed_agent_count = 0usize;
372            let mut parsed_turns = 0usize;
373            let mut parsed_output_tokens: u64 = 0;
374            let mut parsed_cost = 0.0f64;
375            for sa in &session.subagents {
376                if sa.workflow_run_id.as_deref() != Some(run.run_id.as_str()) {
377                    continue;
378                }
379                parsed_agent_count += 1;
380                parsed_turns += sa.turns.len();
381                for t in &sa.turns {
382                    parsed_output_tokens += t.usage.output_tokens.unwrap_or(0);
383                    parsed_cost += calc.calculate_turn_cost(&t.model, &t.usage).total;
384                }
385            }
386
387            let snapshot = run.snapshot.as_ref();
388            let phases = snapshot
389                .and_then(|s| s.phases.as_ref())
390                .map(|ps| {
391                    ps.iter()
392                        .map(|p| WorkflowPhaseSummary {
393                            title: p.title.clone(),
394                            detail: p.detail.clone(),
395                        })
396                        .collect()
397                })
398                .unwrap_or_default();
399
400            WorkflowSummary {
401                run_id: run.run_id.clone(),
402                workflow_name: snapshot.and_then(|s| s.workflow_name.clone()),
403                status: snapshot.and_then(|s| s.status.clone()),
404                snapshot_duration_ms: snapshot.and_then(|s| s.duration_ms),
405                snapshot_agent_count: snapshot.and_then(|s| s.agent_count),
406                snapshot_total_tokens: snapshot.and_then(|s| s.total_tokens),
407                phases,
408                parsed_agent_count,
409                parsed_turns,
410                parsed_output_tokens,
411                parsed_cost,
412            }
413        })
414        .collect();
415
416    out.sort_by(|a, b| a.run_id.cmp(&b.run_id));
417    out
418}