1use anyhow::{Context, Result};
2use chrono::{DateTime, Datelike, Local, Timelike, Utc};
3use contrail_types::MasterLog;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::{BTreeMap, HashMap, HashSet};
7use std::fs::File;
8use std::io::{BufRead, BufReader, Write};
9use std::path::{Path, PathBuf};
10
11mod report;
12
13#[derive(Debug, Default)]
14struct SessionAgg {
15 source_tool: String,
16 session_id: String,
17 project_counts: HashMap<String, usize>,
18 started_at: Option<DateTime<Utc>>,
19 ended_at: Option<DateTime<Utc>>,
20 turns: usize,
21 interrupted: bool,
22 clipboard_hits: usize,
23 file_effects: usize,
24 models: HashSet<String>,
25 git_branches: HashSet<String>,
26 token_cumulative_total_max: u64,
27 token_cumulative_prompt_max: u64,
28 token_cumulative_completion_max: u64,
29 token_cumulative_cached_input_max: u64,
30 token_cumulative_reasoning_output_max: u64,
31 saw_token_cumulative: bool,
32 token_sum_prompt: u64,
33 token_sum_completion: u64,
34 token_sum_cached_input: u64,
35 token_sum_cache_creation: u64,
36 saw_token_per_turn: bool,
37}
38
39#[derive(Debug, Serialize)]
40pub struct TopEntry {
41 pub key: String,
42 pub count: u64,
43}
44
45#[derive(Debug, Serialize, Clone)]
46pub struct LongestSession {
47 pub source_tool: String,
48 pub session_id: String,
49 pub project_context: String,
50 pub started_at: DateTime<Utc>,
51 pub ended_at: DateTime<Utc>,
52 pub duration_seconds: i64,
53 pub turns: u64,
54}
55
56#[derive(Debug, Serialize)]
57pub struct TokensSummary {
58 pub sessions_with_token_counts: u64,
59 pub total_tokens: u64,
60 pub prompt_tokens: u64,
61 pub completion_tokens: u64,
62 pub cached_input_tokens: u64,
63 pub reasoning_output_tokens: u64,
64}
65
66#[derive(Debug, Serialize)]
67pub struct CursorUsageSummary {
68 pub team_id: u32,
69 pub start: DateTime<Utc>,
70 pub end: DateTime<Utc>,
71 pub total_input_tokens: u64,
72 pub total_output_tokens: u64,
73 pub total_cache_write_tokens: u64,
74 pub total_cache_read_tokens: u64,
75 pub total_cost_cents: Option<f64>,
76 pub by_model: Vec<CursorModelUsage>,
77}
78
79#[derive(Debug, Serialize)]
80pub struct CursorModelUsage {
81 pub model_intent: String,
82 pub input_tokens: u64,
83 pub output_tokens: u64,
84 pub cache_write_tokens: u64,
85 pub cache_read_tokens: u64,
86 pub total_cents: Option<f64>,
87 pub request_cost: Option<f64>,
88 pub tier: Option<u32>,
89}
90
91#[derive(Debug, Serialize)]
92pub struct Wrapup {
93 pub year: i32,
94 pub range_start: Option<DateTime<Utc>>,
95 pub range_end: Option<DateTime<Utc>>,
96 pub turns_total: u64,
97 pub sessions_total: u64,
98 pub turns_by_tool: Vec<TopEntry>,
99 pub sessions_by_tool: Vec<TopEntry>,
100 pub roles: Vec<TopEntry>,
101 pub active_days: u64,
102 pub longest_streak_days: u64,
103 pub busiest_day: Option<String>,
104 pub busiest_day_turns: Option<u64>,
105 pub peak_hour_local: Option<u32>,
106 pub peak_hour_turns: Option<u64>,
107 pub top_projects_by_turns: Vec<TopEntry>,
108 pub top_projects_by_sessions: Vec<TopEntry>,
109 pub top_models: Vec<TopEntry>,
110 pub tokens: TokensSummary,
111 pub cursor_usage: Option<CursorUsageSummary>,
112 pub redacted_turns: u64,
113 pub redacted_labels: Vec<TopEntry>,
114 pub clipboard_hits: u64,
115 pub file_effects: u64,
116 pub function_calls: u64,
117 pub function_call_outputs: u64,
118 pub apply_patch_calls: u64,
119 pub antigravity_images: u64,
120 pub unique_projects: u64,
121 pub longest_session_by_duration: Option<LongestSession>,
122 pub longest_session_by_turns: Option<LongestSession>,
123 pub user_turns: u64,
124 pub user_avg_words: Option<f64>,
125 pub user_question_rate: Option<f64>,
126 pub user_code_hint_rate: Option<f64>,
127 pub hourly_activity: Vec<u64>,
128 pub daily_activity: Vec<(String, u64)>,
129 pub total_interrupts: u64,
130 pub languages: Vec<TopEntry>,
131}
132
133pub fn run() -> Result<()> {
134 let mut year: Option<i32> = None;
135 let mut start: Option<DateTime<Utc>> = None;
136 let mut end: Option<DateTime<Utc>> = None;
137 let mut last_days: Option<i64> = None;
138 let mut include_cursor_usage = false;
139 let mut log_path: Option<PathBuf> = None;
140 let mut out_path: Option<PathBuf> = None;
141 let mut top_n: usize = 10;
142
143 let mut args = std::env::args().skip(1).peekable();
144 let mut html_path: Option<PathBuf> = None;
145
146 while let Some(arg) = args.next() {
147 match arg.as_str() {
148 "--help" | "-h" => {
149 print_help();
150 return Ok(());
151 }
152 "--year" => {
153 let val = args.next().context("--year requires YYYY")?;
154 year = Some(val.parse::<i32>().context("invalid --year")?);
155 }
156 "--start" => {
157 let val = args
158 .next()
159 .context("--start requires DATE (YYYY-MM-DD) or RFC3339")?;
160 start = Some(parse_date_arg(&val, DateBoundary::Start)?);
161 }
162 "--end" => {
163 let val = args
164 .next()
165 .context("--end requires DATE (YYYY-MM-DD) or RFC3339")?;
166 end = Some(parse_date_arg(&val, DateBoundary::End)?);
167 }
168 "--last-days" => {
169 let val = args.next().context("--last-days requires N")?;
170 last_days = Some(val.parse::<i64>().context("invalid --last-days")?);
171 }
172 "--cursor-usage" => {
173 include_cursor_usage = true;
174 }
175 "--log" => {
176 let val = args.next().context("--log requires PATH")?;
177 log_path = Some(PathBuf::from(val));
178 }
179 "--out" => {
180 let val = args.next().context("--out requires PATH")?;
181 out_path = Some(PathBuf::from(val));
182 }
183 "--html" => {
184 let val = args.next().context("--html requires PATH")?;
185 html_path = Some(PathBuf::from(val));
186 }
187 "--top" => {
188 let val = args.next().context("--top requires N")?;
189 top_n = val.parse::<usize>().context("invalid --top")?;
190 }
191 other => {
192 anyhow::bail!("unknown arg: {other} (use --help)");
193 }
194 }
195 }
196
197 if last_days.is_some() && (start.is_some() || end.is_some()) {
198 anyhow::bail!("--last-days cannot be combined with --start/--end");
199 }
200
201 if last_days.is_some() && year.is_some() {
202 anyhow::bail!("--last-days cannot be combined with --year");
203 }
204
205 if (start.is_some() || end.is_some()) && year.is_some() {
206 anyhow::bail!("--start/--end cannot be combined with --year");
207 }
208
209 if let Some(days) = last_days {
210 if days <= 0 {
211 anyhow::bail!("--last-days must be a positive integer");
212 }
213 let range_end = Utc::now();
214 let range_start = range_end - chrono::Duration::days(days);
215 start = Some(range_start);
216 end = Some(range_end);
217 }
218
219 let year = year.unwrap_or_else(|| {
220 end.as_ref()
221 .map(|d| d.year())
222 .or_else(|| start.as_ref().map(|d| d.year()))
223 .unwrap_or_else(|| Local::now().year())
224 });
225 let log_path = log_path.unwrap_or_else(default_log_path);
226 let start_filter = start;
227 let end_filter = end;
228 let mut wrapup = compute_wrapup(&log_path, year, start_filter, end_filter, top_n)?;
229
230 if include_cursor_usage {
231 let (cursor_start, cursor_end) = resolve_cursor_usage_range(
232 year,
233 start_filter,
234 end_filter,
235 wrapup.range_start,
236 wrapup.range_end,
237 )?;
238 let cursor_usage = fetch_cursor_usage(cursor_start, cursor_end)?;
239
240 wrapup.tokens.total_tokens = wrapup
241 .tokens
242 .total_tokens
243 .saturating_add(cursor_usage.total_input_tokens)
244 .saturating_add(cursor_usage.total_output_tokens);
245 wrapup.tokens.prompt_tokens = wrapup
246 .tokens
247 .prompt_tokens
248 .saturating_add(cursor_usage.total_input_tokens);
249 wrapup.tokens.completion_tokens = wrapup
250 .tokens
251 .completion_tokens
252 .saturating_add(cursor_usage.total_output_tokens);
253 wrapup.tokens.cached_input_tokens = wrapup
254 .tokens
255 .cached_input_tokens
256 .saturating_add(cursor_usage.total_cache_read_tokens);
257
258 wrapup.cursor_usage = Some(cursor_usage);
259 }
260
261 if let Some(ref html_path) = html_path {
262 let html = report::generate_html_report(&wrapup);
263 if let Some(dir) = html_path.parent() {
264 std::fs::create_dir_all(dir)
265 .with_context(|| format!("create html output dir {:?}", dir))?;
266 }
267 let mut file = File::create(html_path).with_context(|| format!("write {:?}", html_path))?;
268 file.write_all(html.as_bytes())?;
269 println!("Wrote HTML wrapup to {:?}", html_path);
270 }
271
272 let out = serde_json::to_string_pretty(&wrapup)?;
273 if let Some(out_path) = out_path {
274 if let Some(dir) = out_path.parent() {
275 std::fs::create_dir_all(dir).with_context(|| format!("create output dir {:?}", dir))?;
276 }
277 let mut file = File::create(&out_path).with_context(|| format!("write {:?}", out_path))?;
278 file.write_all(out.as_bytes())?;
279 file.write_all(b"\n")?;
280 println!("Wrote JSON wrapup to {:?}", out_path);
281 } else if out_path.is_none() && html_path.is_none() {
282 println!("{out}");
283 }
284 Ok(())
285}
286
287fn print_help() {
288 println!(
289 r#"contrail wrapup
290
291Usage:
292 cargo run -p wrapup -- --year 2025
293 cargo run -p wrapup -- --last-days 30
294
295Options:
296 --year YYYY Year filter (default: current year)
297 --start DATE Range start (YYYY-MM-DD or RFC3339); cannot combine with --year/--last-days
298 --end DATE Range end (YYYY-MM-DD or RFC3339); cannot combine with --year/--last-days
299 --last-days N Range end=now, start=now-N days; cannot combine with --year/--start/--end
300 --cursor-usage Fetch Cursor token usage from Cursor backend API (requires Cursor login; uses local access token)
301 --log PATH Master log path (default: ~/.contrail/logs/master_log.jsonl or $CONTRAIL_LOG_PATH)
302 --out PATH Write JSON output to a file (default: stdout)
303 --html PATH Write HTML report to a file
304 --top N Top-N lists size (default: 10)
305"#
306 );
307}
308
309#[derive(Clone, Copy)]
310enum DateBoundary {
311 Start,
312 End,
313}
314
315fn parse_date_arg(input: &str, boundary: DateBoundary) -> Result<DateTime<Utc>> {
316 if let Ok(ts) = DateTime::parse_from_rfc3339(input) {
317 return Ok(ts.with_timezone(&Utc));
318 }
319
320 let date = chrono::NaiveDate::parse_from_str(input, "%Y-%m-%d").context("invalid date")?;
321 let time = match boundary {
322 DateBoundary::Start => chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
323 DateBoundary::End => chrono::NaiveTime::from_hms_nano_opt(23, 59, 59, 999_999_999).unwrap(),
324 };
325
326 Ok(DateTime::<Utc>::from_naive_utc_and_offset(
327 chrono::NaiveDateTime::new(date, time),
328 Utc,
329 ))
330}
331
332fn default_log_path() -> PathBuf {
333 if let Ok(path) = std::env::var("CONTRAIL_LOG_PATH")
334 && !path.trim().is_empty()
335 {
336 return PathBuf::from(path);
337 }
338 let home = dirs::home_dir().expect("Could not find home directory");
339 home.join(".contrail/logs/master_log.jsonl")
340}
341
342fn compute_wrapup(
343 log_path: &Path,
344 year: i32,
345 start: Option<DateTime<Utc>>,
346 end: Option<DateTime<Utc>>,
347 top_n: usize,
348) -> Result<Wrapup> {
349 let file = File::open(log_path).with_context(|| format!("open {:?}", log_path))?;
350 let reader = BufReader::new(file);
351
352 let mut turns_total: u64 = 0;
353 let mut roles: HashMap<String, u64> = HashMap::new();
354 let mut turns_by_tool: HashMap<String, u64> = HashMap::new();
355 let mut daily_turns: BTreeMap<chrono::NaiveDate, u64> = BTreeMap::new();
356 let mut hourly: HashMap<u32, u64> = HashMap::new();
357 let mut model_counts: HashMap<String, u64> = HashMap::new();
358 let mut project_turns_by_session: HashMap<String, u64> = HashMap::new();
359 let mut redacted_turns: u64 = 0;
360 let mut redacted_labels: HashMap<String, u64> = HashMap::new();
361 let mut clipboard_hits: u64 = 0;
362 let mut file_effects: u64 = 0;
363 let mut function_calls: u64 = 0;
364 let mut function_call_outputs: u64 = 0;
365 let mut apply_patch_calls: u64 = 0;
366 let mut antigravity_images: u64 = 0;
367 let mut language_counts: HashMap<String, u64> = HashMap::new();
368
369 let mut user_turns: u64 = 0;
370 let mut user_words: u64 = 0;
371 let mut user_questions: u64 = 0;
372 let mut user_code_hints: u64 = 0;
373
374 let mut range_start: Option<DateTime<Utc>> = None;
375 let mut range_end: Option<DateTime<Utc>> = None;
376
377 let mut sessions: HashMap<(String, String), SessionAgg> = HashMap::new();
378
379 let mut last_seen_map: HashMap<(String, String), DateTime<Utc>> = HashMap::new();
380 let mut sub_session_index_map: HashMap<(String, String), usize> = HashMap::new();
381
382 for line in reader.lines() {
383 let line = line?;
384 let log = match serde_json::from_str::<MasterLog>(&line) {
385 Ok(v) => v,
386 Err(_) => continue,
387 };
388
389 if start.is_some() || end.is_some() {
390 if start.is_some_and(|s| log.timestamp < s) {
391 continue;
392 }
393 if end.is_some_and(|e| log.timestamp > e) {
394 continue;
395 }
396 } else if log.timestamp.year() != year {
397 continue;
398 }
399
400 let raw_key = (log.source_tool.clone(), log.session_id.clone());
401 let last_ts = *last_seen_map.get(&raw_key).unwrap_or(&log.timestamp);
402
403 let gap = log.timestamp.signed_duration_since(last_ts);
404 if gap > chrono::Duration::minutes(30) {
405 *sub_session_index_map.entry(raw_key.clone()).or_insert(0) += 1;
406 }
407 last_seen_map.insert(raw_key.clone(), log.timestamp);
408
409 let sub_idx = *sub_session_index_map.get(&raw_key).unwrap_or(&0);
410 let effective_session_id = if sub_idx > 0 {
411 format!("{}#{}", log.session_id, sub_idx)
412 } else {
413 log.session_id.clone()
414 };
415
416 turns_total += 1;
417 let local_ts = log.timestamp.with_timezone(&Local);
418 *daily_turns.entry(local_ts.date_naive()).or_insert(0) += 1;
419 *hourly.entry(local_ts.hour()).or_insert(0) += 1;
420
421 range_start = Some(range_start.map_or(log.timestamp, |v| v.min(log.timestamp)));
422 range_end = Some(range_end.map_or(log.timestamp, |v| v.max(log.timestamp)));
423
424 *turns_by_tool.entry(log.source_tool.clone()).or_insert(0) += 1;
425 *roles.entry(log.interaction.role.clone()).or_insert(0) += 1;
426
427 if log.security_flags.has_pii {
428 redacted_turns += 1;
429 }
430 for label in &log.security_flags.redacted_secrets {
431 *redacted_labels.entry(label.clone()).or_insert(0) += 1;
432 }
433
434 let meta_obj = log.metadata.as_object();
435 if let Some(obj) = meta_obj {
436 if obj
437 .get("copied_to_clipboard")
438 .and_then(Value::as_bool)
439 .unwrap_or(false)
440 {
441 clipboard_hits += 1;
442 }
443 if let Some(arr) = obj.get("file_effects").and_then(Value::as_array) {
444 file_effects += arr.len() as u64;
445 for effect in arr {
446 let path_str = effect
447 .as_str()
448 .or_else(|| effect.get("path").and_then(Value::as_str));
449
450 if let Some(path) = path_str
451 && let Some(ext) = Path::new(path).extension().and_then(|e| e.to_str())
452 {
453 let ext = ext.to_lowercase();
454 if !matches!(
455 ext.as_str(),
456 "json" | "md" | "txt" | "csv" | "png" | "jpg" | "lock"
457 ) {
458 *language_counts.entry(ext).or_insert(0) += 1;
459 }
460 }
461 }
462 }
463 if obj
464 .get("interrupted")
465 .and_then(Value::as_bool)
466 .unwrap_or(false)
467 {
468 let key = (log.source_tool.clone(), effective_session_id.clone());
469 let sess = sessions.entry(key).or_insert_with(|| SessionAgg {
470 source_tool: log.source_tool.clone(),
471 session_id: effective_session_id.clone(),
472 ..Default::default()
473 });
474 sess.interrupted = true;
475 }
476 if let Some(model) = obj.get("model").and_then(Value::as_str) {
477 let model = model.trim();
478 if !model.is_empty() {
479 *model_counts.entry(model.to_string()).or_insert(0) += 1;
480 }
481 }
482
483 if log.source_tool == "antigravity"
484 && let Some(n) = obj
485 .get("antigravity_image_count")
486 .and_then(Value::as_u64)
487 .or_else(|| {
488 obj.get("antigravity_image_count")
489 .and_then(Value::as_i64)
490 .and_then(|v| u64::try_from(v).ok())
491 })
492 {
493 antigravity_images = antigravity_images.saturating_add(n);
494 }
495 }
496
497 if log.interaction.role == "user" {
498 user_turns += 1;
499 user_words += word_count(&log.interaction.content) as u64;
500 if log.interaction.content.contains('?') {
501 user_questions += 1;
502 }
503 if looks_like_code(&log.interaction.content) {
504 user_code_hints += 1;
505 }
506 }
507
508 let key = (log.source_tool.clone(), effective_session_id.clone());
509 let sess = sessions.entry(key).or_insert_with(|| SessionAgg {
510 source_tool: log.source_tool.clone(),
511 session_id: effective_session_id.clone(),
512 ..Default::default()
513 });
514 sess.turns += 1;
515 *sess
516 .project_counts
517 .entry(log.project_context.clone())
518 .or_insert(0) += 1;
519 sess.started_at = Some(
520 sess.started_at
521 .map_or(log.timestamp, |v| v.min(log.timestamp)),
522 );
523 sess.ended_at = Some(
524 sess.ended_at
525 .map_or(log.timestamp, |v| v.max(log.timestamp)),
526 );
527
528 if let Some(obj) = meta_obj {
529 if let Some(arr) = obj.get("file_effects").and_then(Value::as_array) {
530 sess.file_effects += arr.len();
531 }
532 if obj
533 .get("copied_to_clipboard")
534 .and_then(Value::as_bool)
535 .unwrap_or(false)
536 {
537 sess.clipboard_hits += 1;
538 }
539 if let Some(branch) = obj.get("git_branch").and_then(Value::as_str) {
540 let branch = branch.trim();
541 if !branch.is_empty() {
542 sess.git_branches.insert(branch.to_string());
543 }
544 }
545 if let Some(model) = obj.get("model").and_then(Value::as_str) {
546 let model = model.trim();
547 if !model.is_empty() {
548 sess.models.insert(model.to_string());
549 }
550 }
551
552 update_cumulative_tokens_from_metadata(sess, obj);
553 }
554
555 if log.source_tool == "codex-cli"
556 && log.interaction.content.contains("\"token_count\"")
557 && let Some(usage) = extract_token_count_from_content(&log.interaction.content)
558 {
559 sess.saw_token_cumulative = true;
560 sess.token_cumulative_total_max = sess.token_cumulative_total_max.max(usage.total);
561 sess.token_cumulative_prompt_max = sess.token_cumulative_prompt_max.max(usage.prompt);
562 sess.token_cumulative_completion_max =
563 sess.token_cumulative_completion_max.max(usage.completion);
564 sess.token_cumulative_cached_input_max = sess
565 .token_cumulative_cached_input_max
566 .max(usage.cached_input);
567 sess.token_cumulative_reasoning_output_max = sess
568 .token_cumulative_reasoning_output_max
569 .max(usage.reasoning_output);
570 }
571
572 if log.source_tool == "codex-cli"
573 && log.interaction.content.contains("\"type\"")
574 && let Ok(value) = serde_json::from_str::<Value>(&log.interaction.content)
575 {
576 if value.get("type").and_then(Value::as_str) == Some("function_call_output") {
577 function_call_outputs += 1;
578 }
579 if value.get("type").and_then(Value::as_str) == Some("function_call") {
580 function_calls += 1;
581 if let Some(args) = value.get("arguments").and_then(Value::as_str)
582 && args.contains("apply_patch")
583 {
584 apply_patch_calls += 1;
585 }
586 }
587 }
588 }
589
590 let sessions_total = sessions.len() as u64;
591
592 let sessions_by_tool = top_entries(
593 sessions
594 .values()
595 .fold(HashMap::<String, u64>::new(), |mut acc, sess| {
596 *acc.entry(sess.source_tool.clone()).or_insert(0) += 1;
597 acc
598 }),
599 top_n,
600 );
601 let turns_by_tool = top_entries(turns_by_tool, top_n);
602 let roles = top_entries(roles, top_n);
603 let top_models = top_entries(model_counts, top_n);
604 let redacted_labels = top_entries(redacted_labels, top_n);
605
606 let active_days = daily_turns.len() as u64;
607 let longest_streak_days = longest_streak(daily_turns.keys().copied().collect::<Vec<_>>());
608
609 let (busiest_day, busiest_day_turns) = daily_turns
610 .iter()
611 .max_by_key(|(_, c)| *c)
612 .map(|(d, c)| (Some(d.to_string()), Some(*c)))
613 .unwrap_or((None, None));
614
615 let (peak_hour_local, peak_hour_turns) = hourly
616 .iter()
617 .max_by_key(|(_, c)| *c)
618 .map(|(h, c)| (Some(*h), Some(*c)))
619 .unwrap_or((None, None));
620
621 let mut project_sessions: HashMap<String, u64> = HashMap::new();
622 for sess in sessions.values() {
623 let project = pick_project_context(&sess.project_counts);
624 if is_generic_project_context(&project) {
625 continue;
626 }
627 *project_sessions.entry(project.clone()).or_insert(0) += 1;
628 *project_turns_by_session.entry(project).or_insert(0) += sess.turns as u64;
629 }
630
631 let unique_projects = project_turns_by_session.len() as u64;
632 let top_projects_by_turns = top_entries(project_turns_by_session, top_n);
633 let top_projects_by_sessions = top_entries(project_sessions, top_n);
634
635 let (longest_session_by_duration, longest_session_by_turns) =
636 compute_longest_sessions(&sessions);
637
638 let tokens = summarize_tokens(&sessions);
639
640 let total_interrupts = sessions.values().filter(|s| s.interrupted).count() as u64;
641
642 let mut hourly_activity = vec![0u64; 24];
643 for (hour, count) in hourly {
644 if hour < 24 {
645 hourly_activity[hour as usize] = count;
646 }
647 }
648
649 let daily_activity: Vec<(String, u64)> = daily_turns
650 .into_iter()
651 .map(|(d, c)| (d.format("%Y-%m-%d").to_string(), c))
652 .collect();
653
654 Ok(Wrapup {
655 year,
656 range_start,
657 range_end,
658 turns_total,
659 sessions_total,
660 turns_by_tool,
661 sessions_by_tool,
662 roles,
663 active_days,
664 longest_streak_days,
665 busiest_day,
666 busiest_day_turns,
667 peak_hour_local,
668 peak_hour_turns,
669 top_projects_by_turns,
670 top_projects_by_sessions,
671 top_models,
672 tokens,
673 cursor_usage: None,
674 redacted_turns,
675 redacted_labels,
676 clipboard_hits,
677 file_effects,
678 function_calls,
679 function_call_outputs,
680 apply_patch_calls,
681 antigravity_images,
682 unique_projects,
683 longest_session_by_duration,
684 longest_session_by_turns,
685 user_turns,
686 user_avg_words: rate(user_words, user_turns),
687 user_question_rate: pct(user_questions, user_turns),
688 user_code_hint_rate: pct(user_code_hints, user_turns),
689 hourly_activity,
690 daily_activity,
691 total_interrupts,
692 languages: top_entries(language_counts, top_n),
693 })
694}
695
696fn resolve_cursor_usage_range(
697 year: i32,
698 requested_start: Option<DateTime<Utc>>,
699 requested_end: Option<DateTime<Utc>>,
700 observed_start: Option<DateTime<Utc>>,
701 observed_end: Option<DateTime<Utc>>,
702) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
703 if let (Some(start), Some(end)) = (requested_start, requested_end) {
704 anyhow::ensure!(end >= start, "cursor usage range end must be >= start");
705 return Ok((start, end));
706 }
707
708 if requested_start.is_some() || requested_end.is_some() {
709 anyhow::bail!("--cursor-usage requires both --start and --end (or use --last-days)");
710 }
711
712 if let (Some(start), Some(end)) = (observed_start, observed_end) {
713 anyhow::ensure!(end >= start, "cursor usage range end must be >= start");
714 return Ok((start, end));
715 }
716
717 let start = DateTime::<Utc>::from_naive_utc_and_offset(
718 chrono::NaiveDate::from_ymd_opt(year, 1, 1)
719 .context("invalid year")?
720 .and_hms_opt(0, 0, 0)
721 .unwrap(),
722 Utc,
723 );
724 let end = DateTime::<Utc>::from_naive_utc_and_offset(
725 chrono::NaiveDate::from_ymd_opt(year, 12, 31)
726 .context("invalid year")?
727 .and_hms_nano_opt(23, 59, 59, 999_999_999)
728 .unwrap(),
729 Utc,
730 );
731 Ok((start, end))
732}
733
734#[derive(Debug, Deserialize)]
735struct CursorAggregatedUsageResponse {
736 #[serde(default)]
737 aggregations: Vec<CursorAggregatedModelUsage>,
738 #[serde(default, rename = "totalInputTokens")]
739 total_input_tokens: String,
740 #[serde(default, rename = "totalOutputTokens")]
741 total_output_tokens: String,
742 #[serde(default, rename = "totalCacheWriteTokens")]
743 total_cache_write_tokens: String,
744 #[serde(default, rename = "totalCacheReadTokens")]
745 total_cache_read_tokens: String,
746 #[serde(default, rename = "totalCostCents")]
747 total_cost_cents: Option<f64>,
748}
749
750#[derive(Debug, Deserialize)]
751struct CursorAggregatedModelUsage {
752 #[serde(default, rename = "modelIntent")]
753 model_intent: String,
754 #[serde(default, rename = "inputTokens")]
755 input_tokens: Option<String>,
756 #[serde(default, rename = "outputTokens")]
757 output_tokens: Option<String>,
758 #[serde(default, rename = "cacheWriteTokens")]
759 cache_write_tokens: Option<String>,
760 #[serde(default, rename = "cacheReadTokens")]
761 cache_read_tokens: Option<String>,
762 #[serde(default, rename = "totalCents")]
763 total_cents: Option<f64>,
764 #[serde(default, rename = "requestCost")]
765 request_cost: Option<f64>,
766 #[serde(default)]
767 tier: Option<u32>,
768}
769
770fn fetch_cursor_usage(start: DateTime<Utc>, end: DateTime<Utc>) -> Result<CursorUsageSummary> {
771 let token = read_cursor_access_token()?;
772 let client = reqwest::blocking::Client::new();
773
774 let resp = client
775 .post("https://api2.cursor.sh/aiserver.v1.DashboardService/GetAggregatedUsageEvents")
776 .bearer_auth(token)
777 .header("Connect-Protocol-Version", "1")
778 .json(&serde_json::json!({
779 "teamId": 0,
780 "startDate": start.timestamp_millis().to_string(),
781 "endDate": end.timestamp_millis().to_string(),
782 }))
783 .send()
784 .context("Cursor usage request failed")?;
785
786 if !resp.status().is_success() {
787 anyhow::bail!("Cursor usage request failed: HTTP {}", resp.status());
788 }
789
790 let parsed: CursorAggregatedUsageResponse = resp.json().context("parse Cursor usage JSON")?;
791
792 let by_model = parsed
793 .aggregations
794 .into_iter()
795 .map(|m| CursorModelUsage {
796 model_intent: m.model_intent,
797 input_tokens: parse_u64_opt(m.input_tokens),
798 output_tokens: parse_u64_opt(m.output_tokens),
799 cache_write_tokens: parse_u64_opt(m.cache_write_tokens),
800 cache_read_tokens: parse_u64_opt(m.cache_read_tokens),
801 total_cents: m.total_cents,
802 request_cost: m.request_cost,
803 tier: m.tier,
804 })
805 .collect();
806
807 Ok(CursorUsageSummary {
808 team_id: 0,
809 start,
810 end,
811 total_input_tokens: parse_u64(&parsed.total_input_tokens),
812 total_output_tokens: parse_u64(&parsed.total_output_tokens),
813 total_cache_write_tokens: parse_u64(&parsed.total_cache_write_tokens),
814 total_cache_read_tokens: parse_u64(&parsed.total_cache_read_tokens),
815 total_cost_cents: parsed.total_cost_cents,
816 by_model,
817 })
818}
819
820fn read_cursor_access_token() -> Result<String> {
821 let home = dirs::home_dir().context("could not resolve home directory")?;
822 let db_path = home.join("Library/Application Support/Cursor/User/globalStorage/state.vscdb");
823
824 let conn = rusqlite::Connection::open(&db_path)
825 .with_context(|| format!("open Cursor globalStorage DB: {:?}", db_path))?;
826
827 let mut stmt = conn
828 .prepare("SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken'")
829 .context("prepare Cursor access token query")?;
830
831 let token = stmt
832 .query_row([], |row| {
833 use rusqlite::types::ValueRef;
834 let value = row.get_ref(0)?;
835 let data_type = value.data_type();
836 match value {
837 ValueRef::Text(s) => Ok(String::from_utf8_lossy(s).into_owned()),
838 ValueRef::Blob(b) => Ok(String::from_utf8_lossy(b).into_owned()),
839 _ => Err(rusqlite::Error::InvalidColumnType(
840 0,
841 "value".to_string(),
842 data_type,
843 )),
844 }
845 })
846 .context("cursorAuth/accessToken not found (are you logged into Cursor?)")?;
847
848 anyhow::ensure!(!token.trim().is_empty(), "cursorAuth/accessToken was empty");
849
850 Ok(token)
851}
852
853fn parse_u64(s: &str) -> u64 {
854 s.trim().parse::<u64>().unwrap_or(0)
855}
856
857fn parse_u64_opt(s: Option<String>) -> u64 {
858 s.as_deref().map(parse_u64).unwrap_or(0)
859}
860
861fn is_generic_project_context(project_context: &str) -> bool {
862 matches!(
863 project_context,
864 "Imported History" | "Codex Session" | "Unknown" | "Claude Global" | "Antigravity Brain"
865 )
866}
867
868fn top_entries(map: HashMap<String, u64>, top_n: usize) -> Vec<TopEntry> {
869 let mut items: Vec<(String, u64)> = map.into_iter().collect();
870 items.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
871 items
872 .into_iter()
873 .take(top_n)
874 .map(|(k, v)| TopEntry { key: k, count: v })
875 .collect()
876}
877
878fn longest_streak(mut dates: Vec<chrono::NaiveDate>) -> u64 {
879 if dates.is_empty() {
880 return 0;
881 }
882 dates.sort();
883 let mut best = 1u64;
884 let mut current = 1u64;
885 for w in dates.windows(2) {
886 let prev = w[0];
887 let next = w[1];
888 if next == prev + chrono::Days::new(1) {
889 current += 1;
890 } else {
891 best = best.max(current);
892 current = 1;
893 }
894 }
895 best.max(current)
896}
897
898fn pick_project_context(counts: &HashMap<String, usize>) -> String {
899 const GENERIC: &[&str] = &[
900 "Imported History",
901 "Codex Session",
902 "Unknown",
903 "Claude Global",
904 "Antigravity Brain",
905 ];
906
907 let mut entries: Vec<(&String, &usize)> = counts.iter().collect();
908 entries.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
909
910 for (ctx, _) in &entries {
911 if !GENERIC.contains(&ctx.as_str()) {
912 return (*ctx).clone();
913 }
914 }
915 entries
916 .first()
917 .map(|(ctx, _)| (*ctx).clone())
918 .unwrap_or_else(|| "Unknown".to_string())
919}
920
921fn compute_longest_sessions(
922 sessions: &HashMap<(String, String), SessionAgg>,
923) -> (Option<LongestSession>, Option<LongestSession>) {
924 let mut best_duration: Option<LongestSession> = None;
925 let mut best_turns: Option<LongestSession> = None;
926
927 for sess in sessions.values() {
928 let (Some(start), Some(end)) = (sess.started_at, sess.ended_at) else {
929 continue;
930 };
931 let duration_seconds = (end - start).num_seconds();
932 let project = pick_project_context(&sess.project_counts);
933
934 let candidate = LongestSession {
935 source_tool: sess.source_tool.clone(),
936 session_id: sess.session_id.clone(),
937 project_context: project,
938 started_at: start,
939 ended_at: end,
940 duration_seconds,
941 turns: sess.turns as u64,
942 };
943
944 if best_duration
945 .as_ref()
946 .is_none_or(|b| candidate.duration_seconds > b.duration_seconds)
947 {
948 best_duration = Some(candidate.clone());
949 }
950 if best_turns
951 .as_ref()
952 .is_none_or(|b| candidate.turns > b.turns)
953 {
954 best_turns = Some(candidate);
955 }
956 }
957
958 (best_duration, best_turns)
959}
960
961fn summarize_tokens(sessions: &HashMap<(String, String), SessionAgg>) -> TokensSummary {
962 let mut sessions_with = 0u64;
963 let mut total = 0u64;
964 let mut prompt = 0u64;
965 let mut completion = 0u64;
966 let mut cached_input = 0u64;
967 let mut reasoning_output = 0u64;
968
969 for sess in sessions.values() {
970 if sess.saw_token_cumulative && sess.token_cumulative_total_max > 0 {
971 sessions_with += 1;
972 total += sess.token_cumulative_total_max;
973 prompt += sess.token_cumulative_prompt_max;
974 completion += sess.token_cumulative_completion_max;
975 cached_input += sess.token_cumulative_cached_input_max;
976 reasoning_output += sess.token_cumulative_reasoning_output_max;
977 } else if sess.saw_token_per_turn
978 && (sess.token_sum_prompt > 0 || sess.token_sum_completion > 0)
979 {
980 sessions_with += 1;
981 let session_total = sess.token_sum_prompt + sess.token_sum_completion;
982 total += session_total;
983 prompt += sess.token_sum_prompt;
984 completion += sess.token_sum_completion;
985 cached_input += sess.token_sum_cached_input;
986 }
987 }
988
989 TokensSummary {
990 sessions_with_token_counts: sessions_with,
991 total_tokens: total,
992 prompt_tokens: prompt,
993 completion_tokens: completion,
994 cached_input_tokens: cached_input,
995 reasoning_output_tokens: reasoning_output,
996 }
997}
998
999fn update_cumulative_tokens_from_metadata(
1000 sess: &mut SessionAgg,
1001 meta: &serde_json::Map<String, Value>,
1002) {
1003 let read_u64 = |key: &str| {
1004 meta.get(key).and_then(Value::as_u64).or_else(|| {
1005 meta.get(key)
1006 .and_then(Value::as_i64)
1007 .and_then(|n| u64::try_from(n).ok())
1008 })
1009 };
1010
1011 let total = read_u64("usage_cumulative_total_tokens").unwrap_or(0);
1012 if total > 0 {
1013 sess.saw_token_cumulative = true;
1014 sess.token_cumulative_total_max = sess.token_cumulative_total_max.max(total);
1015 sess.token_cumulative_prompt_max = sess
1016 .token_cumulative_prompt_max
1017 .max(read_u64("usage_cumulative_prompt_tokens").unwrap_or(0));
1018 sess.token_cumulative_completion_max = sess
1019 .token_cumulative_completion_max
1020 .max(read_u64("usage_cumulative_completion_tokens").unwrap_or(0));
1021 sess.token_cumulative_cached_input_max = sess
1022 .token_cumulative_cached_input_max
1023 .max(read_u64("usage_cumulative_cached_input_tokens").unwrap_or(0));
1024 sess.token_cumulative_reasoning_output_max = sess
1025 .token_cumulative_reasoning_output_max
1026 .max(read_u64("usage_cumulative_reasoning_output_tokens").unwrap_or(0));
1027 }
1028
1029 let prompt_turn = read_u64("usage_prompt_tokens").unwrap_or(0);
1030 let completion_turn = read_u64("usage_completion_tokens").unwrap_or(0);
1031 if prompt_turn > 0 || completion_turn > 0 {
1032 sess.saw_token_per_turn = true;
1033 sess.token_sum_prompt += prompt_turn;
1034 sess.token_sum_completion += completion_turn;
1035 sess.token_sum_cached_input += read_u64("usage_cached_input_tokens").unwrap_or(0);
1036 sess.token_sum_cache_creation += read_u64("usage_cache_creation_tokens").unwrap_or(0);
1037 }
1038}
1039
1040#[derive(Debug)]
1041struct TokenCountUsage {
1042 total: u64,
1043 prompt: u64,
1044 completion: u64,
1045 cached_input: u64,
1046 reasoning_output: u64,
1047}
1048
1049fn extract_token_count_from_content(content: &str) -> Option<TokenCountUsage> {
1050 let value = serde_json::from_str::<Value>(content).ok()?;
1051 if value.get("type").and_then(Value::as_str)? != "event_msg" {
1052 return None;
1053 }
1054 if value.pointer("/payload/type").and_then(Value::as_str)? != "token_count" {
1055 return None;
1056 }
1057 let total = value
1058 .pointer("/payload/info/total_token_usage/total_tokens")
1059 .and_then(Value::as_u64)
1060 .or_else(|| {
1061 value
1062 .pointer("/payload/info/total_token_usage/total_tokens")
1063 .and_then(Value::as_i64)
1064 .and_then(|n| u64::try_from(n).ok())
1065 })?;
1066
1067 let prompt = value
1068 .pointer("/payload/info/total_token_usage/input_tokens")
1069 .and_then(Value::as_u64)
1070 .unwrap_or(0);
1071 let completion = value
1072 .pointer("/payload/info/total_token_usage/output_tokens")
1073 .and_then(Value::as_u64)
1074 .unwrap_or(0);
1075 let cached_input = value
1076 .pointer("/payload/info/total_token_usage/cached_input_tokens")
1077 .and_then(Value::as_u64)
1078 .unwrap_or(0);
1079 let reasoning_output = value
1080 .pointer("/payload/info/total_token_usage/reasoning_output_tokens")
1081 .and_then(Value::as_u64)
1082 .unwrap_or(0);
1083
1084 Some(TokenCountUsage {
1085 total,
1086 prompt,
1087 completion,
1088 cached_input,
1089 reasoning_output,
1090 })
1091}
1092
1093fn word_count(text: &str) -> usize {
1094 text.split_whitespace().count()
1095}
1096
1097fn looks_like_code(text: &str) -> bool {
1098 if text.contains("```") {
1099 return true;
1100 }
1101 if text.contains("\n ") || text.contains("\n\t") {
1102 return true;
1103 }
1104 for token in ["::", "->", "=>", "{", "}", ";", "&&", "||", "==", "!="] {
1105 if text.contains(token) {
1106 return true;
1107 }
1108 }
1109 false
1110}
1111
1112fn rate(total_words: u64, n: u64) -> Option<f64> {
1113 if n == 0 {
1114 return None;
1115 }
1116 Some(total_words as f64 / n as f64)
1117}
1118
1119fn pct(n: u64, d: u64) -> Option<f64> {
1120 if d == 0 {
1121 return None;
1122 }
1123 Some(100.0 * n as f64 / d as f64)
1124}