claude-code-tracker 1.2.4 → 1.4.0-beta.4

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.
@@ -1,16 +1,33 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- Generates tracking/charts.html from tokens.json + key-prompts/ folder.
3
+ Generates tracking/charts.html from SQLite storage + key-prompts/ folder.
4
4
  Called by stop-hook.sh after each session update.
5
5
 
6
- Usage: python3 generate-charts.py <tokens.json> <output.html>
6
+ Usage: python3 generate-charts.py <tracking_dir_or_tokens.json> <output.html>
7
7
  """
8
8
  import sys, json, os, re, glob
9
9
  from collections import defaultdict
10
10
 
11
- tokens_file = sys.argv[1]
11
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
12
+ sys.path.insert(0, SCRIPT_DIR)
13
+ import storage
14
+
15
+ arg1 = sys.argv[1]
12
16
  output_file = sys.argv[2]
13
17
 
18
+ # Backward compat: accept either tokens.json path or tracking directory
19
+ if arg1.endswith('.json'):
20
+ tracking_dir = os.path.dirname(os.path.abspath(arg1))
21
+ else:
22
+ tracking_dir = os.path.abspath(arg1)
23
+
24
+ data = storage.get_all_turns(tracking_dir)
25
+ agent_data = storage.get_all_agents(tracking_dir)
26
+ skill_data = storage.get_all_skills(tracking_dir)
27
+
28
+ friction_data = storage.get_all_friction(tracking_dir)
29
+ compaction_data = storage.get_all_compactions(tracking_dir)
30
+
14
31
  def format_duration(seconds):
15
32
  if seconds <= 0:
16
33
  return "0m"
@@ -21,9 +38,6 @@ def format_duration(seconds):
21
38
  return f"{h}h {m}m"
22
39
  return f"{m}m {s}s"
23
40
 
24
- with open(tokens_file) as f:
25
- data = json.load(f)
26
-
27
41
  if not data:
28
42
  sys.exit(0)
29
43
 
@@ -72,6 +86,7 @@ total_turns = len(data)
72
86
  total_sessions = len({e.get("session_id") for e in data})
73
87
  sessions_with_data = len({e.get("session_id") for e in data if e.get("total_tokens", 0) > 0})
74
88
  total_output = sum(e.get("output_tokens", 0) for e in data)
89
+ total_input = sum(e.get("input_tokens", 0) for e in data)
75
90
  total_cache_read = sum(e.get("cache_read_tokens", 0) for e in data)
76
91
  total_all_tokens = sum(e.get("total_tokens", 0) for e in data)
77
92
  cache_pct = round(total_cache_read / total_all_tokens * 100, 1) if total_all_tokens > 0 else 0
@@ -81,7 +96,7 @@ avg_duration = total_duration // total_turns if total_turns > 0 else 0
81
96
  project_name = data[0].get("project", "Project") if data else "Project"
82
97
 
83
98
  # --- Count total human messages per date from JSONL transcripts ---
84
- project_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(tokens_file)))) # project root
99
+ project_dir = os.path.dirname(os.path.dirname(tracking_dir)) # project root
85
100
  # Claude Code slugifies paths as: replace every "/" with "-" (keeping leading slash → leading dash)
86
101
  transcripts_dir = os.path.expanduser(
87
102
  "~/.claude/projects/" + project_dir.replace("/", "-")
@@ -107,7 +122,7 @@ if os.path.isdir(transcripts_dir):
107
122
  os.path.getmtime(jf)).strftime("%Y-%m-%d")
108
123
 
109
124
  try:
110
- with open(jf) as f:
125
+ with open(jf, encoding='utf-8') as f:
111
126
  for line in f:
112
127
  try:
113
128
  obj = json.loads(line)
@@ -147,7 +162,7 @@ total_human_msgs = sum(human_by_date.values())
147
162
  total_trivial_msgs = sum(trivial_by_date.values())
148
163
 
149
164
  # --- Aggregate prompt data from key-prompts/ folder ---
150
- prompts_dir = os.path.join(os.path.dirname(tokens_file), "key-prompts")
165
+ prompts_dir = os.path.join(tracking_dir, "key-prompts")
151
166
  prompt_files = sorted(glob.glob(os.path.join(prompts_dir, "????-??-??.md")))
152
167
 
153
168
  prompt_by_date = {} # date -> {total, by_category}
@@ -155,7 +170,7 @@ all_categories = set()
155
170
 
156
171
  for f in prompt_files:
157
172
  date = os.path.splitext(os.path.basename(f))[0]
158
- content = open(f).read()
173
+ content = open(f, encoding='utf-8').read()
159
174
  cats = re.findall(r'^\*\*Category\*\*: (\S+)', content, re.MULTILINE)
160
175
  by_cat = defaultdict(int)
161
176
  for c in cats:
@@ -176,6 +191,8 @@ cache_read_by_date_js = json.dumps([by_date[d]["cache_read"] for d in dates])
176
191
  opus_by_date_js = json.dumps([round(by_date[d]["opus_cost"], 4) for d in dates])
177
192
  sonnet_by_date_js = json.dumps([round(by_date[d]["sonnet_cost"], 4) for d in dates])
178
193
  duration_by_date_js = json.dumps([by_date[d]["duration"] for d in dates])
194
+ input_by_date_js = json.dumps([by_date[d]["input"] for d in dates])
195
+ cache_create_by_date_js = json.dumps([by_date[d]["cache_create"] for d in dates])
179
196
 
180
197
  cumul_labels_js = json.dumps([f"{c['date']} {c['session_id']}#{c['turn_index']}" for c in cumulative])
181
198
  cumul_values_js = json.dumps([c["cumulative_cost"] for c in cumulative])
@@ -285,6 +302,617 @@ donut_labels_js = json.dumps(list(cat_totals.keys()))
285
302
  donut_values_js = json.dumps(list(cat_totals.values()))
286
303
  donut_colors_js = json.dumps([CAT_COLORS.get(c, DEFAULT_COLOR) for c in cat_totals])
287
304
 
305
+ # --- Agent data aggregation ---
306
+ by_agent_type = defaultdict(lambda: {"cost": 0, "count": 0, "turns": 0})
307
+ for a in agent_data:
308
+ t = a.get('agent_type', 'unknown')
309
+ by_agent_type[t]["cost"] += a.get('estimated_cost_usd', 0)
310
+ by_agent_type[t]["count"] += 1
311
+ by_agent_type[t]["turns"] += a.get('turns', 0)
312
+
313
+ agent_types_sorted = sorted(by_agent_type.keys(), key=lambda t: -by_agent_type[t]["cost"])
314
+ total_agent_cost = sum(a.get('estimated_cost_usd', 0) for a in agent_data)
315
+ total_agent_invocations = len(agent_data)
316
+
317
+ agent_labels_js = json.dumps(agent_types_sorted)
318
+ agent_costs_js = json.dumps([round(by_agent_type[t]["cost"], 4) for t in agent_types_sorted])
319
+ agent_counts_js = json.dumps([by_agent_type[t]["count"] for t in agent_types_sorted])
320
+ agent_cpt_js = json.dumps([
321
+ round(by_agent_type[t]["cost"] / by_agent_type[t]["turns"], 4)
322
+ if by_agent_type[t]["turns"] > 0 else 0
323
+ for t in agent_types_sorted
324
+ ])
325
+
326
+ # Conditional HTML blocks
327
+ if agent_data:
328
+ agents_stat_html = f''' <div class="stat">
329
+ <div class="stat-label">Agent cost</div>
330
+ <div class="stat-value">${total_agent_cost:.2f}</div>
331
+ <div class="stat-sub">{total_agent_invocations} invocations</div>
332
+ </div>'''
333
+ agents_section_html = f'''<div class="section">
334
+ <div class="section-header agents">Agents</div>
335
+ <div class="grid">
336
+ <div class="card">
337
+ <h2>Cost by agent type</h2>
338
+ <canvas id="agentCost"></canvas>
339
+ </div>
340
+ <div class="card">
341
+ <h2>Invocations by agent type</h2>
342
+ <canvas id="agentCount"></canvas>
343
+ </div>
344
+ <div class="card">
345
+ <h2>Cost per turn by agent type</h2>
346
+ <canvas id="agentCPT"></canvas>
347
+ </div>
348
+ </div>
349
+ </div>
350
+
351
+ '''
352
+ agents_js_constants = f'''const AGENT_LABELS = {agent_labels_js};
353
+ const AGENT_COSTS = {agent_costs_js};
354
+ const AGENT_COUNTS = {agent_counts_js};
355
+ const AGENT_CPT = {agent_cpt_js};'''
356
+ agents_js_charts = '''
357
+ new Chart(document.getElementById('agentCost'), {
358
+ type: 'bar',
359
+ data: { labels: AGENT_LABELS,
360
+ datasets: [{ label: 'Cost ($)', data: AGENT_COSTS,
361
+ backgroundColor: '#f97316', borderRadius: 4 }] },
362
+ options: { ...baseOpts, plugins: { ...baseOpts.plugins,
363
+ tooltip: { callbacks: { label: ctx => ' $' + ctx.parsed.y.toFixed(4) } } } }
364
+ });
365
+
366
+ new Chart(document.getElementById('agentCount'), {
367
+ type: 'bar',
368
+ data: { labels: AGENT_LABELS,
369
+ datasets: [{ label: 'Invocations', data: AGENT_COUNTS,
370
+ backgroundColor: '#fb923c', borderRadius: 4 }] },
371
+ options: baseOpts
372
+ });
373
+
374
+ new Chart(document.getElementById('agentCPT'), {
375
+ type: 'bar',
376
+ data: { labels: AGENT_LABELS,
377
+ datasets: [{ label: 'Cost/turn', data: AGENT_CPT,
378
+ backgroundColor: '#fbbf24', borderRadius: 4 }] },
379
+ options: { ...baseOpts, plugins: { ...baseOpts.plugins,
380
+ tooltip: { callbacks: { label: ctx => ' $' + ctx.parsed.y.toFixed(4) + '/turn' } } } }
381
+ });'''
382
+ else:
383
+ agents_stat_html = ''
384
+ agents_section_html = ''
385
+ agents_js_constants = ''
386
+ agents_js_charts = ''
387
+
388
+ # --- Skill data aggregation ---
389
+ by_skill_name = defaultdict(lambda: {"count": 0, "success": 0, "fail": 0, "duration": 0})
390
+ skill_by_date = defaultdict(int)
391
+ for sk in skill_data:
392
+ name = sk.get('skill_name', 'unknown')
393
+ by_skill_name[name]["count"] += 1
394
+ if sk.get('success', 1):
395
+ by_skill_name[name]["success"] += 1
396
+ else:
397
+ by_skill_name[name]["fail"] += 1
398
+ by_skill_name[name]["duration"] += sk.get('duration_seconds', 0)
399
+ skill_by_date[sk.get('date', 'unknown')] += 1
400
+
401
+ skill_names_sorted = sorted(by_skill_name.keys(), key=lambda n: -by_skill_name[n]["count"])
402
+ total_skill_invocations = sum(by_skill_name[n]["count"] for n in by_skill_name)
403
+ total_skill_success = sum(by_skill_name[n]["success"] for n in by_skill_name)
404
+ skill_success_rate = round(total_skill_success / total_skill_invocations * 100, 1) if total_skill_invocations > 0 else 0
405
+
406
+ skill_labels_js = json.dumps(skill_names_sorted)
407
+ skill_counts_js = json.dumps([by_skill_name[n]["count"] for n in skill_names_sorted])
408
+ skill_success_js = json.dumps([by_skill_name[n]["success"] for n in skill_names_sorted])
409
+ skill_fail_js = json.dumps([by_skill_name[n]["fail"] for n in skill_names_sorted])
410
+
411
+ skill_timeline_dates = sorted(skill_by_date.keys())
412
+ skill_timeline_dates_js = json.dumps(skill_timeline_dates)
413
+ skill_timeline_values_js = json.dumps([skill_by_date[d] for d in skill_timeline_dates])
414
+
415
+ if skill_data:
416
+ skills_stat_html = f''' <div class="stat">
417
+ <div class="stat-label">Skill invocations</div>
418
+ <div class="stat-value">{total_skill_invocations}</div>
419
+ <div class="stat-sub">{skill_success_rate}% success rate</div>
420
+ </div>'''
421
+ skills_section_html = f'''<div class="section">
422
+ <div class="section-header skills">Skills</div>
423
+ <div class="grid">
424
+ <div class="card">
425
+ <h2>Invocations by skill</h2>
426
+ <canvas id="skillCount"></canvas>
427
+ </div>
428
+ <div class="card">
429
+ <h2>Success / fail by skill</h2>
430
+ <canvas id="skillSuccess"></canvas>
431
+ </div>
432
+ <div class="card wide">
433
+ <h2>Skill invocations over time</h2>
434
+ <canvas id="skillTimeline"></canvas>
435
+ </div>
436
+ </div>
437
+ </div>
438
+
439
+ '''
440
+ skills_js_constants = f'''const SKILL_LABELS = {skill_labels_js};
441
+ const SKILL_COUNTS = {skill_counts_js};
442
+ const SKILL_SUCCESS = {skill_success_js};
443
+ const SKILL_FAIL = {skill_fail_js};
444
+ const SKILL_TIMELINE_DATES = {skill_timeline_dates_js};
445
+ const SKILL_TIMELINE_VALUES = {skill_timeline_values_js};'''
446
+ skills_js_charts = '''
447
+ new Chart(document.getElementById('skillCount'), {
448
+ type: 'bar',
449
+ data: { labels: SKILL_LABELS,
450
+ datasets: [{ label: 'Invocations', data: SKILL_COUNTS,
451
+ backgroundColor: '#f59e0b', borderRadius: 4 }] },
452
+ options: { ...baseOpts, indexAxis: 'y' }
453
+ });
454
+
455
+ new Chart(document.getElementById('skillSuccess'), {
456
+ type: 'bar',
457
+ data: { labels: SKILL_LABELS,
458
+ datasets: [
459
+ { label: 'Success', data: SKILL_SUCCESS, backgroundColor: '#f59e0b', borderRadius: 2 },
460
+ { label: 'Fail', data: SKILL_FAIL, backgroundColor: '#92400e', borderRadius: 2 }
461
+ ] },
462
+ options: { ...baseOpts, scales: { ...baseOpts.scales,
463
+ x: { ...baseOpts.scales.x, stacked: true },
464
+ y: { ...baseOpts.scales.y, stacked: true } } }
465
+ });
466
+
467
+ new Chart(document.getElementById('skillTimeline'), {
468
+ type: 'line',
469
+ data: {
470
+ labels: SKILL_TIMELINE_DATES,
471
+ datasets: [{ label: 'Invocations', data: SKILL_TIMELINE_VALUES,
472
+ borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.15)',
473
+ fill: true, tension: 0.3, pointRadius: 3 }]
474
+ },
475
+ options: baseOpts
476
+ });'''
477
+ else:
478
+ skills_stat_html = ''
479
+ skills_section_html = ''
480
+ skills_js_constants = ''
481
+ skills_js_charts = ''
482
+
483
+ # --- Friction data aggregation ---
484
+ FRICTION_CAT_COLORS = {
485
+ "permission_denied": "#dc2626",
486
+ "hook_blocked": "#b91c1c",
487
+ "cascade_error": "#f97316",
488
+ "command_failed": "#eab308",
489
+ "tool_error": "#ef4444",
490
+ "correction": "#8b5cf6",
491
+ "retry": "#06b6d4",
492
+ }
493
+ FRICTION_DEFAULT_COLOR = "#94a3b8"
494
+
495
+ friction_by_date = defaultdict(lambda: {"main": 0, "subagent": 0})
496
+ friction_by_category = defaultdict(int)
497
+ friction_by_tool = defaultdict(int)
498
+ for fe in friction_data:
499
+ d = fe.get('date', 'unknown')
500
+ src = fe.get('source', 'main')
501
+ if src == 'subagent':
502
+ friction_by_date[d]["subagent"] += 1
503
+ else:
504
+ friction_by_date[d]["main"] += 1
505
+ friction_by_category[fe.get('category', 'unknown')] += 1
506
+ tn = fe.get('tool_name')
507
+ if tn:
508
+ friction_by_tool[tn] += 1
509
+
510
+ friction_by_skill = defaultdict(int)
511
+ for fe in friction_data:
512
+ sk = fe.get('skill')
513
+ if sk:
514
+ friction_by_skill[sk] += 1
515
+ has_skill_data = bool(friction_by_skill)
516
+
517
+ retry_events = [fe for fe in friction_data if fe.get('category') == 'retry']
518
+ retry_total = len(retry_events)
519
+ retry_resolved = sum(1 for fe in retry_events if fe.get('resolved'))
520
+ retry_rate = round(retry_resolved / retry_total * 100, 1) if retry_total > 0 else 0
521
+
522
+ total_friction = len(friction_data)
523
+ friction_rate = round(total_friction / total_turns * 100, 1) if total_turns > 0 else 0
524
+
525
+ friction_dates_sorted = sorted(friction_by_date.keys())
526
+ friction_dates_js = json.dumps(friction_dates_sorted)
527
+ friction_main_by_date_js = json.dumps([friction_by_date[d]["main"] for d in friction_dates_sorted])
528
+ friction_sub_by_date_js = json.dumps([friction_by_date[d]["subagent"] for d in friction_dates_sorted])
529
+
530
+ friction_cats_sorted = sorted(friction_by_category.keys(), key=lambda c: -friction_by_category[c])
531
+ friction_cat_labels_js = json.dumps(friction_cats_sorted)
532
+ friction_cat_values_js = json.dumps([friction_by_category[c] for c in friction_cats_sorted])
533
+ friction_cat_colors_js = json.dumps([FRICTION_CAT_COLORS.get(c, FRICTION_DEFAULT_COLOR) for c in friction_cats_sorted])
534
+
535
+ friction_tools_sorted = sorted(friction_by_tool.keys(), key=lambda t: -friction_by_tool[t])
536
+ friction_tool_labels_js = json.dumps(friction_tools_sorted)
537
+ friction_tool_values_js = json.dumps([friction_by_tool[t] for t in friction_tools_sorted])
538
+
539
+ friction_skills_sorted = sorted(friction_by_skill.keys(), key=lambda s: -friction_by_skill[s])
540
+ friction_skill_labels_js = json.dumps(friction_skills_sorted)
541
+ friction_skill_values_js = json.dumps([friction_by_skill[s] for s in friction_skills_sorted])
542
+
543
+ # Friction rate trend per day (events per 100 prompts)
544
+ friction_rate_dates = sorted(set(friction_dates_sorted) & set(dates))
545
+ friction_rate_values = []
546
+ for d in friction_rate_dates:
547
+ day_friction = friction_by_date[d]["main"] + friction_by_date[d]["subagent"]
548
+ day_turns = by_date[d]["turns"] if d in by_date else 0
549
+ friction_rate_values.append(round(day_friction / day_turns * 100, 1) if day_turns > 0 else 0)
550
+ friction_rate_dates_js = json.dumps(friction_rate_dates)
551
+ friction_rate_values_js = json.dumps(friction_rate_values)
552
+
553
+ if friction_data:
554
+ friction_stat_html = f''' <div class="stat">
555
+ <div class="stat-label">Friction events</div>
556
+ <div class="stat-value">{total_friction}</div>
557
+ <div class="stat-sub">{friction_rate} per 100 prompts</div>
558
+ </div>'''
559
+ retry_stat_html = f''' <div class="stat">
560
+ <div class="stat-label">Retry resolution</div>
561
+ <div class="stat-value">{retry_rate}%</div>
562
+ <div class="stat-sub">{retry_resolved} of {retry_total} retries succeeded</div>
563
+ </div>''' if retry_total > 0 else ''
564
+ friction_skill_card = '''
565
+ <div class="card">
566
+ <h2>Friction by skill</h2>
567
+ <canvas id="frictionSkill"></canvas>
568
+ </div>''' if has_skill_data else ''
569
+ friction_section_html = f'''<div class="section">
570
+ <div class="section-header friction">Friction</div>
571
+ <div class="grid">
572
+ <div class="card">
573
+ <h2>Friction per day</h2>
574
+ <canvas id="frictionDay"></canvas>
575
+ </div>
576
+ <div class="card">
577
+ <h2>Friction by category</h2>
578
+ <canvas id="frictionCat"></canvas>
579
+ </div>
580
+ <div class="card">
581
+ <h2>Friction by tool</h2>
582
+ <canvas id="frictionTool"></canvas>
583
+ </div>
584
+ <div class="card">
585
+ <h2>Friction rate trend</h2>
586
+ <canvas id="frictionRate"></canvas>
587
+ </div>{friction_skill_card}
588
+ </div>
589
+ </div>
590
+
591
+ '''
592
+ friction_js_constants = f'''const FRICTION_DATES = {friction_dates_js};
593
+ const FRICTION_MAIN = {friction_main_by_date_js};
594
+ const FRICTION_SUB = {friction_sub_by_date_js};
595
+ const FRICTION_CAT_LABELS = {friction_cat_labels_js};
596
+ const FRICTION_CAT_VALUES = {friction_cat_values_js};
597
+ const FRICTION_CAT_COLORS = {friction_cat_colors_js};
598
+ const FRICTION_TOOL_LABELS = {friction_tool_labels_js};
599
+ const FRICTION_TOOL_VALUES = {friction_tool_values_js};
600
+ const FRICTION_RATE_DATES = {friction_rate_dates_js};
601
+ const FRICTION_RATE_VALUES = {friction_rate_values_js};''' + (f'''
602
+ const FRICTION_SKILL_LABELS = {friction_skill_labels_js};
603
+ const FRICTION_SKILL_VALUES = {friction_skill_values_js};''' if has_skill_data else '')
604
+ friction_js_charts = '''
605
+ // Friction per day (stacked bar)
606
+ new Chart(document.getElementById('frictionDay'), {
607
+ type: 'bar',
608
+ data: {
609
+ labels: FRICTION_DATES,
610
+ datasets: [
611
+ { label: 'Main session', data: FRICTION_MAIN, backgroundColor: '#ef4444', borderRadius: 2 },
612
+ { label: 'Subagent', data: FRICTION_SUB, backgroundColor: '#f97316', borderRadius: 2 }
613
+ ]
614
+ },
615
+ options: { ...baseOpts, scales: { ...baseOpts.scales,
616
+ x: { ...baseOpts.scales.x, stacked: true },
617
+ y: { ...baseOpts.scales.y, stacked: true } } }
618
+ });
619
+
620
+ // Friction by category (horizontal bar)
621
+ new Chart(document.getElementById('frictionCat'), {
622
+ type: 'bar',
623
+ data: {
624
+ labels: FRICTION_CAT_LABELS,
625
+ datasets: [{ label: 'Events', data: FRICTION_CAT_VALUES,
626
+ backgroundColor: FRICTION_CAT_COLORS, borderRadius: 4 }]
627
+ },
628
+ options: { ...baseOpts, indexAxis: 'y' }
629
+ });
630
+
631
+ // Friction by tool (bar)
632
+ new Chart(document.getElementById('frictionTool'), {
633
+ type: 'bar',
634
+ data: {
635
+ labels: FRICTION_TOOL_LABELS,
636
+ datasets: [{ label: 'Events', data: FRICTION_TOOL_VALUES,
637
+ backgroundColor: '#ef4444', borderRadius: 4 }]
638
+ },
639
+ options: baseOpts
640
+ });
641
+
642
+ // Friction rate trend (line)
643
+ new Chart(document.getElementById('frictionRate'), {
644
+ type: 'line',
645
+ data: {
646
+ labels: FRICTION_RATE_DATES,
647
+ datasets: [{ label: 'Per 100 prompts', data: FRICTION_RATE_VALUES,
648
+ borderColor: '#ef4444', backgroundColor: 'rgba(239,68,68,0.15)',
649
+ fill: true, tension: 0.3, pointRadius: 3 }]
650
+ },
651
+ options: { ...baseOpts, plugins: { ...baseOpts.plugins,
652
+ tooltip: { callbacks: { label: ctx => ' ' + ctx.parsed.y + ' per 100 prompts' } } } }
653
+ });''' + ('''
654
+
655
+ // Friction by skill (horizontal bar)
656
+ new Chart(document.getElementById('frictionSkill'), {
657
+ type: 'bar',
658
+ data: {
659
+ labels: FRICTION_SKILL_LABELS,
660
+ datasets: [{ label: 'Events', data: FRICTION_SKILL_VALUES,
661
+ backgroundColor: '#c084fc', borderRadius: 4 }]
662
+ },
663
+ options: { ...baseOpts, indexAxis: 'y' }
664
+ });''' if has_skill_data else '')
665
+ else:
666
+ friction_stat_html = ''
667
+ retry_stat_html = ''
668
+ friction_section_html = ''
669
+ friction_js_constants = ''
670
+ friction_js_charts = ''
671
+
672
+ # --- Error tracking ---
673
+ ERROR_CATEGORIES = {'tool_error', 'command_failed', 'cascade_error'}
674
+
675
+ def classify_error(event):
676
+ detail = (event.get('detail') or '').strip()
677
+ if 'InputValidationError' in detail:
678
+ return 'validation_error'
679
+ if 'No such file' in detail or 'FileNotFoundError' in detail:
680
+ return 'file_not_found'
681
+ if 'timed out' in detail.lower() or 'timeout' in detail.lower():
682
+ return 'timeout'
683
+ if 'UnicodeDecodeError' in detail or 'encoding' in detail.lower():
684
+ return 'encoding_error'
685
+ if event.get('category') == 'command_failed':
686
+ import re as _re
687
+ m = _re.search(r'Exit code (\d+)', detail)
688
+ if m:
689
+ return f'exit_code_{m.group(1)}'
690
+ return 'generic'
691
+
692
+ error_events = [fe for fe in friction_data if fe.get('category') in ERROR_CATEGORIES]
693
+
694
+ error_by_type = defaultdict(int)
695
+ error_by_tool = defaultdict(int)
696
+ error_by_date = defaultdict(int)
697
+ for ee in error_events:
698
+ error_by_type[classify_error(ee)] += 1
699
+ tn = ee.get('tool_name')
700
+ if tn:
701
+ error_by_tool[tn] += 1
702
+ error_by_date[ee.get('date', 'unknown')] += 1
703
+
704
+ total_errors = len(error_events)
705
+ error_rate = round(total_errors / total_turns * 100, 1) if total_turns > 0 else 0
706
+
707
+ if error_events:
708
+ error_types_sorted = sorted(error_by_type.keys(), key=lambda t: -error_by_type[t])
709
+ error_tools_sorted = sorted(error_by_tool.keys(), key=lambda t: -error_by_tool[t])
710
+ error_dates_sorted = sorted(error_by_date.keys())
711
+
712
+ error_type_labels_js = json.dumps(error_types_sorted)
713
+ error_type_values_js = json.dumps([error_by_type[t] for t in error_types_sorted])
714
+ error_tool_labels_js = json.dumps(error_tools_sorted)
715
+ error_tool_values_js = json.dumps([error_by_tool[t] for t in error_tools_sorted])
716
+ error_dates_js = json.dumps(error_dates_sorted)
717
+ error_date_values_js = json.dumps([error_by_date[d] for d in error_dates_sorted])
718
+
719
+ error_stat_html = f''' <div class="stat">
720
+ <div class="stat-label">Errors</div>
721
+ <div class="stat-value">{total_errors}</div>
722
+ <div class="stat-sub">{error_rate} per 100 prompts</div>
723
+ </div>'''
724
+ error_section_html = f'''<div class="section">
725
+ <div class="section-header errors">Errors</div>
726
+ <div class="grid">
727
+ <div class="card">
728
+ <h2>Error types</h2>
729
+ <canvas id="errorTypes"></canvas>
730
+ </div>
731
+ <div class="card">
732
+ <h2>Errors by tool</h2>
733
+ <canvas id="errorTools"></canvas>
734
+ </div>
735
+ <div class="card wide">
736
+ <h2>Error trend per day</h2>
737
+ <canvas id="errorTrend"></canvas>
738
+ </div>
739
+ </div>
740
+ </div>
741
+
742
+ '''
743
+ error_js_constants = f'''const ERROR_TYPE_LABELS = {error_type_labels_js};
744
+ const ERROR_TYPE_VALUES = {error_type_values_js};
745
+ const ERROR_TOOL_LABELS = {error_tool_labels_js};
746
+ const ERROR_TOOL_VALUES = {error_tool_values_js};
747
+ const ERROR_DATES = {error_dates_js};
748
+ const ERROR_DATE_VALUES = {error_date_values_js};'''
749
+ error_js_charts = '''
750
+ // Error types (horizontal bar)
751
+ new Chart(document.getElementById('errorTypes'), {
752
+ type: 'bar',
753
+ data: {
754
+ labels: ERROR_TYPE_LABELS,
755
+ datasets: [{ label: 'Errors', data: ERROR_TYPE_VALUES,
756
+ backgroundColor: '#fb7185', borderRadius: 4 }]
757
+ },
758
+ options: { ...baseOpts, indexAxis: 'y' }
759
+ });
760
+
761
+ // Errors by tool (bar)
762
+ new Chart(document.getElementById('errorTools'), {
763
+ type: 'bar',
764
+ data: {
765
+ labels: ERROR_TOOL_LABELS,
766
+ datasets: [{ label: 'Errors', data: ERROR_TOOL_VALUES,
767
+ backgroundColor: '#e11d48', borderRadius: 4 }]
768
+ },
769
+ options: baseOpts
770
+ });
771
+
772
+ // Error trend per day (line)
773
+ new Chart(document.getElementById('errorTrend'), {
774
+ type: 'line',
775
+ data: {
776
+ labels: ERROR_DATES,
777
+ datasets: [{ label: 'Errors', data: ERROR_DATE_VALUES,
778
+ borderColor: '#e11d48', backgroundColor: 'rgba(225,29,72,0.15)',
779
+ fill: true, tension: 0.3, pointRadius: 3 }]
780
+ },
781
+ options: baseOpts
782
+ });'''
783
+ else:
784
+ error_stat_html = ''
785
+ error_section_html = ''
786
+ error_js_constants = ''
787
+ error_js_charts = ''
788
+
789
+ # --- Compaction data aggregation ---
790
+ compaction_by_date = defaultdict(lambda: {"auto": 0, "manual": 0})
791
+ compaction_tokens = []
792
+ compaction_per_session = defaultdict(int)
793
+ for ce in compaction_data:
794
+ d = ce.get('date', 'unknown')
795
+ trig = ce.get('trigger', 'auto')
796
+ if trig == 'manual':
797
+ compaction_by_date[d]["manual"] += 1
798
+ else:
799
+ compaction_by_date[d]["auto"] += 1
800
+ pre_t = ce.get('pre_tokens', 0)
801
+ if pre_t > 0:
802
+ compaction_tokens.append({"x": ce.get('timestamp', '')[:10] if ce.get('timestamp') else 'unknown', "y": pre_t})
803
+ compaction_per_session[ce.get('session_id', '')] += 1
804
+
805
+ total_compactions = len(compaction_data)
806
+ total_auto = sum(1 for c in compaction_data if c.get('trigger', 'auto') == 'auto')
807
+ total_manual = total_compactions - total_auto
808
+ avg_pre_tokens = round(sum(c.get('pre_tokens', 0) for c in compaction_data) / total_compactions) if total_compactions > 0 else 0
809
+ sessions_with_compaction = len([s for s in compaction_per_session if compaction_per_session[s] > 0])
810
+ pct_sessions_compacted = round(sessions_with_compaction / total_sessions * 100, 1) if total_sessions > 0 else 0
811
+ avg_per_session = round(total_compactions / total_sessions, 1) if total_sessions > 0 else 0
812
+
813
+ compact_dates_sorted = sorted(compaction_by_date.keys())
814
+ compact_dates_js = json.dumps(compact_dates_sorted)
815
+ compact_auto_js = json.dumps([compaction_by_date[d]["auto"] for d in compact_dates_sorted])
816
+ compact_manual_js = json.dumps([compaction_by_date[d]["manual"] for d in compact_dates_sorted])
817
+ compact_scatter_js = json.dumps(compaction_tokens)
818
+
819
+ compact_dist = {"0": 0, "1": 0, "2": 0, "3+": 0}
820
+ all_session_ids = {e.get("session_id") for e in data}
821
+ for sid in all_session_ids:
822
+ cnt = compaction_per_session.get(sid, 0)
823
+ if cnt == 0:
824
+ compact_dist["0"] += 1
825
+ elif cnt == 1:
826
+ compact_dist["1"] += 1
827
+ elif cnt == 2:
828
+ compact_dist["2"] += 1
829
+ else:
830
+ compact_dist["3+"] += 1
831
+ compact_dist_labels_js = json.dumps(list(compact_dist.keys()))
832
+ compact_dist_values_js = json.dumps(list(compact_dist.values()))
833
+
834
+ if compaction_data:
835
+ compaction_stat_html = f''' <div class="stat">
836
+ <div class="stat-label">Compactions</div>
837
+ <div class="stat-value">{total_compactions}</div>
838
+ <div class="stat-sub">{avg_per_session}/session, {pct_sessions_compacted}% of sessions</div>
839
+ </div>'''
840
+ compaction_section_html = f'''<div class="section">
841
+ <div class="section-header compaction">Context Compactions</div>
842
+ <div class="grid">
843
+ <div class="card">
844
+ <h2>Compactions per day</h2>
845
+ <canvas id="compactDay"></canvas>
846
+ </div>
847
+ <div class="card">
848
+ <h2>Pre-compaction token count</h2>
849
+ <canvas id="compactTokens"></canvas>
850
+ </div>
851
+ <div class="card">
852
+ <h2>Compactions per session</h2>
853
+ <canvas id="compactDist"></canvas>
854
+ </div>
855
+ </div>
856
+ </div>
857
+
858
+ '''
859
+ compaction_js_constants = f'''const COMPACT_DATES = {compact_dates_js};
860
+ const COMPACT_AUTO = {compact_auto_js};
861
+ const COMPACT_MANUAL = {compact_manual_js};
862
+ const COMPACT_SCATTER = {compact_scatter_js};
863
+ const COMPACT_DIST_LABELS = {compact_dist_labels_js};
864
+ const COMPACT_DIST_VALUES = {compact_dist_values_js};'''
865
+ compaction_js_charts = '''
866
+ // Compactions per day (stacked bar)
867
+ new Chart(document.getElementById('compactDay'), {
868
+ type: 'bar',
869
+ data: {
870
+ labels: COMPACT_DATES,
871
+ datasets: [
872
+ { label: 'Auto', data: COMPACT_AUTO, backgroundColor: '#8b5cf6', borderRadius: 2 },
873
+ { label: 'Manual', data: COMPACT_MANUAL, backgroundColor: '#c084fc', borderRadius: 2 }
874
+ ]
875
+ },
876
+ options: { ...baseOpts, scales: { ...baseOpts.scales,
877
+ x: { ...baseOpts.scales.x, stacked: true },
878
+ y: { ...baseOpts.scales.y, stacked: true } } }
879
+ });
880
+
881
+ // Pre-compaction token count (scatter)
882
+ new Chart(document.getElementById('compactTokens'), {
883
+ type: 'scatter',
884
+ data: {
885
+ datasets: [{ label: 'Pre-tokens', data: COMPACT_SCATTER,
886
+ backgroundColor: '#a78bfa', pointRadius: 5 }]
887
+ },
888
+ options: { ...baseOpts,
889
+ scales: {
890
+ x: { type: 'category', labels: [...new Set(COMPACT_SCATTER.map(p => p.x))],
891
+ ticks: { color: TEXT, font: { size: 10 } }, grid: { color: GRID } },
892
+ y: { ticks: { color: TEXT, font: { size: 10 },
893
+ callback: v => (v/1000).toFixed(0) + 'K' }, grid: { color: GRID } }
894
+ },
895
+ plugins: { ...baseOpts.plugins,
896
+ tooltip: { callbacks: { label: ctx => ' ' + ctx.parsed.y.toLocaleString() + ' tokens' } } }
897
+ }
898
+ });
899
+
900
+ // Compactions per session distribution
901
+ new Chart(document.getElementById('compactDist'), {
902
+ type: 'bar',
903
+ data: {
904
+ labels: COMPACT_DIST_LABELS,
905
+ datasets: [{ label: 'Sessions', data: COMPACT_DIST_VALUES,
906
+ backgroundColor: '#8b5cf6', borderRadius: 4 }]
907
+ },
908
+ options: baseOpts
909
+ });'''
910
+ else:
911
+ compaction_stat_html = ''
912
+ compaction_section_html = ''
913
+ compaction_js_constants = ''
914
+ compaction_js_charts = ''
915
+
288
916
  html = f"""<!DOCTYPE html>
289
917
  <html lang="en">
290
918
  <head>
@@ -314,6 +942,11 @@ html = f"""<!DOCTYPE html>
314
942
  .section-header.cost {{ border-left: 3px solid #6366f1; color: #818cf8; }}
315
943
  .section-header.time {{ border-left: 3px solid #34d399; color: #34d399; }}
316
944
  .section-header.prompts {{ border-left: 3px solid #a78bfa; color: #a78bfa; }}
945
+ .section-header.agents {{ border-left: 3px solid #f97316; color: #fb923c; }}
946
+ .section-header.friction {{ border-left: 3px solid #ef4444; color: #f87171; }}
947
+ .section-header.errors {{ border-left: 3px solid #e11d48; color: #fb7185; }}
948
+ .section-header.skills {{ border-left: 3px solid #f59e0b; color: #fbbf24; }}
949
+ .section-header.compaction {{ border-left: 3px solid #8b5cf6; color: #a78bfa; }}
317
950
  .grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }}
318
951
  .card {{ background: #1e2330; border: 1px solid #2d3748; border-radius: 10px;
319
952
  padding: 16px; }}
@@ -350,6 +983,11 @@ html = f"""<!DOCTYPE html>
350
983
  <div class="stat-value">{total_output:,}</div>
351
984
  <div class="stat-sub">&nbsp;</div>
352
985
  </div>
986
+ <div class="stat">
987
+ <div class="stat-label">Input tokens</div>
988
+ <div class="stat-value">{total_input:,}</div>
989
+ <div class="stat-sub">&nbsp;</div>
990
+ </div>
353
991
  <div class="stat">
354
992
  <div class="stat-label">Cache read share</div>
355
993
  <div class="stat-value">{cache_pct}%</div>
@@ -370,6 +1008,12 @@ html = f"""<!DOCTYPE html>
370
1008
  <div class="stat-value">{overall_efficiency}%</div>
371
1009
  <div class="stat-sub">key / non-trivial (higher = better)</div>
372
1010
  </div>
1011
+ {agents_stat_html}
1012
+ {skills_stat_html}
1013
+ {friction_stat_html}
1014
+ {retry_stat_html}
1015
+ {error_stat_html}
1016
+ {compaction_stat_html}
373
1017
  </div>
374
1018
 
375
1019
  <div class="section">
@@ -396,10 +1040,15 @@ html = f"""<!DOCTYPE html>
396
1040
  <canvas id="modelStack"></canvas>
397
1041
  </div>
398
1042
 
1043
+ <div class="card wide">
1044
+ <h2>Token composition per day</h2>
1045
+ <canvas id="tokenComp"></canvas>
1046
+ </div>
1047
+
399
1048
  </div>
400
1049
  </div>
401
1050
 
402
- <div class="section">
1051
+ {agents_section_html}{skills_section_html}{friction_section_html}{error_section_html}{compaction_section_html}<div class="section">
403
1052
  <div class="section-header prompts">Key Prompts</div>
404
1053
  <div class="grid">
405
1054
 
@@ -477,6 +1126,9 @@ const DATES = {dates_js};
477
1126
  const COST_BY_DATE = {cost_by_date_js};
478
1127
  const SESSIONS_BY_DATE = {sessions_by_date_js};
479
1128
  const OUTPUT_BY_DATE = {output_by_date_js};
1129
+ const INPUT_BY_DATE = {input_by_date_js};
1130
+ const CACHE_CREATE_BY_DATE = {cache_create_by_date_js};
1131
+ const CACHE_READ_BY_DATE = {cache_read_by_date_js};
480
1132
  const OPUS_BY_DATE = {opus_by_date_js};
481
1133
  const SONNET_BY_DATE = {sonnet_by_date_js};
482
1134
  const CUMUL_LABELS = {cumul_labels_js};
@@ -501,6 +1153,11 @@ const AVG_DURATION_BY_DATE = {avg_duration_by_date_js};
501
1153
  const SCATTER_DATA = {scatter_data_js};
502
1154
  const TPM_DATA = {tpm_data_js};
503
1155
  const DUR_HIST_RANGES = {dur_hist_ranges_js};
1156
+ {agents_js_constants}
1157
+ {skills_js_constants}
1158
+ {friction_js_constants}
1159
+ {error_js_constants}
1160
+ {compaction_js_constants}
504
1161
 
505
1162
  function formatDuration(s) {{
506
1163
  if (s <= 0) return '0s';
@@ -576,6 +1233,23 @@ new Chart(document.getElementById('modelStack'), {{
576
1233
  tooltip: {{ callbacks: {{ label: ctx => ' $' + ctx.parsed.y.toFixed(2) }} }} }} }}
577
1234
  }});
578
1235
 
1236
+ // Token composition per day (stacked bar)
1237
+ new Chart(document.getElementById('tokenComp'), {{
1238
+ type: 'bar',
1239
+ data: {{
1240
+ labels: DATES,
1241
+ datasets: [
1242
+ {{ label: 'Input', data: INPUT_BY_DATE, backgroundColor: '#6366f1', borderRadius: 2 }},
1243
+ {{ label: 'Cache creation', data: CACHE_CREATE_BY_DATE, backgroundColor: '#f59e0b', borderRadius: 2 }},
1244
+ {{ label: 'Cache read', data: CACHE_READ_BY_DATE, backgroundColor: '#22d3ee', borderRadius: 2 }},
1245
+ {{ label: 'Output', data: OUTPUT_BY_DATE, backgroundColor: '#a78bfa', borderRadius: 2 }}
1246
+ ]
1247
+ }},
1248
+ options: {{ ...baseOpts, scales: {{ ...baseOpts.scales,
1249
+ x: {{ ...baseOpts.scales.x, stacked: true }},
1250
+ y: {{ ...baseOpts.scales.y, stacked: true }} }} }}
1251
+ }});
1252
+
579
1253
  // Session duration per day
580
1254
  new Chart(document.getElementById('durationDay'), {{
581
1255
  type: 'bar',
@@ -757,10 +1431,15 @@ new Chart(document.getElementById('promptStack'), {{
757
1431
  }}
758
1432
  }}
759
1433
  }});
1434
+ {agents_js_charts}
1435
+ {skills_js_charts}
1436
+ {friction_js_charts}
1437
+ {error_js_charts}
1438
+ {compaction_js_charts}
760
1439
  </script>
761
1440
  </body>
762
1441
  </html>
763
1442
  """
764
1443
 
765
- with open(output_file, "w") as f:
1444
+ with open(output_file, "w", encoding='utf-8') as f:
766
1445
  f.write(html)