claude-code-wrapped 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "WebFetch(domain:api.github.com)",
5
+ "WebFetch(domain:github.com)",
6
+ "WebFetch(domain:raw.githubusercontent.com)"
7
+ ]
8
+ }
9
+ }
@@ -1,3 +1,3 @@
1
1
  """Claude Code Wrapped - Your year with Claude Code, Spotify Wrapped style."""
2
2
 
3
- __version__ = "0.1.5"
3
+ __version__ = "0.1.6"
@@ -92,6 +92,25 @@ Examples:
92
92
  "weekday_distribution": stats.weekday_distribution,
93
93
  "estimated_cost_usd": stats.estimated_cost,
94
94
  "cost_by_model": stats.cost_by_model,
95
+ # Averages
96
+ "avg_messages_per_day": round(stats.avg_messages_per_day, 1),
97
+ "avg_messages_per_week": round(stats.avg_messages_per_week, 1),
98
+ "avg_messages_per_month": round(stats.avg_messages_per_month, 1),
99
+ "avg_cost_per_day": round(stats.avg_cost_per_day, 2) if stats.avg_cost_per_day else None,
100
+ "avg_cost_per_week": round(stats.avg_cost_per_week, 2) if stats.avg_cost_per_week else None,
101
+ "avg_cost_per_month": round(stats.avg_cost_per_month, 2) if stats.avg_cost_per_month else None,
102
+ # Code activity
103
+ "total_edits": stats.total_edits,
104
+ "total_writes": stats.total_writes,
105
+ "avg_code_changes_per_day": round(stats.avg_edits_per_day, 1),
106
+ "avg_code_changes_per_week": round(stats.avg_edits_per_week, 1),
107
+ # Monthly breakdown
108
+ "monthly_costs": stats.monthly_costs,
109
+ "monthly_tokens": stats.monthly_tokens,
110
+ # Longest conversation
111
+ "longest_conversation_messages": stats.longest_conversation_messages,
112
+ "longest_conversation_tokens": stats.longest_conversation_tokens,
113
+ "longest_conversation_date": stats.longest_conversation_date.isoformat() if stats.longest_conversation_date else None,
95
114
  }
96
115
  print(json.dumps(output, indent=2))
97
116
  else:
@@ -71,14 +71,39 @@ class WrappedStats:
71
71
 
72
72
  # Fun stats
73
73
  longest_conversation_tokens: int = 0
74
- avg_messages_per_day: float = 0.0
75
74
  avg_tokens_per_message: float = 0.0
76
75
 
76
+ # Averages (messages)
77
+ avg_messages_per_day: float = 0.0
78
+ avg_messages_per_week: float = 0.0
79
+ avg_messages_per_month: float = 0.0
80
+
81
+ # Averages (cost)
82
+ avg_cost_per_day: float = 0.0
83
+ avg_cost_per_week: float = 0.0
84
+ avg_cost_per_month: float = 0.0
85
+
86
+ # Code activity (from Edit/Write tools)
87
+ total_edits: int = 0
88
+ total_writes: int = 0
89
+ avg_edits_per_day: float = 0.0
90
+ avg_edits_per_week: float = 0.0
91
+
77
92
  # Cost tracking (per model)
78
93
  model_token_usage: dict[str, dict[str, int]] = field(default_factory=dict)
79
94
  estimated_cost: float | None = None
80
95
  cost_by_model: dict[str, float] = field(default_factory=dict)
81
96
 
97
+ # Monthly breakdown for cost table
98
+ monthly_costs: dict[str, float] = field(default_factory=dict) # "YYYY-MM" -> cost
99
+ monthly_tokens: dict[str, dict[str, int]] = field(default_factory=dict) # "YYYY-MM" -> {input, output, ...}
100
+
101
+ # Longest conversation tracking
102
+ longest_conversation_messages: int = 0
103
+ longest_conversation_tokens: int = 0
104
+ longest_conversation_session: str | None = None
105
+ longest_conversation_date: datetime | None = None
106
+
82
107
  @property
83
108
  def total_tokens(self) -> int:
84
109
  return (
@@ -163,6 +188,19 @@ def aggregate_stats(messages: list[Message], year: int) -> WrappedStats:
163
188
  projects = Counter()
164
189
  daily = defaultdict(lambda: DailyStats(date=datetime.now()))
165
190
 
191
+ # Track monthly token usage for cost breakdown
192
+ monthly_tokens: dict[str, dict[str, int]] = defaultdict(
193
+ lambda: {"input": 0, "output": 0, "cache_create": 0, "cache_read": 0}
194
+ )
195
+ monthly_model_tokens: dict[str, dict[str, dict[str, int]]] = defaultdict(
196
+ lambda: defaultdict(lambda: {"input": 0, "output": 0, "cache_create": 0, "cache_read": 0})
197
+ )
198
+
199
+ # Track per-session message counts for longest conversation
200
+ session_messages: dict[str, int] = Counter()
201
+ session_tokens: dict[str, int] = Counter()
202
+ session_first_time: dict[str, datetime] = {}
203
+
166
204
  # Process each message
167
205
  for msg in messages:
168
206
  stats.total_messages += 1
@@ -175,6 +213,10 @@ def aggregate_stats(messages: list[Message], year: int) -> WrappedStats:
175
213
  # Session tracking
176
214
  if msg.session_id:
177
215
  sessions.add(msg.session_id)
216
+ session_messages[msg.session_id] += 1
217
+ # Track first timestamp for each session
218
+ if msg.session_id not in session_first_time and msg.timestamp:
219
+ session_first_time[msg.session_id] = msg.timestamp
178
220
 
179
221
  # Project tracking
180
222
  project_name = extract_project_name(msg.project)
@@ -218,6 +260,25 @@ def aggregate_stats(messages: list[Message], year: int) -> WrappedStats:
218
260
  stats.model_token_usage[raw_model]["cache_create"] += msg.usage.cache_creation_tokens
219
261
  stats.model_token_usage[raw_model]["cache_read"] += msg.usage.cache_read_tokens
220
262
 
263
+ # Track monthly token usage for cost breakdown
264
+ if msg.timestamp:
265
+ month_key = msg.timestamp.strftime("%Y-%m")
266
+ monthly_tokens[month_key]["input"] += msg.usage.input_tokens
267
+ monthly_tokens[month_key]["output"] += msg.usage.output_tokens
268
+ monthly_tokens[month_key]["cache_create"] += msg.usage.cache_creation_tokens
269
+ monthly_tokens[month_key]["cache_read"] += msg.usage.cache_read_tokens
270
+
271
+ # Also track per-model per-month for accurate cost calculation
272
+ if raw_model and raw_model != '<synthetic>':
273
+ monthly_model_tokens[month_key][raw_model]["input"] += msg.usage.input_tokens
274
+ monthly_model_tokens[month_key][raw_model]["output"] += msg.usage.output_tokens
275
+ monthly_model_tokens[month_key][raw_model]["cache_create"] += msg.usage.cache_creation_tokens
276
+ monthly_model_tokens[month_key][raw_model]["cache_read"] += msg.usage.cache_read_tokens
277
+
278
+ # Track per-session tokens for longest conversation
279
+ if msg.session_id:
280
+ session_tokens[msg.session_id] += msg.usage.total_tokens
281
+
221
282
  # Tool usage
222
283
  for tool in msg.tool_calls:
223
284
  stats.tool_calls[tool] += 1
@@ -280,20 +341,62 @@ def aggregate_stats(messages: list[Message], year: int) -> WrappedStats:
280
341
  # Streaks
281
342
  stats.streak_longest, stats.streak_current = calculate_streaks(daily, year)
282
343
 
283
- # Averages
284
- if stats.active_days > 0:
285
- stats.avg_messages_per_day = stats.total_messages / stats.active_days
286
-
287
- if stats.total_assistant_messages > 0:
288
- stats.avg_tokens_per_message = stats.total_tokens / stats.total_assistant_messages
289
-
290
- # Calculate estimated cost
344
+ # Calculate estimated cost first (needed for averages)
291
345
  from .pricing import calculate_total_cost_by_model
292
346
  if stats.model_token_usage:
293
347
  stats.estimated_cost, stats.cost_by_model = calculate_total_cost_by_model(
294
348
  stats.model_token_usage
295
349
  )
296
350
 
351
+ # Calculate monthly costs
352
+ stats.monthly_tokens = dict(monthly_tokens)
353
+ for month_key, model_usage in monthly_model_tokens.items():
354
+ month_cost, _ = calculate_total_cost_by_model(dict(model_usage))
355
+ stats.monthly_costs[month_key] = month_cost
356
+
357
+ # Find longest conversation
358
+ if session_messages:
359
+ longest_session = max(session_messages.items(), key=lambda x: x[1])
360
+ stats.longest_conversation_session = longest_session[0]
361
+ stats.longest_conversation_messages = longest_session[1]
362
+ if longest_session[0] in session_tokens:
363
+ stats.longest_conversation_tokens = session_tokens[longest_session[0]]
364
+ if longest_session[0] in session_first_time:
365
+ stats.longest_conversation_date = session_first_time[longest_session[0]]
366
+
367
+ # Calculate time periods for averages
368
+ today = datetime.now()
369
+ if year == today.year:
370
+ total_days = (today - datetime(year, 1, 1)).days + 1
371
+ else:
372
+ total_days = 366 if year % 4 == 0 else 365
373
+ total_weeks = max(1, total_days / 7)
374
+ total_months = max(1, total_days / 30.44) # Average days per month
375
+
376
+ # Message averages (over total time period, not just active days)
377
+ if total_days > 0:
378
+ stats.avg_messages_per_day = stats.total_messages / total_days
379
+ stats.avg_messages_per_week = stats.total_messages / total_weeks
380
+ stats.avg_messages_per_month = stats.total_messages / total_months
381
+
382
+ # Cost averages
383
+ if stats.estimated_cost is not None and total_days > 0:
384
+ stats.avg_cost_per_day = stats.estimated_cost / total_days
385
+ stats.avg_cost_per_week = stats.estimated_cost / total_weeks
386
+ stats.avg_cost_per_month = stats.estimated_cost / total_months
387
+
388
+ # Token averages
389
+ if stats.total_assistant_messages > 0:
390
+ stats.avg_tokens_per_message = stats.total_tokens / stats.total_assistant_messages
391
+
392
+ # Code activity from Edit/Write tools
393
+ stats.total_edits = stats.tool_calls.get("Edit", 0)
394
+ stats.total_writes = stats.tool_calls.get("Write", 0)
395
+ total_code_changes = stats.total_edits + stats.total_writes
396
+ if total_days > 0:
397
+ stats.avg_edits_per_day = total_code_changes / total_days
398
+ stats.avg_edits_per_week = total_code_changes / total_weeks
399
+
297
400
  return stats
298
401
 
299
402
 
@@ -357,6 +357,70 @@ def simplify_model_name(model: str) -> str:
357
357
  return model
358
358
 
359
359
 
360
+ def create_monthly_cost_table(stats: WrappedStats) -> Panel:
361
+ """Create a monthly cost breakdown table like ccusage."""
362
+ from .pricing import format_cost
363
+
364
+ table = Table(
365
+ show_header=True,
366
+ header_style=Style(color=COLORS["white"], bold=True),
367
+ border_style=Style(color=COLORS["dark"]),
368
+ box=None,
369
+ padding=(0, 1),
370
+ )
371
+
372
+ table.add_column("Month", style=Style(color=COLORS["gray"]))
373
+ table.add_column("Input", justify="right", style=Style(color=COLORS["blue"]))
374
+ table.add_column("Output", justify="right", style=Style(color=COLORS["orange"]))
375
+ table.add_column("Cache", justify="right", style=Style(color=COLORS["purple"]))
376
+ table.add_column("Cost", justify="right", style=Style(color=COLORS["green"], bold=True))
377
+
378
+ # Sort months chronologically
379
+ sorted_months = sorted(stats.monthly_costs.keys())
380
+
381
+ for month_key in sorted_months:
382
+ cost = stats.monthly_costs.get(month_key, 0)
383
+ tokens = stats.monthly_tokens.get(month_key, {})
384
+
385
+ # Format month name
386
+ try:
387
+ month_date = datetime.strptime(month_key, "%Y-%m")
388
+ month_name = month_date.strftime("%b %Y")
389
+ except ValueError:
390
+ month_name = month_key
391
+
392
+ input_tokens = tokens.get("input", 0)
393
+ output_tokens = tokens.get("output", 0)
394
+ cache_tokens = tokens.get("cache_create", 0) + tokens.get("cache_read", 0)
395
+
396
+ table.add_row(
397
+ month_name,
398
+ format_tokens(input_tokens),
399
+ format_tokens(output_tokens),
400
+ format_tokens(cache_tokens),
401
+ format_cost(cost),
402
+ )
403
+
404
+ # Add total row
405
+ if sorted_months:
406
+ table.add_row("", "", "", "", "") # Separator
407
+ table.add_row(
408
+ "Total",
409
+ format_tokens(stats.total_input_tokens),
410
+ format_tokens(stats.total_output_tokens),
411
+ format_tokens(stats.total_cache_creation_tokens + stats.total_cache_read_tokens),
412
+ format_cost(stats.estimated_cost) if stats.estimated_cost else "N/A",
413
+ style=Style(bold=True),
414
+ )
415
+
416
+ return Panel(
417
+ table,
418
+ title="Monthly Cost Breakdown",
419
+ border_style=Style(color=COLORS["green"]),
420
+ padding=(0, 1),
421
+ )
422
+
423
+
360
424
  def create_credits_roll(stats: WrappedStats) -> list[Text]:
361
425
  """Create end credits content."""
362
426
  from .pricing import format_cost
@@ -413,7 +477,59 @@ def create_credits_roll(stats: WrappedStats) -> list[Text]:
413
477
  timeline.append(" [ENTER]", style=Style(color=COLORS["dark"]))
414
478
  frames.append(timeline)
415
479
 
416
- # Frame 3: Cast (models)
480
+ # Frame 3: Averages
481
+ from .pricing import format_cost
482
+ averages = Text()
483
+ averages.append("\n\n\n")
484
+ averages.append(" A V E R A G E S\n\n", style=Style(color=COLORS["blue"], bold=True))
485
+ averages.append(" Messages\n", style=Style(color=COLORS["white"], bold=True))
486
+ averages.append(f" Per day: {stats.avg_messages_per_day:.1f}\n", style=Style(color=COLORS["gray"]))
487
+ averages.append(f" Per week: {stats.avg_messages_per_week:.1f}\n", style=Style(color=COLORS["gray"]))
488
+ averages.append(f" Per month: {stats.avg_messages_per_month:.1f}\n", style=Style(color=COLORS["gray"]))
489
+ if stats.estimated_cost is not None:
490
+ averages.append("\n Cost\n", style=Style(color=COLORS["white"], bold=True))
491
+ averages.append(f" Per day: {format_cost(stats.avg_cost_per_day)}\n", style=Style(color=COLORS["gray"]))
492
+ averages.append(f" Per week: {format_cost(stats.avg_cost_per_week)}\n", style=Style(color=COLORS["gray"]))
493
+ averages.append(f" Per month: {format_cost(stats.avg_cost_per_month)}\n", style=Style(color=COLORS["gray"]))
494
+ averages.append("\n\n")
495
+ averages.append(" [ENTER]", style=Style(color=COLORS["dark"]))
496
+ frames.append(averages)
497
+
498
+ # Frame 4: Code Activity
499
+ code_activity = Text()
500
+ code_activity.append("\n\n\n")
501
+ code_activity.append(" C O D E A C T I V I T Y\n\n", style=Style(color=COLORS["orange"], bold=True))
502
+ total_code_changes = stats.total_edits + stats.total_writes
503
+ code_activity.append(" File Changes\n", style=Style(color=COLORS["white"], bold=True))
504
+ code_activity.append(f" Edits: {stats.total_edits:,}\n", style=Style(color=COLORS["gray"]))
505
+ code_activity.append(f" Writes: {stats.total_writes:,}\n", style=Style(color=COLORS["gray"]))
506
+ code_activity.append(f" Total: {total_code_changes:,}\n", style=Style(color=COLORS["orange"], bold=True))
507
+ code_activity.append("\n Averages\n", style=Style(color=COLORS["white"], bold=True))
508
+ code_activity.append(f" Per day: {stats.avg_edits_per_day:.1f}\n", style=Style(color=COLORS["gray"]))
509
+ code_activity.append(f" Per week: {stats.avg_edits_per_week:.1f}\n", style=Style(color=COLORS["gray"]))
510
+ code_activity.append("\n\n")
511
+ code_activity.append(" [ENTER]", style=Style(color=COLORS["dark"]))
512
+ frames.append(code_activity)
513
+
514
+ # Frame 5: Longest Conversation
515
+ if stats.longest_conversation_messages > 0:
516
+ longest = Text()
517
+ longest.append("\n\n\n")
518
+ longest.append(" L O N G E S T C O N V E R S A T I O N\n\n", style=Style(color=COLORS["purple"], bold=True))
519
+ longest.append(f" Messages ", style=Style(color=COLORS["white"], bold=True))
520
+ longest.append(f"{stats.longest_conversation_messages:,}\n", style=Style(color=COLORS["purple"], bold=True))
521
+ if stats.longest_conversation_tokens > 0:
522
+ longest.append(f" Tokens ", style=Style(color=COLORS["white"], bold=True))
523
+ longest.append(f"{format_tokens(stats.longest_conversation_tokens)}\n", style=Style(color=COLORS["orange"], bold=True))
524
+ if stats.longest_conversation_date:
525
+ longest.append(f" Date ", style=Style(color=COLORS["white"], bold=True))
526
+ longest.append(f"{stats.longest_conversation_date.strftime('%B %d, %Y')}\n", style=Style(color=COLORS["gray"]))
527
+ longest.append("\n That's one epic coding session!\n", style=Style(color=COLORS["gray"]))
528
+ longest.append("\n\n")
529
+ longest.append(" [ENTER]", style=Style(color=COLORS["dark"]))
530
+ frames.append(longest)
531
+
532
+ # Frame 6: Cast (models)
417
533
  cast = Text()
418
534
  cast.append("\n\n\n")
419
535
  cast.append(" S T A R R I N G\n\n", style=Style(color=COLORS["purple"], bold=True))
@@ -424,7 +540,7 @@ def create_credits_roll(stats: WrappedStats) -> list[Text]:
424
540
  cast.append(" [ENTER]", style=Style(color=COLORS["dark"]))
425
541
  frames.append(cast)
426
542
 
427
- # Frame 4: Projects
543
+ # Frame 6: Projects
428
544
  if stats.top_projects:
429
545
  projects = Text()
430
546
  projects.append("\n\n\n")
@@ -559,6 +675,10 @@ def render_wrapped(stats: WrappedStats, console: Console | None = None, animate:
559
675
  )
560
676
  console.print(lists)
561
677
 
678
+ # Monthly cost table
679
+ if stats.monthly_costs:
680
+ console.print(create_monthly_cost_table(stats))
681
+
562
682
  # Insights
563
683
  insights = Text()
564
684
  if stats.most_active_day:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-wrapped",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Your year with Claude Code - Spotify Wrapped style terminal experience",
5
5
  "bin": {
6
6
  "claude-code-wrapped": "./bin/cli.js"
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "claude-code-wrapped"
3
- version = "0.1.5"
3
+ version = "0.1.6"
4
4
  description = "Your year with Claude Code - Spotify Wrapped style terminal experience"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
package/uv.lock CHANGED
@@ -4,7 +4,7 @@ requires-python = ">=3.12"
4
4
 
5
5
  [[package]]
6
6
  name = "claude-code-wrapped"
7
- version = "0.1.4"
7
+ version = "0.1.6"
8
8
  source = { editable = "." }
9
9
  dependencies = [
10
10
  { name = "rich" },