diff --git a/README.md b/README.md index 55e1a01..c7108a2 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ Splitrail is a **fast, cross-platform, real-time token usage tracker and cost mo - [Claude Code](https://github.com/anthropics/claude-code) - [Codex CLI](https://github.com/openai/codex) - [Cline](https://github.com/cline/cline) / [Roo Code](https://github.com/RooCodeInc/Roo-Code) / [Kilo Code](https://github.com/Kilo-Org/kilocode) (VS Code extension + CLI) -- [GitHub Copilot](https://github.com/features/copilot) +- [GitHub Copilot](https://github.com/features/copilot) (VS Code) +- [GitHub Copilot CLI](https://github.com/features/copilot) - [OpenCode](https://github.com/sst/opencode) - [Pi Agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) diff --git a/src/analyzers/copilot.rs b/src/analyzers/copilot.rs index 40e94a0..540a3f4 100644 --- a/src/analyzers/copilot.rs +++ b/src/analyzers/copilot.rs @@ -132,7 +132,7 @@ struct CopilotToolCall { } // Helper function to count tokens in a string using tiktoken -fn count_tokens(text: &str) -> u64 { +pub(crate) fn count_tokens(text: &str) -> u64 { // Use o200k_base encoding (GPT-4o and newer models) match get_bpe_from_model("o200k_base") { Ok(bpe) => { @@ -189,13 +189,13 @@ fn extract_and_hash_project_id_copilot(_file_path: &Path) -> String { hash_text("copilot-global") } -fn is_probably_tool_json_text(text: &str) -> bool { +pub(crate) fn is_probably_tool_json_text(text: &str) -> bool { let trimmed = text.trim_start(); (trimmed.starts_with('{') || trimmed.starts_with("[{")) && trimmed.contains("\"tool\"") } // Helper function to extract model from model_id field -fn extract_model_from_model_id(model_id: &str) -> Option { +pub(crate) fn extract_model_from_model_id(model_id: &str) -> Option { // Model ID format examples: // "generic-copilot/litellm/anthropic/claude-haiku-4.5" // "LiteLLM/Sonnet 4.5" @@ -425,22 +425,11 @@ impl Analyzer for CopilotAnalyzer { fn get_data_glob_patterns(&self) -> Vec { let mut patterns = Vec::new(); - // VSCode forks that might have Copilot installed: Code, Cursor, Windsurf, VSCodium, Positron, Antigravity - let vscode_forks = [ - "Code", - "Cursor", - "Windsurf", - "VSCodium", - "Positron", - "Code - Insiders", - "Antigravity", - ]; - if let Some(home_dir) = dirs::home_dir() { let home_str = home_dir.to_string_lossy(); // macOS paths for all VSCode forks - for fork in &vscode_forks { + for fork in COPILOT_VSCODE_FORKS { patterns.push(format!("{home_str}/Library/Application Support/{fork}/User/workspaceStorage/*/chatSessions/*.json")); } } @@ -449,7 +438,7 @@ impl Analyzer for CopilotAnalyzer { } fn discover_data_sources(&self) -> Result> { - let sources = Self::workspace_storage_dirs() + let sources: Vec = Self::workspace_storage_dirs() .into_iter() .flat_map(|dir| WalkDir::new(dir).min_depth(3).max_depth(3).into_iter()) .filter_map(|e| e.ok()) diff --git a/src/analyzers/copilot_cli.rs b/src/analyzers/copilot_cli.rs new file mode 100644 index 0000000..ad46f41 --- /dev/null +++ b/src/analyzers/copilot_cli.rs @@ -0,0 +1,1131 @@ +use crate::analyzer::{Analyzer, DataSource}; +use crate::contribution_cache::ContributionStrategy; +use crate::models::calculate_total_cost; +use crate::types::{Application, ConversationMessage, MessageRole, Stats}; +use crate::utils::hash_text; +use anyhow::{Context, Result}; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use simd_json::prelude::*; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +use super::copilot::{count_tokens, extract_model_from_model_id, is_probably_tool_json_text}; + +pub struct CopilotCliAnalyzer; + +const COPILOT_CLI_STATE_DIRS: &[&str] = &["session-state", "history-session-state"]; + +impl CopilotCliAnalyzer { + pub fn new() -> Self { + Self + } +} + +fn copilot_cli_session_dirs() -> Vec { + let mut dirs = Vec::new(); + + if let Some(home_dir) = dirs::home_dir() { + let copilot_dir = home_dir.join(".copilot"); + for dir_name in COPILOT_CLI_STATE_DIRS { + let session_dir = copilot_dir.join(dir_name); + if session_dir.is_dir() { + dirs.push(session_dir); + } + } + } + + dirs +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CopilotCliEvent { + #[serde(rename = "type")] + event_type: String, + #[serde(default)] + timestamp: Option, + #[serde(default)] + data: simd_json::OwnedValue, +} + +#[derive(Debug, Clone)] +struct CopilotCliTurn { + user_text: String, + user_date: DateTime, + assistant_date: Option>, + assistant_text_parts: Vec, + reasoning_parts: Vec, + tool_request_parts: Vec, + tool_result_parts: Vec, + stats: Stats, + model: Option, + exact_output_tokens: u64, +} + +#[derive(Debug, Clone)] +struct CopilotCliPendingUser { + text: String, + date: DateTime, + emitted: bool, +} + +#[derive(Debug, Clone, Default)] +struct CopilotCliUsageTotals { + input_tokens: u64, + output_tokens: u64, + cache_read_tokens: u64, + cache_write_tokens: u64, +} + +#[derive(Debug, Clone, Default)] +struct CopilotCliLiveContext { + reusable_input_tokens: u64, + static_prompt_tokens: u64, +} + +impl CopilotCliTurn { + fn new(user_text: String, user_date: DateTime, model: Option) -> Self { + Self { + user_text, + user_date, + assistant_date: None, + assistant_text_parts: Vec::new(), + reasoning_parts: Vec::new(), + tool_request_parts: Vec::new(), + tool_result_parts: Vec::new(), + stats: Stats::default(), + model, + exact_output_tokens: 0, + } + } + + fn has_assistant_content(&self) -> bool { + !self.assistant_text_parts.is_empty() + || !self.reasoning_parts.is_empty() + || !self.tool_request_parts.is_empty() + || !self.tool_result_parts.is_empty() + || self.stats.tool_calls > 0 + || self.exact_output_tokens > 0 + } + + fn input_text(&self, include_user_text: bool) -> String { + let mut parts = Vec::with_capacity(1 + self.tool_result_parts.len()); + if include_user_text && !self.user_text.trim().is_empty() { + parts.push(self.user_text.as_str()); + } + parts.extend( + self.tool_result_parts + .iter() + .map(String::as_str) + .filter(|text| !text.trim().is_empty()), + ); + parts.join("\n") + } + + fn output_text(&self) -> String { + self.reasoning_parts + .iter() + .chain(self.tool_request_parts.iter()) + .chain(self.assistant_text_parts.iter()) + .map(String::as_str) + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n") + } + + fn reasoning_text(&self) -> String { + self.reasoning_parts + .iter() + .map(String::as_str) + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n") + } + + fn reasoning_tokens(&self) -> u64 { + count_tokens(&self.reasoning_text()) + } + + fn visible_output_tokens(&self) -> u64 { + if self.exact_output_tokens > 0 { + self.exact_output_tokens + } else { + let visible_output = self + .tool_request_parts + .iter() + .chain(self.assistant_text_parts.iter()) + .map(String::as_str) + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n"); + count_tokens(&visible_output) + } + } + + fn reusable_context_tokens(&self, include_user_text: bool) -> u64 { + count_tokens(&self.input_text(include_user_text)) + .saturating_add(self.visible_output_tokens()) + } +} + +impl CopilotCliLiveContext { + fn estimated_input_tokens(&self, turn: &CopilotCliTurn, include_user_text: bool) -> u64 { + self.reusable_input_tokens + .saturating_add(count_tokens(&turn.input_text(include_user_text))) + } + + fn estimated_cache_read_tokens(&self) -> u64 { + self.reusable_input_tokens + } + + fn absorb_turn(&mut self, turn: &CopilotCliTurn, include_user_text: bool) { + self.reusable_input_tokens = self + .reusable_input_tokens + .saturating_add(turn.reusable_context_tokens(include_user_text)); + } + + fn apply_compaction(&mut self, event_data: &simd_json::OwnedValue) { + let Some(data) = event_data.as_object() else { + return; + }; + + let compacted_tokens = data + .get("postCompactionTokens") + .and_then(|value| value.as_u64()) + .or_else(|| { + data.get("summaryContent") + .map(extract_text_from_cli_value) + .filter(|text| !text.trim().is_empty()) + .map(|text| count_tokens(&text)) + }); + + if let Some(compacted_tokens) = compacted_tokens { + self.reusable_input_tokens = compacted_tokens; + } + } + + fn update_static_prompt_tokens(&mut self, event_data: &simd_json::OwnedValue) { + if let Some(tool_definition_tokens) = event_data + .as_object() + .and_then(|data| data.get("toolDefinitionsTokens")) + .and_then(|value| value.as_u64()) + { + self.static_prompt_tokens = tool_definition_tokens; + } + } +} + +fn calculate_copilot_cli_cost(stats: &Stats, model_name: &str) -> f64 { + let actual_input_tokens = stats.input_tokens.saturating_sub(stats.cache_read_tokens); + calculate_total_cost( + model_name, + actual_input_tokens, + stats.output_tokens, + stats.cache_creation_tokens, + stats.cache_read_tokens, + ) +} + +fn parse_rfc3339_timestamp(timestamp: Option<&str>) -> Option> { + timestamp.and_then(|ts| { + DateTime::parse_from_rfc3339(ts) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + }) +} + +fn extract_text_from_cli_value(value: &simd_json::OwnedValue) -> String { + match value { + simd_json::OwnedValue::String(s) => s.to_string(), + simd_json::OwnedValue::Array(arr) => arr + .iter() + .map(extract_text_from_cli_value) + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n"), + simd_json::OwnedValue::Object(obj) => { + for key in ["content", "text", "message", "output", "result", "error"] { + if let Some(value) = obj.get(key) { + let text = extract_text_from_cli_value(value); + if !text.trim().is_empty() { + return text; + } + } + } + + obj.iter() + .map(|(_, value)| extract_text_from_cli_value(value)) + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n") + } + _ => String::new(), + } +} + +fn value_to_json_string(value: &simd_json::OwnedValue) -> String { + simd_json::to_vec(value) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .unwrap_or_default() +} + +fn extract_cli_tool_text(tool_name: &str, arguments: &simd_json::OwnedValue) -> String { + let arguments_text = value_to_json_string(arguments); + if arguments_text.is_empty() { + tool_name.to_string() + } else { + format!("{tool_name} {arguments_text}") + } +} + +fn apply_cli_tool_stats(stats: &mut Stats, tool_name: &str) { + match tool_name { + "read_file" => stats.files_read += 1, + "replace_string_in_file" | "multi_replace_string_in_file" => stats.files_edited += 1, + "create_file" => stats.files_added += 1, + "delete_file" => stats.files_deleted += 1, + "file_search" => stats.file_searches += 1, + "grep_search" | "semantic_search" => stats.file_content_searches += 1, + "run_in_terminal" | "bash" | "shell" | "powershell" => stats.terminal_commands += 1, + _ => {} + } +} + +pub(crate) fn is_copilot_cli_session_file(path: &Path) -> bool { + if path.extension().is_none_or(|ext| ext != "jsonl") { + return false; + } + + if path.file_name().is_some_and(|name| name == "events.jsonl") { + return path + .parent() + .and_then(|parent| parent.parent()) + .and_then(|grandparent| grandparent.file_name()) + .and_then(|name| name.to_str()) + .is_some_and(|name| COPILOT_CLI_STATE_DIRS.contains(&name)); + } + + path.parent() + .and_then(|parent| parent.file_name()) + .and_then(|name| name.to_str()) + .is_some_and(|name| COPILOT_CLI_STATE_DIRS.contains(&name)) +} + +fn extract_copilot_cli_project_hash(workspace_path: Option<&str>) -> String { + workspace_path + .map(hash_text) + .unwrap_or_else(|| hash_text("copilot-global")) +} + +fn push_copilot_cli_user_message( + entries: &mut Vec, + pending_user: &CopilotCliPendingUser, + user_index: &mut usize, + conversation_hash: &str, + project_hash: &str, + session_name: Option<&String>, +) { + let user_local_hash = format!("{conversation_hash}-cli-user-{}", *user_index); + let user_global_hash = hash_text(&format!( + "{project_hash}:{conversation_hash}:cli:user:{}:{}", + *user_index, + pending_user.date.to_rfc3339() + )); + + entries.push(ConversationMessage { + application: Application::CopilotCli, + date: pending_user.date, + project_hash: project_hash.to_string(), + conversation_hash: conversation_hash.to_string(), + local_hash: Some(user_local_hash), + global_hash: user_global_hash, + model: None, + stats: Stats::default(), + role: MessageRole::User, + uuid: None, + session_name: session_name.cloned(), + }); + + *user_index += 1; +} + +fn distribute_total(total: u64, weights: &[u64]) -> Vec { + if weights.is_empty() { + return Vec::new(); + } + + if total == 0 { + return vec![0; weights.len()]; + } + + let normalized_weights: Vec = if weights.iter().any(|weight| *weight > 0) { + weights.to_vec() + } else { + vec![1; weights.len()] + }; + let weight_sum: u128 = normalized_weights + .iter() + .map(|weight| *weight as u128) + .sum(); + + let mut distributed = Vec::with_capacity(normalized_weights.len()); + let mut assigned = 0u64; + for (idx, weight) in normalized_weights.iter().enumerate() { + let value = if idx + 1 == normalized_weights.len() { + total.saturating_sub(assigned) + } else { + ((total as u128 * *weight as u128) / weight_sum) as u64 + }; + assigned = assigned.saturating_add(value); + distributed.push(value); + } + + distributed +} + +fn extract_copilot_cli_shutdown_metrics( + event_data: &simd_json::OwnedValue, +) -> BTreeMap { + let mut metrics = BTreeMap::new(); + + let Some(model_metrics) = event_data + .as_object() + .and_then(|data| data.get("modelMetrics")) + .and_then(|value| value.as_object()) + else { + return metrics; + }; + + for (model_name, metrics_value) in model_metrics { + let Some(usage_obj) = metrics_value + .as_object() + .and_then(|metrics_map| metrics_map.get("usage")) + .and_then(|value| value.as_object()) + else { + continue; + }; + + let normalized_model = + extract_model_from_model_id(model_name).unwrap_or_else(|| model_name.to_string()); + + metrics.insert( + normalized_model, + CopilotCliUsageTotals { + input_tokens: usage_obj + .get("inputTokens") + .and_then(|value| value.as_u64()) + .unwrap_or(0), + output_tokens: usage_obj + .get("outputTokens") + .and_then(|value| value.as_u64()) + .unwrap_or(0), + cache_read_tokens: usage_obj + .get("cacheReadTokens") + .and_then(|value| value.as_u64()) + .unwrap_or(0), + cache_write_tokens: usage_obj + .get("cacheWriteTokens") + .and_then(|value| value.as_u64()) + .unwrap_or(0), + }, + ); + } + + metrics +} + +fn apply_copilot_cli_shutdown_metrics( + entries: &mut [ConversationMessage], + shutdown_metrics: &BTreeMap, +) { + for (model_name, usage) in shutdown_metrics { + let assistant_indexes: Vec = entries + .iter() + .enumerate() + .filter(|(_, message)| { + message.application == Application::CopilotCli + && message.role == MessageRole::Assistant + && message.model.as_deref() == Some(model_name.as_str()) + }) + .map(|(idx, _)| idx) + .collect(); + + if assistant_indexes.is_empty() { + continue; + } + + let output_weights: Vec = assistant_indexes + .iter() + .map(|idx| entries[*idx].stats.output_tokens) + .collect(); + + let input_distribution = distribute_total(usage.input_tokens, &output_weights); + let output_distribution = distribute_total(usage.output_tokens, &output_weights); + let cache_read_distribution = distribute_total(usage.cache_read_tokens, &output_weights); + let cache_write_distribution = distribute_total(usage.cache_write_tokens, &output_weights); + + for (position, message_index) in assistant_indexes.iter().enumerate() { + let message = &mut entries[*message_index]; + message.stats.input_tokens = input_distribution[position]; + message.stats.output_tokens = output_distribution[position]; + message.stats.reasoning_tokens = message + .stats + .reasoning_tokens + .min(message.stats.output_tokens); + message.stats.cache_read_tokens = cache_read_distribution[position]; + message.stats.cache_creation_tokens = cache_write_distribution[position]; + message.stats.cached_tokens = + message.stats.cache_read_tokens + message.stats.cache_creation_tokens; + message.stats.cost = calculate_copilot_cli_cost(&message.stats, model_name); + } + } +} + +fn fill_missing_copilot_cli_models( + entries: &mut [ConversationMessage], + shutdown_metrics: &BTreeMap, +) { + if shutdown_metrics.len() != 1 { + return; + } + + let Some(model_name) = shutdown_metrics.keys().next().cloned() else { + return; + }; + + for message in entries.iter_mut() { + if message.application == Application::CopilotCli + && message.role == MessageRole::Assistant + && message.model.is_none() + { + message.model = Some(model_name.clone()); + } + } +} + +fn apply_copilot_cli_live_prompt_overhead( + entries: &mut [ConversationMessage], + static_prompt_tokens: u64, +) { + if static_prompt_tokens == 0 { + return; + } + + for message in entries.iter_mut() { + if message.application != Application::CopilotCli || message.role != MessageRole::Assistant + { + continue; + } + + message.stats.input_tokens = message + .stats + .input_tokens + .saturating_add(static_prompt_tokens); + message.stats.cache_read_tokens = message + .stats + .cache_read_tokens + .saturating_add(static_prompt_tokens); + message.stats.cached_tokens = message + .stats + .cache_creation_tokens + .saturating_add(message.stats.cache_read_tokens); + if let Some(model_name) = message.model.as_deref() { + message.stats.cost = calculate_copilot_cli_cost(&message.stats, model_name); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn flush_copilot_cli_turn( + entries: &mut Vec, + current_turn: &mut Option, + live_context: &mut CopilotCliLiveContext, + pending_user: &mut Option, + user_index: &mut usize, + assistant_index: &mut usize, + conversation_hash: &str, + project_hash: &str, + session_name: Option<&String>, +) { + let Some(turn) = current_turn.take() else { + return; + }; + + let Some(pending_user) = pending_user.as_mut() else { + return; + }; + + let include_user_text = !pending_user.emitted; + + if !pending_user.emitted { + push_copilot_cli_user_message( + entries, + pending_user, + user_index, + conversation_hash, + project_hash, + session_name, + ); + pending_user.emitted = true; + } + + if turn.has_assistant_content() { + let assistant_date = turn.assistant_date.unwrap_or(turn.user_date); + let assistant_local_hash = + format!("{conversation_hash}-cli-assistant-{}", *assistant_index); + let assistant_global_hash = hash_text(&format!( + "{project_hash}:{conversation_hash}:cli:assistant:{}:{}", + *assistant_index, + assistant_date.to_rfc3339() + )); + + let output_text = turn.output_text(); + let estimated_input_tokens = live_context.estimated_input_tokens(&turn, include_user_text); + let estimated_cache_read_tokens = live_context.estimated_cache_read_tokens(); + let output_tokens = if turn.exact_output_tokens > 0 { + turn.exact_output_tokens + } else { + count_tokens(&output_text) + }; + let reasoning_tokens = turn.reasoning_tokens().min(output_tokens); + let model = turn.model.clone(); + live_context.absorb_turn(&turn, include_user_text); + + let mut assistant_stats = turn.stats; + assistant_stats.input_tokens = estimated_input_tokens; + assistant_stats.cache_read_tokens = estimated_cache_read_tokens; + assistant_stats.output_tokens = output_tokens; + assistant_stats.reasoning_tokens = reasoning_tokens; + assistant_stats.cached_tokens = + assistant_stats.cache_read_tokens + assistant_stats.cache_creation_tokens; + if let Some(model_name) = model.as_deref() { + assistant_stats.cost = calculate_copilot_cli_cost(&assistant_stats, model_name); + } + + entries.push(ConversationMessage { + application: Application::CopilotCli, + date: assistant_date, + project_hash: project_hash.to_string(), + conversation_hash: conversation_hash.to_string(), + local_hash: Some(assistant_local_hash), + global_hash: assistant_global_hash, + model, + stats: assistant_stats, + role: MessageRole::Assistant, + uuid: None, + session_name: session_name.cloned(), + }); + + *assistant_index += 1; + } +} + +pub(crate) fn parse_copilot_cli_session_file( + session_file: &Path, +) -> Result> { + let session_content = std::fs::read_to_string(session_file)?; + let mut events = Vec::new(); + + for line in session_content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let mut event_bytes = trimmed.as_bytes().to_vec(); + let event: CopilotCliEvent = + simd_json::from_slice(&mut event_bytes).context("Failed to parse Copilot CLI event")?; + events.push(event); + } + + if events.is_empty() { + return Ok(Vec::new()); + } + + let mut session_id = session_file + .file_stem() + .and_then(|name| name.to_str()) + .map(str::to_string); + let mut workspace_path: Option = None; + let mut session_name: Option = None; + let mut current_model: Option = None; + + for event in &events { + if event.event_type == "session.start" + && let Some(data) = event.data.as_object() + { + if let Some(start_data) = data.get("sessionId").and_then(|value| value.as_str()) { + session_id = Some(start_data.to_string()); + } + + if let Some(context) = data.get("context").and_then(|value| value.as_object()) { + workspace_path = context + .get("cwd") + .and_then(|value| value.as_str()) + .or_else(|| context.get("gitRoot").and_then(|value| value.as_str())) + .map(str::to_string); + + current_model = context + .get("model") + .and_then(|value| value.as_str()) + .and_then(extract_model_from_model_id); + } + } + } + + let conversation_hash = session_id + .as_ref() + .map(|id| hash_text(id)) + .unwrap_or_else(|| hash_text(&session_file.to_string_lossy())); + let project_hash = extract_copilot_cli_project_hash(workspace_path.as_deref()); + + let mut entries = Vec::new(); + let mut pending_user: Option = None; + let mut user_index = 0usize; + let mut assistant_index = 0usize; + let mut current_turn: Option = None; + let mut live_context = CopilotCliLiveContext::default(); + let mut shutdown_metrics: Option> = None; + + for event in events { + let event_timestamp = parse_rfc3339_timestamp(event.timestamp.as_deref()); + let event_data = event.data; + + match event.event_type.as_str() { + "session.model_change" => { + if let Some(new_model) = event_data + .as_object() + .and_then(|data| data.get("newModel")) + .and_then(|value| value.as_str()) + { + current_model = extract_model_from_model_id(new_model); + } + } + "user.message" => { + flush_copilot_cli_turn( + &mut entries, + &mut current_turn, + &mut live_context, + &mut pending_user, + &mut user_index, + &mut assistant_index, + &conversation_hash, + &project_hash, + session_name.as_ref(), + ); + + if let Some(previous_user) = pending_user.take() + && !previous_user.emitted + { + push_copilot_cli_user_message( + &mut entries, + &previous_user, + &mut user_index, + &conversation_hash, + &project_hash, + session_name.as_ref(), + ); + } + + let user_text = event_data + .as_object() + .and_then(|data| data.get("content")) + .map(extract_text_from_cli_value) + .unwrap_or_default(); + + if session_name.is_none() + && !user_text.is_empty() + && !is_probably_tool_json_text(&user_text) + { + let truncated = if user_text.chars().count() > 50 { + format!("{}...", user_text.chars().take(50).collect::()) + } else { + user_text.clone() + }; + session_name = Some(truncated); + } + + pending_user = Some(CopilotCliPendingUser { + text: user_text, + date: event_timestamp.unwrap_or_else(Utc::now), + emitted: false, + }); + } + "assistant.turn_start" => { + flush_copilot_cli_turn( + &mut entries, + &mut current_turn, + &mut live_context, + &mut pending_user, + &mut user_index, + &mut assistant_index, + &conversation_hash, + &project_hash, + session_name.as_ref(), + ); + + let Some(pending_user) = pending_user.as_ref() else { + continue; + }; + current_turn = Some(CopilotCliTurn::new( + pending_user.text.clone(), + pending_user.date, + current_model.clone(), + )); + if let Some(turn) = current_turn.as_mut() { + turn.assistant_date + .get_or_insert_with(|| event_timestamp.unwrap_or(turn.user_date)); + } + } + "assistant.turn_end" => { + flush_copilot_cli_turn( + &mut entries, + &mut current_turn, + &mut live_context, + &mut pending_user, + &mut user_index, + &mut assistant_index, + &conversation_hash, + &project_hash, + session_name.as_ref(), + ); + } + "assistant.message" | "assistant.message.delta" => { + if current_turn.is_none() { + let Some(pending_user) = pending_user.as_ref() else { + continue; + }; + current_turn = Some(CopilotCliTurn::new( + pending_user.text.clone(), + pending_user.date, + current_model.clone(), + )); + } + let Some(turn) = current_turn.as_mut() else { + continue; + }; + + turn.assistant_date + .get_or_insert_with(|| event_timestamp.unwrap_or(turn.user_date)); + + if let Some(data) = event_data.as_object() { + if let Some(model) = data + .get("model") + .and_then(|value| value.as_str()) + .and_then(extract_model_from_model_id) + { + current_model = Some(model.clone()); + turn.model = Some(model); + } else { + turn.model = current_model.clone().or_else(|| turn.model.clone()); + } + + if let Some(content) = data.get("content") { + let text = extract_text_from_cli_value(content); + if !text.trim().is_empty() { + turn.assistant_text_parts.push(text); + } + } + + if let Some(reasoning_text) = data.get("reasoningText") { + let text = extract_text_from_cli_value(reasoning_text); + if !text.trim().is_empty() { + turn.reasoning_parts.push(text); + } + } + + if let Some(output_tokens) = + data.get("outputTokens").and_then(|value| value.as_u64()) + { + turn.exact_output_tokens += output_tokens; + } + + if let Some(tool_requests) = + data.get("toolRequests").and_then(|value| value.as_array()) + { + for request in tool_requests { + if let Some(request_obj) = request.as_object() { + let tool_name = request_obj + .get("toolName") + .and_then(|value| value.as_str()) + .or_else(|| { + request_obj.get("name").and_then(|value| value.as_str()) + }); + + let arguments = request_obj + .get("arguments") + .cloned() + .unwrap_or_else(simd_json::OwnedValue::null); + + if let Some("report_intent") = tool_name + && session_name.is_none() + && let Some(intent) = arguments + .as_object() + .and_then(|args| args.get("intent")) + .and_then(|value| value.as_str()) + { + session_name = Some(intent.to_string()); + } + } + } + } + } + } + "assistant.reasoning" => { + if current_turn.is_none() { + let Some(pending_user) = pending_user.as_ref() else { + continue; + }; + current_turn = Some(CopilotCliTurn::new( + pending_user.text.clone(), + pending_user.date, + current_model.clone(), + )); + } + let Some(turn) = current_turn.as_mut() else { + continue; + }; + + turn.assistant_date + .get_or_insert_with(|| event_timestamp.unwrap_or(turn.user_date)); + turn.model = current_model.clone().or_else(|| turn.model.clone()); + + let text = event_data + .as_object() + .and_then(|data| data.get("content")) + .map(extract_text_from_cli_value) + .unwrap_or_default(); + if !text.trim().is_empty() { + turn.reasoning_parts.push(text); + } + } + "tool.execution_start" => { + if current_turn.is_none() { + let Some(pending_user) = pending_user.as_ref() else { + continue; + }; + current_turn = Some(CopilotCliTurn::new( + pending_user.text.clone(), + pending_user.date, + current_model.clone(), + )); + } + let Some(turn) = current_turn.as_mut() else { + continue; + }; + + turn.assistant_date + .get_or_insert_with(|| event_timestamp.unwrap_or(turn.user_date)); + turn.stats.tool_calls += 1; + + if let Some(data) = event_data.as_object() { + if let Some(model) = data + .get("model") + .and_then(|value| value.as_str()) + .and_then(extract_model_from_model_id) + { + current_model = Some(model.clone()); + turn.model = Some(model); + } else { + turn.model = current_model.clone().or_else(|| turn.model.clone()); + } + + let tool_name = data + .get("toolName") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let arguments = data + .get("arguments") + .cloned() + .unwrap_or_else(simd_json::OwnedValue::null); + + apply_cli_tool_stats(&mut turn.stats, tool_name); + turn.tool_request_parts + .push(extract_cli_tool_text(tool_name, &arguments)); + + if tool_name == "report_intent" + && session_name.is_none() + && let Some(intent) = arguments + .as_object() + .and_then(|args| args.get("intent")) + .and_then(|value| value.as_str()) + { + session_name = Some(intent.to_string()); + } + } + } + "tool.execution_complete" => { + if current_turn.is_none() { + let Some(pending_user) = pending_user.as_ref() else { + continue; + }; + current_turn = Some(CopilotCliTurn::new( + pending_user.text.clone(), + pending_user.date, + current_model.clone(), + )); + } + let Some(turn) = current_turn.as_mut() else { + continue; + }; + + turn.assistant_date + .get_or_insert_with(|| event_timestamp.unwrap_or(turn.user_date)); + + if let Some(data) = event_data.as_object() { + if let Some(model) = data + .get("model") + .and_then(|value| value.as_str()) + .and_then(extract_model_from_model_id) + { + current_model = Some(model.clone()); + turn.model = Some(model); + } else { + turn.model = current_model.clone().or_else(|| turn.model.clone()); + } + + if let Some(result) = data.get("result") { + let text = extract_text_from_cli_value(result); + if !text.trim().is_empty() { + turn.tool_result_parts.push(text); + } + } + } + } + "session.shutdown" => { + let metrics = extract_copilot_cli_shutdown_metrics(&event_data); + if !metrics.is_empty() { + shutdown_metrics = Some(metrics); + } + } + "session.compaction_start" => { + live_context.update_static_prompt_tokens(&event_data); + } + "session.compaction_complete" => { + live_context.apply_compaction(&event_data); + } + "abort" | "session.error" => { + if current_turn.is_none() { + let Some(pending_user) = pending_user.as_ref() else { + continue; + }; + current_turn = Some(CopilotCliTurn::new( + pending_user.text.clone(), + pending_user.date, + current_model.clone(), + )); + } + let Some(turn) = current_turn.as_mut() else { + continue; + }; + + turn.assistant_date + .get_or_insert_with(|| event_timestamp.unwrap_or(turn.user_date)); + + let text = extract_text_from_cli_value(&event_data); + if !text.trim().is_empty() { + turn.assistant_text_parts.push(text); + } + } + _ => {} + } + } + + flush_copilot_cli_turn( + &mut entries, + &mut current_turn, + &mut live_context, + &mut pending_user, + &mut user_index, + &mut assistant_index, + &conversation_hash, + &project_hash, + session_name.as_ref(), + ); + + if let Some(pending_user) = pending_user.take() + && !pending_user.emitted + { + push_copilot_cli_user_message( + &mut entries, + &pending_user, + &mut user_index, + &conversation_hash, + &project_hash, + session_name.as_ref(), + ); + } + + if let Some(shutdown_metrics) = shutdown_metrics { + fill_missing_copilot_cli_models(&mut entries, &shutdown_metrics); + apply_copilot_cli_shutdown_metrics(&mut entries, &shutdown_metrics); + } else { + apply_copilot_cli_live_prompt_overhead(&mut entries, live_context.static_prompt_tokens); + } + + Ok(entries) +} + +#[async_trait] +impl Analyzer for CopilotCliAnalyzer { + fn display_name(&self) -> &'static str { + "GitHub Copilot CLI" + } + + fn get_data_glob_patterns(&self) -> Vec { + let mut patterns = Vec::new(); + + if let Some(home_dir) = dirs::home_dir() { + let home_str = home_dir.to_string_lossy(); + for dir_name in COPILOT_CLI_STATE_DIRS { + patterns.push(format!("{home_str}/.copilot/{dir_name}/*.jsonl")); + patterns.push(format!("{home_str}/.copilot/{dir_name}/*/events.jsonl")); + } + } + + patterns + } + + fn discover_data_sources(&self) -> Result> { + let sources = copilot_cli_session_dirs() + .into_iter() + .flat_map(|dir| WalkDir::new(dir).min_depth(1).max_depth(2).into_iter()) + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry.file_type().is_file() && is_copilot_cli_session_file(entry.path()) + }) + .map(|entry| DataSource { + path: entry.into_path(), + }) + .collect(); + + Ok(sources) + } + + fn is_available(&self) -> bool { + copilot_cli_session_dirs() + .into_iter() + .flat_map(|dir| WalkDir::new(dir).min_depth(1).max_depth(2).into_iter()) + .filter_map(|entry| entry.ok()) + .any(|entry| entry.file_type().is_file() && is_copilot_cli_session_file(entry.path())) + } + + fn parse_source(&self, source: &DataSource) -> Result> { + parse_copilot_cli_session_file(&source.path) + } + + fn get_watch_directories(&self) -> Vec { + copilot_cli_session_dirs() + } + + fn is_valid_data_path(&self, path: &Path) -> bool { + is_copilot_cli_session_file(path) + } + + fn contribution_strategy(&self) -> ContributionStrategy { + ContributionStrategy::SingleSession + } +} diff --git a/src/analyzers/mod.rs b/src/analyzers/mod.rs index 9f612fc..a901f95 100644 --- a/src/analyzers/mod.rs +++ b/src/analyzers/mod.rs @@ -2,6 +2,7 @@ pub mod claude_code; pub mod cline; pub mod codex_cli; pub mod copilot; +pub mod copilot_cli; pub mod gemini_cli; pub mod kilo_cli; pub mod kilo_code; @@ -16,6 +17,7 @@ pub use claude_code::ClaudeCodeAnalyzer; pub use cline::ClineAnalyzer; pub use codex_cli::CodexCliAnalyzer; pub use copilot::CopilotAnalyzer; +pub use copilot_cli::CopilotCliAnalyzer; pub use gemini_cli::GeminiCliAnalyzer; pub use kilo_cli::KiloCliAnalyzer; pub use kilo_code::KiloCodeAnalyzer; diff --git a/src/analyzers/tests/copilot.rs b/src/analyzers/tests/copilot.rs index f8298d6..7ac3ed5 100644 --- a/src/analyzers/tests/copilot.rs +++ b/src/analyzers/tests/copilot.rs @@ -190,4 +190,12 @@ fn test_copilot_glob_patterns() { patterns_str.contains("chatSessions"), "Patterns should include copilot-chat extension" ); + assert!( + !patterns_str.contains(".copilot/session-state"), + "VS Code Copilot patterns should not include Copilot CLI session-state files" + ); + assert!( + !patterns_str.contains("events.jsonl"), + "VS Code Copilot patterns should not include Copilot CLI event files" + ); } diff --git a/src/analyzers/tests/copilot_cli.rs b/src/analyzers/tests/copilot_cli.rs new file mode 100644 index 0000000..16f3fcd --- /dev/null +++ b/src/analyzers/tests/copilot_cli.rs @@ -0,0 +1,321 @@ +use crate::analyzers::copilot_cli::{is_copilot_cli_session_file, parse_copilot_cli_session_file}; +use crate::models::calculate_total_cost; +use crate::types::MessageRole; +use std::path::PathBuf; +use tempfile::tempdir; +use tiktoken_rs::get_bpe_from_model; + +fn token_count(text: &str) -> u64 { + get_bpe_from_model("o200k_base") + .map(|bpe| bpe.encode_with_special_tokens(text).len() as u64) + .unwrap_or_else(|_| (text.len() / 4) as u64) +} + +#[test] +fn test_registry_exposes_separate_copilot_cli_analyzer() { + let registry = crate::create_analyzer_registry(); + + let copilot = registry + .get_analyzer_by_display_name("GitHub Copilot") + .expect("registry should keep the VS Code Copilot analyzer"); + let copilot_patterns = copilot.get_data_glob_patterns().join(" "); + assert!(copilot_patterns.contains("chatSessions")); + assert!(!copilot_patterns.contains(".copilot/session-state")); + + let copilot_cli = registry + .get_analyzer_by_display_name("GitHub Copilot CLI") + .expect("registry should register a dedicated Copilot CLI analyzer"); + let cli_patterns = copilot_cli.get_data_glob_patterns().join(" "); + assert!(cli_patterns.contains(".copilot/session-state")); + assert!(cli_patterns.contains("events.jsonl")); + assert!(!cli_patterns.contains("chatSessions")); +} + +#[test] +fn test_copilot_cli_identifies_valid_session_files() { + let nested_path = PathBuf::from("/home/user/.copilot/session-state/12345678-1234/events.jsonl"); + assert!(is_copilot_cli_session_file(&nested_path)); + + let flat_path = PathBuf::from("/home/user/.copilot/history-session-state/test.jsonl"); + assert!(is_copilot_cli_session_file(&flat_path)); + + let invalid_path = PathBuf::from("/home/user/.copilot/session-state/12345678-1234/meta.json"); + assert!(!is_copilot_cli_session_file(&invalid_path)); +} + +#[test] +fn test_parse_sample_copilot_cli_session() { + let temp_dir = tempdir().unwrap(); + let session_dir = temp_dir.path().join("cli-session"); + std::fs::create_dir_all(&session_dir).unwrap(); + + let session_file = session_dir.join("events.jsonl"); + std::fs::write( + &session_file, + concat!( + r#"{"type":"session.start","timestamp":"2026-02-09T09:28:30.798Z","data":{"sessionId":"cli-session-1","context":{"cwd":"/home/user/project","model":"openai/gpt-4.1"}}}"#, + "\n", + r#"{"type":"user.message","timestamp":"2026-02-09T09:28:31.000Z","data":{"content":"Add a health check endpoint"}}"#, + "\n", + r#"{"type":"assistant.message","timestamp":"2026-02-09T09:28:32.000Z","data":{"reasoningText":"I should inspect the server routes.","content":"I'll add the route and wire it up.","toolRequests":[{"toolCallId":"tool-1","toolName":"read_file","arguments":{"path":"src/main.rs"}},{"toolCallId":"tool-2","toolName":"run_in_terminal","arguments":{"command":"cargo test","description":"Run tests"}}]}}"#, + "\n", + r#"{"type":"tool.execution_start","timestamp":"2026-02-09T09:28:32.100Z","data":{"toolCallId":"tool-1","toolName":"read_file","arguments":{"path":"src/main.rs"}}}"#, + "\n", + r#"{"type":"tool.execution_complete","timestamp":"2026-02-09T09:28:32.200Z","data":{"toolCallId":"tool-1","success":true,"result":{"content":"fn main() {}"}}}"#, + "\n", + r#"{"type":"tool.execution_start","timestamp":"2026-02-09T09:28:32.300Z","data":{"toolCallId":"tool-2","toolName":"run_in_terminal","arguments":{"command":"cargo test","description":"Run tests"}}}"#, + "\n", + r#"{"type":"tool.execution_complete","timestamp":"2026-02-09T09:28:32.400Z","data":{"toolCallId":"tool-2","success":true,"result":{"content":"test result: ok"}}}"#, + "\n", + r#"{"type":"assistant.message.delta","timestamp":"2026-02-09T09:28:33.000Z","data":{"content":"Done — the endpoint is available at /health."}}"#, + "\n" + ), + ) + .unwrap(); + + let messages = parse_copilot_cli_session_file(&session_file).unwrap(); + assert_eq!( + messages.len(), + 2, + "Expected one user message and one assistant message" + ); + + assert_eq!(messages[0].role, MessageRole::User); + assert_eq!(messages[0].model, None); + assert_eq!(messages[0].stats.input_tokens, 0); + assert_eq!(messages[0].stats.output_tokens, 0); + + assert_eq!(messages[1].role, MessageRole::Assistant); + assert_eq!(messages[1].model.as_deref(), Some("gpt-4.1")); + assert_eq!(messages[1].stats.tool_calls, 2); + assert_eq!(messages[1].stats.files_read, 1); + assert_eq!(messages[1].stats.terminal_commands, 1); + assert!(messages[1].stats.input_tokens > 0); + assert!(messages[1].stats.output_tokens > 0); + assert_eq!( + messages[1].session_name.as_deref(), + Some("Add a health check endpoint") + ); +} + +#[test] +fn test_copilot_cli_messages_use_copilot_cli_application_variant() { + let temp_dir = tempdir().unwrap(); + let session_dir = temp_dir.path().join("cli-session"); + std::fs::create_dir_all(&session_dir).unwrap(); + + let session_file = session_dir.join("events.jsonl"); + std::fs::write( + &session_file, + concat!( + r#"{"type":"session.start","timestamp":"2026-02-09T09:28:30.798Z","data":{"sessionId":"cli-session-1","context":{"cwd":"/home/user/project","model":"openai/gpt-4.1"}}}"#, + "\n", + r#"{"type":"user.message","timestamp":"2026-02-09T09:28:31.000Z","data":{"content":"Add a health check endpoint"}}"#, + "\n", + r#"{"type":"assistant.message","timestamp":"2026-02-09T09:28:32.000Z","data":{"reasoningText":"I should inspect the server routes.","content":"I'll add the route and wire it up.","toolRequests":[{"toolCallId":"tool-1","toolName":"read_file","arguments":{"path":"src/main.rs"}}]}}"#, + "\n", + r#"{"type":"tool.execution_start","timestamp":"2026-02-09T09:28:32.100Z","data":{"toolCallId":"tool-1","toolName":"read_file","arguments":{"path":"src/main.rs"}}}"#, + "\n", + r#"{"type":"tool.execution_complete","timestamp":"2026-02-09T09:28:32.200Z","data":{"toolCallId":"tool-1","success":true,"result":{"content":"fn main() {}"}}}"#, + "\n", + r#"{"type":"assistant.message.delta","timestamp":"2026-02-09T09:28:33.000Z","data":{"content":"Done — the endpoint is available at /health."}}"#, + "\n" + ), + ) + .unwrap(); + + let messages = parse_copilot_cli_session_file(&session_file).unwrap(); + assert_eq!(messages.len(), 2); + + let user_application = + String::from_utf8(simd_json::to_vec(&messages[0].application).unwrap()).unwrap(); + let assistant_application = + String::from_utf8(simd_json::to_vec(&messages[1].application).unwrap()).unwrap(); + + assert_eq!(user_application, "\"copilot_cli\""); + assert_eq!(assistant_application, "\"copilot_cli\""); +} + +#[test] +fn test_copilot_cli_uses_shutdown_metrics_for_multi_turn_sessions() { + let temp_dir = tempdir().unwrap(); + let session_dir = temp_dir.path().join("cli-session"); + std::fs::create_dir_all(&session_dir).unwrap(); + + let session_file = session_dir.join("events.jsonl"); + std::fs::write( + &session_file, + concat!( + r#"{"type":"session.start","timestamp":"2026-04-08T05:00:00.000Z","data":{"sessionId":"cli-session-usage","context":{"cwd":"/home/user/project","model":"openai/gpt-5.4"}}}"#, + "\n", + r#"{"type":"user.message","timestamp":"2026-04-08T05:00:01.000Z","data":{"content":"Improve the Copilot CLI parser"}}"#, + "\n", + r#"{"type":"assistant.turn_start","timestamp":"2026-04-08T05:00:02.000Z","data":{"turnId":"0","interactionId":"interaction-1"}}"#, + "\n", + r#"{"type":"assistant.message","timestamp":"2026-04-08T05:00:03.000Z","data":{"messageId":"assistant-1","interactionId":"interaction-1","content":"I'll inspect the current parser.","outputTokens":32700,"toolRequests":[{"toolCallId":"tool-1","toolName":"read_file","arguments":{"path":"src/analyzers/copilot.rs"}}]}}"#, + "\n", + r#"{"type":"tool.execution_start","timestamp":"2026-04-08T05:00:04.000Z","data":{"toolCallId":"tool-1","toolName":"read_file","arguments":{"path":"src/analyzers/copilot.rs"}}}"#, + "\n", + r#"{"type":"tool.execution_complete","timestamp":"2026-04-08T05:00:05.000Z","data":{"toolCallId":"tool-1","success":true,"result":{"content":"pub(crate) fn parse_copilot_cli_session_file(...)"}}}"#, + "\n", + r#"{"type":"assistant.turn_end","timestamp":"2026-04-08T05:00:06.000Z","data":{"turnId":"0"}}"#, + "\n", + r#"{"type":"session.model_change","timestamp":"2026-04-08T05:00:07.000Z","data":{"newModel":"anthropic/claude-sonnet-4.5"}}"#, + "\n", + r#"{"type":"assistant.turn_start","timestamp":"2026-04-08T05:00:08.000Z","data":{"turnId":"1","interactionId":"interaction-1"}}"#, + "\n", + r#"{"type":"assistant.message","timestamp":"2026-04-08T05:00:09.000Z","data":{"messageId":"assistant-2","interactionId":"interaction-1","content":"Now I'll split the CLI analyzer out.","outputTokens":8700,"toolRequests":[{"toolCallId":"tool-2","toolName":"bash","arguments":{"command":"cargo test","description":"Run tests"}}]}}"#, + "\n", + r#"{"type":"tool.execution_start","timestamp":"2026-04-08T05:00:10.000Z","data":{"toolCallId":"tool-2","toolName":"bash","arguments":{"command":"cargo test","description":"Run tests"}}}"#, + "\n", + r#"{"type":"tool.execution_complete","timestamp":"2026-04-08T05:00:11.000Z","data":{"toolCallId":"tool-2","success":true,"result":{"content":"test result: ok"}}}"#, + "\n", + r#"{"type":"assistant.turn_end","timestamp":"2026-04-08T05:00:12.000Z","data":{"turnId":"1"}}"#, + "\n", + r#"{"type":"session.shutdown","timestamp":"2026-04-08T05:00:13.000Z","data":{"shutdownType":"routine","totalPremiumRequests":1,"totalApiDurationMs":811000,"sessionStartTime":1775624400000,"modelMetrics":{"gpt-5.4":{"requests":{"count":1,"cost":1},"usage":{"inputTokens":4700000,"outputTokens":32700,"cacheReadTokens":4600000,"cacheWriteTokens":0}},"claude-sonnet-4.5":{"requests":{"count":1,"cost":0},"usage":{"inputTokens":2300000,"outputTokens":8700,"cacheReadTokens":2200000,"cacheWriteTokens":0}}},"currentModel":"claude-sonnet-4.5","currentTokens":152434,"systemTokens":11351,"conversationTokens":86337,"toolDefinitionsTokens":23939}}"#, + "\n" + ), + ) + .unwrap(); + + let messages = parse_copilot_cli_session_file(&session_file).unwrap(); + + assert_eq!( + messages.len(), + 3, + "Expected one user message plus one assistant message per assistant turn" + ); + + assert_eq!(messages[0].role, MessageRole::User); + assert_eq!(messages[1].role, MessageRole::Assistant); + assert_eq!(messages[2].role, MessageRole::Assistant); + + assert_eq!(messages[1].model.as_deref(), Some("gpt-5.4")); + assert_eq!(messages[1].stats.input_tokens, 4_700_000); + assert_eq!(messages[1].stats.output_tokens, 32_700); + assert_eq!(messages[1].stats.cache_read_tokens, 4_600_000); + assert_eq!(messages[1].stats.tool_calls, 1); + assert_eq!(messages[1].stats.files_read, 1); + let expected_gpt_cost = calculate_total_cost("gpt-5.4", 100_000, 32_700, 0, 4_600_000); + assert!((messages[1].stats.cost - expected_gpt_cost).abs() < f64::EPSILON); + + assert_eq!(messages[2].model.as_deref(), Some("claude-sonnet-4.5")); + assert_eq!(messages[2].stats.input_tokens, 2_300_000); + assert_eq!(messages[2].stats.output_tokens, 8_700); + assert_eq!(messages[2].stats.cache_read_tokens, 2_200_000); + assert_eq!(messages[2].stats.tool_calls, 1); + assert_eq!(messages[2].stats.terminal_commands, 1); + let expected_claude_cost = + calculate_total_cost("claude-sonnet-4.5", 100_000, 8_700, 0, 2_200_000); + assert!((messages[2].stats.cost - expected_claude_cost).abs() < f64::EPSILON); +} + +#[test] +fn test_copilot_cli_infers_model_from_tool_execution_results() { + let temp_dir = tempdir().unwrap(); + let session_dir = temp_dir.path().join("cli-session"); + std::fs::create_dir_all(&session_dir).unwrap(); + + let session_file = session_dir.join("events.jsonl"); + std::fs::write( + &session_file, + concat!( + r#"{"type":"session.start","timestamp":"2026-04-07T11:58:18.193Z","data":{"sessionId":"cli-session-model","context":{"cwd":"/home/user/project"}}}"#, + "\n", + r#"{"type":"user.message","timestamp":"2026-04-07T11:59:29.457Z","data":{"content":"Review this PR"}}"#, + "\n", + r#"{"type":"assistant.turn_start","timestamp":"2026-04-07T11:59:29.476Z","data":{"turnId":"0","interactionId":"interaction-1"}}"#, + "\n", + r#"{"type":"assistant.message","timestamp":"2026-04-07T11:59:53.444Z","data":{"messageId":"assistant-1","interactionId":"interaction-1","content":"","outputTokens":536,"toolRequests":[{"toolCallId":"tool-1","name":"skill","arguments":{"skill":"pr-review"}}]}}"#, + "\n", + r#"{"type":"tool.execution_start","timestamp":"2026-04-07T11:59:53.444Z","data":{"toolCallId":"tool-1","toolName":"skill","arguments":{"skill":"pr-review"}}}"#, + "\n", + r#"{"type":"tool.execution_complete","timestamp":"2026-04-07T11:59:53.472Z","data":{"toolCallId":"tool-1","model":"gpt-5.4","interactionId":"interaction-1","success":true,"result":{"content":"Skill loaded"}}}"#, + "\n", + r#"{"type":"assistant.turn_end","timestamp":"2026-04-07T11:59:53.475Z","data":{"turnId":"0"}}"#, + "\n" + ), + ) + .unwrap(); + + let messages = parse_copilot_cli_session_file(&session_file).unwrap(); + + assert_eq!(messages.len(), 2); + assert_eq!(messages[0].role, MessageRole::User); + assert_eq!(messages[1].role, MessageRole::Assistant); + assert_eq!(messages[1].model.as_deref(), Some("gpt-5.4")); + assert_eq!(messages[1].stats.output_tokens, 536); + assert_eq!(messages[1].stats.tool_calls, 1); +} + +#[test] +fn test_copilot_cli_reasoning_text_populates_reasoning_tokens() { + let temp_dir = tempdir().unwrap(); + let session_dir = temp_dir.path().join("cli-session"); + std::fs::create_dir_all(&session_dir).unwrap(); + + let reasoning_text = "I should inspect the current parser, compare shutdown metrics, and keep output accounting unchanged."; + let session_file = session_dir.join("events.jsonl"); + std::fs::write( + &session_file, + format!( + r#"{{"type":"session.start","timestamp":"2026-04-08T05:00:00.000Z","data":{{"sessionId":"cli-session-reasoning","context":{{"cwd":"/home/user/project","model":"openai/gpt-5.4"}}}}}} +{{"type":"user.message","timestamp":"2026-04-08T05:00:01.000Z","data":{{"content":"Inspect the parser"}}}} +{{"type":"assistant.turn_start","timestamp":"2026-04-08T05:00:02.000Z","data":{{"turnId":"0","interactionId":"interaction-1"}}}} +{{"type":"assistant.message","timestamp":"2026-04-08T05:00:03.000Z","data":{{"messageId":"assistant-1","interactionId":"interaction-1","reasoningText":"{reasoning_text}","content":"I found the parser entrypoint.","outputTokens":1234}}}} +{{"type":"assistant.turn_end","timestamp":"2026-04-08T05:00:04.000Z","data":{{"turnId":"0"}}}} +"# + ), + ) + .unwrap(); + + let messages = parse_copilot_cli_session_file(&session_file).unwrap(); + assert_eq!(messages.len(), 2); + + assert_eq!(messages[1].role, MessageRole::Assistant); + assert_eq!(messages[1].stats.output_tokens, 1_234); + assert_eq!( + messages[1].stats.reasoning_tokens, + token_count(reasoning_text) + ); + assert!(messages[1].stats.reasoning_tokens > 0); +} + +#[test] +fn test_copilot_cli_live_sessions_estimate_input_from_accumulated_context() { + let temp_dir = tempdir().unwrap(); + let session_dir = temp_dir.path().join("cli-session"); + std::fs::create_dir_all(&session_dir).unwrap(); + + let session_file = session_dir.join("events.jsonl"); + let long_context = "analysis ".repeat(400); + std::fs::write( + &session_file, + format!( + r#"{{"type":"session.start","timestamp":"2026-04-08T05:00:00.000Z","data":{{"sessionId":"cli-session-live","context":{{"cwd":"/home/user/project","model":"openai/gpt-5.4"}}}}}} +{{"type":"user.message","timestamp":"2026-04-08T05:00:01.000Z","data":{{"content":"Keep iterating on the parser until the live totals look right."}}}} +{{"type":"assistant.turn_start","timestamp":"2026-04-08T05:00:02.000Z","data":{{"turnId":"0","interactionId":"interaction-1"}}}} +{{"type":"assistant.message","timestamp":"2026-04-08T05:00:03.000Z","data":{{"messageId":"assistant-1","interactionId":"interaction-1","content":"{long_context}","outputTokens":3200}}}} +{{"type":"assistant.turn_end","timestamp":"2026-04-08T05:00:04.000Z","data":{{"turnId":"0"}}}} +{{"type":"assistant.turn_start","timestamp":"2026-04-08T05:00:05.000Z","data":{{"turnId":"1","interactionId":"interaction-1"}}}} +{{"type":"assistant.message","timestamp":"2026-04-08T05:00:06.000Z","data":{{"messageId":"assistant-2","interactionId":"interaction-1","content":"Implemented the remaining parser changes.","outputTokens":1200}}}} +{{"type":"assistant.turn_end","timestamp":"2026-04-08T05:00:07.000Z","data":{{"turnId":"1"}}}} +"# + ), + ) + .unwrap(); + + let messages = parse_copilot_cli_session_file(&session_file).unwrap(); + + assert_eq!(messages.len(), 3); + assert_eq!(messages[1].role, MessageRole::Assistant); + assert_eq!(messages[2].role, MessageRole::Assistant); + assert!( + messages[2].stats.input_tokens > messages[1].stats.input_tokens, + "live sessions should carry prior assistant context into subsequent turn input estimates" + ); + assert!( + messages[2].stats.cache_read_tokens > 0, + "live sessions should expose a non-zero cached prefix estimate once the conversation has prior context" + ); +} diff --git a/src/analyzers/tests/mod.rs b/src/analyzers/tests/mod.rs index ce61338..af70593 100644 --- a/src/analyzers/tests/mod.rs +++ b/src/analyzers/tests/mod.rs @@ -2,6 +2,7 @@ mod claude_code; mod cline; mod codex_cli; mod copilot; +mod copilot_cli; mod gemini_cli; mod kilo_cli; mod kilo_code; diff --git a/src/contribution_cache/single_session.rs b/src/contribution_cache/single_session.rs index c41b995..2f52030 100644 --- a/src/contribution_cache/single_session.rs +++ b/src/contribution_cache/single_session.rs @@ -1,7 +1,9 @@ //! Single-session contribution type for 1-file-1-session analyzers. use super::SessionHash; -use crate::types::{CompactDate, ConversationMessage, ModelCounts, TuiStats, intern_model}; +use crate::types::{ + CompactDate, ConversationMessage, MessageRole, ModelCounts, TuiStats, intern_model, +}; // ============================================================================ // SingleSessionContribution - For 1 file = 1 session analyzers @@ -39,10 +41,13 @@ impl SingleSessionContribution { session_hash = SessionHash::from_str(&msg.conversation_hash); } - if let Some(model) = &msg.model { + if msg.role == MessageRole::Assistant { ai_message_count += 1; - models.increment(intern_model(model), 1); stats += TuiStats::from(&msg.stats); + + if let Some(model) = &msg.model { + models.increment(intern_model(model), 1); + } } } diff --git a/src/main.rs b/src/main.rs index 71e2b06..0111980 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,9 +5,9 @@ use std::sync::Arc; use analyzer::AnalyzerRegistry; use analyzers::{ - ClaudeCodeAnalyzer, ClineAnalyzer, CodexCliAnalyzer, CopilotAnalyzer, GeminiCliAnalyzer, - KiloCliAnalyzer, KiloCodeAnalyzer, OpenCodeAnalyzer, PiAgentAnalyzer, PiebaldAnalyzer, - QwenCodeAnalyzer, RooCodeAnalyzer, + ClaudeCodeAnalyzer, ClineAnalyzer, CodexCliAnalyzer, CopilotAnalyzer, CopilotCliAnalyzer, + GeminiCliAnalyzer, KiloCliAnalyzer, KiloCodeAnalyzer, OpenCodeAnalyzer, PiAgentAnalyzer, + PiebaldAnalyzer, QwenCodeAnalyzer, RooCodeAnalyzer, }; mod analyzer; @@ -198,6 +198,7 @@ pub fn create_analyzer_registry() -> AnalyzerRegistry { registry.register(QwenCodeAnalyzer::new()); registry.register(CodexCliAnalyzer::new()); registry.register(CopilotAnalyzer::new()); + registry.register(CopilotCliAnalyzer::new()); registry.register(OpenCodeAnalyzer::new()); registry.register(PiAgentAnalyzer::new()); registry.register(PiebaldAnalyzer::new()); diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 99f87d5..52d9146 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -372,7 +372,7 @@ impl SplitrailMcpServer { #[tool( name = "list_analyzers", - description = "List all available AI coding tool analyzers (e.g., Claude Code, Codex CLI, Gemini CLI, GitHub Copilot)." + description = "List all available AI coding tool analyzers (e.g., Claude Code, Codex CLI, Gemini CLI, GitHub Copilot, GitHub Copilot CLI)." )] async fn list_analyzers( &self, diff --git a/src/models.rs b/src/models.rs index 7d84e6b..cd00c5c 100644 --- a/src/models.rs +++ b/src/models.rs @@ -391,6 +391,25 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { }), is_estimated: false, }, + "gpt-5.4-pro" => ModelInfo { + pricing: PricingStructure::Tiered(TieredPricing { + tiers: &[ + PricingTier { + max_tokens: Some(272_000), + input_per_1m: 30.0, + output_per_1m: 180.0, + }, + PricingTier { + max_tokens: None, + input_per_1m: 60.0, + output_per_1m: 270.0, + }, + ], + bracket_pricing: false, + }), + caching: CachingSupport::None, + is_estimated: false, + }, "gpt-5.4-mini" => ModelInfo { pricing: PricingStructure::Flat { input_per_1m: 0.75, @@ -926,7 +945,39 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { is_estimated: false, }, + // Moonshot AI Models + "kimi-k2.5" => ModelInfo { + pricing: PricingStructure::Flat { + input_per_1m: 0.60, + output_per_1m: 3.0, + }, + caching: CachingSupport::OpenAI { + cached_input_per_1m: 0.10, + }, + is_estimated: false, + }, + + // ByteDance / Doubao Models + "doubao-seed-2.0-code" => ModelInfo { + pricing: PricingStructure::Flat { + input_per_1m: 0.67, + output_per_1m: 3.36, + }, + caching: CachingSupport::OpenAI { + cached_input_per_1m: 0.14, + }, + is_estimated: true, + }, + // MiniMax Models + "minimax-m2.1" => ModelInfo { + pricing: PricingStructure::Flat { + input_per_1m: 0.30, + output_per_1m: 1.20, + }, + caching: CachingSupport::None, + is_estimated: false, + }, "minimax-m2.5" => ModelInfo { pricing: PricingStructure::Flat { input_per_1m: 0.30, @@ -936,6 +987,26 @@ static MODEL_INDEX: phf::Map<&'static str, ModelInfo> = phf_map! { is_estimated: false, }, + // Meituan Models + "longcat-flash-lite" => ModelInfo { + pricing: PricingStructure::Flat { + input_per_1m: 0.10, + output_per_1m: 0.40, + }, + caching: CachingSupport::None, + is_estimated: true, + }, + + // Routing selector pseudo-models + "auto" => ModelInfo { + pricing: PricingStructure::Flat { + input_per_1m: 0.0, + output_per_1m: 0.0, + }, + caching: CachingSupport::None, + is_estimated: true, + }, + // StepFun Models "step-3.5-flash" => ModelInfo { pricing: PricingStructure::Flat { @@ -1019,11 +1090,15 @@ static MODEL_ALIASES: phf::Map<&'static str, &'static str> = phf_map! { "gpt-5.2-codex" => "gpt-5.2-codex", "gpt-5.3-codex" => "gpt-5.3-codex", "gpt-5-pro" => "gpt-5-pro", + "gpt-5.4-pro" => "gpt-5.4-pro", // Anthropic aliases "claude-opus-4-7" => "claude-opus-4-7", "claude-opus-4.7" => "claude-opus-4-7", "claude-opus-4-6" => "claude-opus-4-6", + "claude-opus-4.6" => "claude-opus-4-6", + "claude-4.6-opus" => "claude-opus-4-6", + "claude-4.6-opus-20260205" => "claude-opus-4-6", "claude-opus-4-5" => "claude-opus-4-5", "claude-opus-4.5" => "claude-opus-4-5", "claude-opus-4-5-20251101" => "claude-opus-4-5", @@ -1039,6 +1114,7 @@ static MODEL_ALIASES: phf::Map<&'static str, &'static str> = phf_map! { "claude-sonnet-4-6" => "claude-sonnet-4-6", "claude-sonnet-4.5" => "claude-sonnet-4-5", "claude-sonnet-4-5" => "claude-sonnet-4-5", + "claude-4.5-sonnet" => "claude-sonnet-4-5", "claude-sonnet-4-5-20250929" => "claude-sonnet-4-5", "claude-3-7-sonnet" => "claude-3-7-sonnet", "claude-3-7-sonnet-20250219" => "claude-3-7-sonnet", @@ -1117,9 +1193,16 @@ static MODEL_ALIASES: phf::Map<&'static str, &'static str> = phf_map! { "gpt-5.4-mini-2026-03-17." => "gpt-5.4-mini", // MiniMax aliases + "minimax-m2.1" => "minimax-m2.1", "minimax-m2.5" => "minimax-m2.5", "minimax-m2.5-20260211" => "minimax-m2.5", + // Moonshot / ByteDance / Meituan aliases + "kimi-k2.5" => "kimi-k2.5", + "doubao-seed-2.0-code" => "doubao-seed-2.0-code", + "doubao-seed-code" => "doubao-seed-2.0-code", + "longcat-flash-lite" => "longcat-flash-lite", + // StepFun aliases "step-3.5-flash" => "step-3.5-flash", @@ -1433,4 +1516,80 @@ mod tests { approx_eq(output_cost, 4.5); approx_eq(cache_cost, 0.075); } + + #[test] + fn claude_reordered_aliases_map_to_existing_pricing() { + let opus = get_model_info("claude-4.6-opus").expect("alias should resolve"); + assert!(!opus.is_estimated); + + let dotted = get_model_info("claude-opus-4.6").expect("dotted opus alias should resolve"); + assert!(!dotted.is_estimated); + + let dated = get_model_info("anthropic/claude-4.6-opus-20260205") + .expect("provider alias should resolve"); + assert!(!dated.is_estimated); + + let sonnet = + get_model_info("claude-4.5-sonnet").expect("reordered sonnet alias should resolve"); + assert!(!sonnet.is_estimated); + + approx_eq(calculate_input_cost("claude-4.6-opus", 1_000_000), 5.0); + approx_eq(calculate_output_cost("claude-4.5-sonnet", 1_000_000), 15.0); + } + + #[test] + fn gpt_5_4_pro_and_provider_prefixed_alias_use_official_pricing() { + let model_info = + get_model_info("pa/gpt-5.4-pro").expect("provider-prefixed alias should resolve"); + assert!(!model_info.is_estimated); + + approx_eq(calculate_input_cost("gpt-5.4-pro", 100_000), 3.0); + approx_eq(calculate_output_cost("pa/gpt-5.4-pro", 100_000), 18.0); + } + + #[test] + fn recent_observed_models_have_lookup_entries() { + let kimi = get_model_info("kimi-k2.5").expect("kimi model should exist"); + assert!(!kimi.is_estimated); + approx_eq(calculate_input_cost("kimi-k2.5", 1_000_000), 0.6); + approx_eq(calculate_output_cost("kimi-k2.5", 1_000_000), 3.0); + approx_eq(calculate_cache_cost("kimi-k2.5", 0, 1_000_000), 0.1); + + let minimax = get_model_info("minimax/minimax-m2.1").expect("minimax model should exist"); + assert!(!minimax.is_estimated); + approx_eq(calculate_input_cost("minimax/minimax-m2.1", 1_000_000), 0.3); + approx_eq( + calculate_output_cost("minimax/minimax-m2.1", 1_000_000), + 1.2, + ); + + let doubao = get_model_info("doubao-seed-code").expect("doubao alias should resolve"); + assert!(doubao.is_estimated); + approx_eq(calculate_input_cost("doubao-seed-code", 1_000_000), 0.67); + approx_eq( + calculate_output_cost("doubao-seed-2.0-code", 1_000_000), + 3.36, + ); + approx_eq(calculate_cache_cost("doubao-seed-code", 0, 1_000_000), 0.14); + + let longcat = + get_model_info("meituan/longcat-flash-lite").expect("meituan model should exist"); + assert!(longcat.is_estimated); + approx_eq( + calculate_input_cost("meituan/longcat-flash-lite", 1_000_000), + 0.1, + ); + approx_eq(calculate_output_cost("longcat-flash-lite", 1_000_000), 0.4); + } + + #[test] + fn auto_route_selector_is_estimated_zero_cost() { + let model_info = + get_model_info("auto").expect("auto selector should have a placeholder entry"); + assert!(model_info.is_estimated); + + approx_eq(calculate_input_cost("auto", 1_000_000), 0.0); + approx_eq(calculate_output_cost("auto", 1_000_000), 0.0); + approx_eq(calculate_cache_cost("auto", 1_000_000, 1_000_000), 0.0); + } } diff --git a/src/tui.rs b/src/tui.rs index fde87d1..56403bd 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1174,7 +1174,7 @@ fn draw_ui( } else { // No data message let no_data_message = Paragraph::new(Text::styled( - "You don't have any agentic development tool data. Once you start using Claude Code / Codex CLI / Gemini CLI / Qwen Code / Cline / Roo Code / Kilo Code / GitHub Copilot / OpenCode / Pi Agent, you'll see some data here.", + "You don't have any agentic development tool data. Once you start using Claude Code / Codex CLI / Gemini CLI / Qwen Code / Cline / Roo Code / Kilo Code / GitHub Copilot / GitHub Copilot CLI / OpenCode / Pi Agent, you'll see some data here.", Style::default().add_modifier(Modifier::DIM), )); frame.render_widget(no_data_message, chunks[1]); diff --git a/src/tui/logic.rs b/src/tui/logic.rs index 1e3d279..b6486aa 100644 --- a/src/tui/logic.rs +++ b/src/tui/logic.rs @@ -2,7 +2,8 @@ /// /// Provides functions to aggregate statistics, filter dates, and check for data presence. use crate::types::{ - CompactDate, ConversationMessage, DailyStats, ModelCounts, Stats, TuiStats, intern_model, + CompactDate, ConversationMessage, DailyStats, MessageRole, ModelCounts, Stats, TuiStats, + intern_model, }; use std::collections::BTreeMap; use std::sync::Arc; @@ -224,10 +225,13 @@ pub fn aggregate_sessions_from_messages( entry.date = CompactDate::from_local(&msg.date); } - // Only aggregate stats for assistant/model messages and track models - if let Some(model) = &msg.model { - entry.models.increment(intern_model(model), 1); + // Only aggregate stats for assistant messages and track models when known. + if msg.role == MessageRole::Assistant { accumulate_tui_stats(&mut entry.stats, &msg.stats); + + if let Some(model) = &msg.model { + entry.models.increment(intern_model(model), 1); + } } // Capture session name if available diff --git a/src/types.rs b/src/types.rs index b9f5323..25dd4d4 100644 --- a/src/types.rs +++ b/src/types.rs @@ -217,6 +217,7 @@ pub enum Application { KiloCode, KiloCli, Copilot, + CopilotCli, OpenCode, PiAgent, Piebald, diff --git a/src/utils.rs b/src/utils.rs index fc32905..08e2756 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use sha2::{Digest, Sha256}; use xxhash_rust::xxh3::xxh3_64; -use crate::types::{CompactDate, ConversationMessage, DailyStats, ModelStats}; +use crate::types::{CompactDate, ConversationMessage, DailyStats, MessageRole, ModelStats}; static WARNED_MESSAGES: OnceLock>> = OnceLock::new(); @@ -209,14 +209,22 @@ pub fn aggregate_by_date(entries: &[ConversationMessage]) -> BTreeMap { - // AI message + match entry.role { + MessageRole::Assistant => { daily_stats_entry.ai_messages += 1; - *daily_stats_entry - .models - .entry(model.to_string()) - .or_insert(0) += 1; + + if let Some(model) = &entry.model { + *daily_stats_entry + .models + .entry(model.to_string()) + .or_insert(0) += 1; + + daily_stats_entry + .model_stats + .entry(model.to_string()) + .or_insert_with(|| ModelStats::new(model.to_string())) + .add_message(&entry.stats); + } // Aggregate TUI-relevant stats only (TuiStats has 6 fields) daily_stats_entry.stats.add_cost(entry.stats.cost); @@ -240,15 +248,8 @@ pub fn aggregate_by_date(entries: &[ConversationMessage]) -> BTreeMap { + MessageRole::User => { // User message - no TUI-relevant stats to aggregate daily_stats_entry.user_messages += 1; } diff --git a/src/utils/tests.rs b/src/utils/tests.rs index e218f5b..9c1a833 100644 --- a/src/utils/tests.rs +++ b/src/utils/tests.rs @@ -361,6 +361,42 @@ fn test_aggregate_by_date_gap_filling() { assert_eq!(result[&date3_str].ai_messages, 1); } +#[test] +fn test_aggregate_by_date_counts_assistant_without_model_as_ai_message() { + let date = Utc.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap(); + let local_date_str = date + .with_timezone(&chrono::Local) + .format("%Y-%m-%d") + .to_string(); + + let msg = ConversationMessage { + date, + application: crate::types::Application::CopilotCli, + project_hash: "p".to_string(), + conversation_hash: "c1".to_string(), + local_hash: None, + global_hash: "g1".to_string(), + model: None, + stats: Stats { + input_tokens: 42, + output_tokens: 84, + ..Stats::default() + }, + role: MessageRole::Assistant, + uuid: None, + session_name: None, + }; + + let result = aggregate_by_date(&[msg]); + let stats = &result[&local_date_str]; + + assert_eq!(stats.user_messages, 0); + assert_eq!(stats.ai_messages, 1); + assert_eq!(stats.stats.input_tokens, 42); + assert_eq!(stats.stats.output_tokens, 84); + assert!(stats.models.is_empty()); +} + #[test] fn test_filter_zero_cost_messages_empty_input() { let messages: Vec = vec![]; diff --git a/vscode-splitrail/README.md b/vscode-splitrail/README.md index a35ac44..4e55fdd 100644 --- a/vscode-splitrail/README.md +++ b/vscode-splitrail/README.md @@ -1,6 +1,6 @@ # Splitrail VS Code Extension -Splitrail is a **fast, cross-platform, real-time Claude Code / Codex CLI / Gemini CLI / Qwen Code / Cline / Roo Code / Kilo Code / GitHub Copilot / OpenCode / Pi Agent token usage tracker and cost monitor.** +Splitrail is a **fast, cross-platform, real-time Claude Code / Codex CLI / Gemini CLI / Qwen Code / Cline / Roo Code / Kilo Code / GitHub Copilot / GitHub Copilot CLI / OpenCode / Pi Agent token usage tracker and cost monitor.** **This is the companion VS Code extension for it.** Upload your usage data to your private account on the [Splitrail Cloud](https://splitrail.dev) for safe-keeping and cross-machine usage aggregation.