claude-code-tracker 1.2.3 → 1.4.0-beta.3

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,40 @@
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
+ # Load friction data (optional — file may not exist on older installs)
29
+ friction_file = os.path.join(tracking_dir, 'friction.json')
30
+ friction_data = []
31
+ if os.path.exists(friction_file):
32
+ try:
33
+ with open(friction_file, encoding='utf-8') as f:
34
+ friction_data = json.load(f)
35
+ except:
36
+ pass
37
+
14
38
  def format_duration(seconds):
15
39
  if seconds <= 0:
16
40
  return "0m"
@@ -21,9 +45,6 @@ def format_duration(seconds):
21
45
  return f"{h}h {m}m"
22
46
  return f"{m}m {s}s"
23
47
 
24
- with open(tokens_file) as f:
25
- data = json.load(f)
26
-
27
48
  if not data:
28
49
  sys.exit(0)
29
50
 
@@ -72,6 +93,7 @@ total_turns = len(data)
72
93
  total_sessions = len({e.get("session_id") for e in data})
73
94
  sessions_with_data = len({e.get("session_id") for e in data if e.get("total_tokens", 0) > 0})
74
95
  total_output = sum(e.get("output_tokens", 0) for e in data)
96
+ total_input = sum(e.get("input_tokens", 0) for e in data)
75
97
  total_cache_read = sum(e.get("cache_read_tokens", 0) for e in data)
76
98
  total_all_tokens = sum(e.get("total_tokens", 0) for e in data)
77
99
  cache_pct = round(total_cache_read / total_all_tokens * 100, 1) if total_all_tokens > 0 else 0
@@ -81,7 +103,7 @@ avg_duration = total_duration // total_turns if total_turns > 0 else 0
81
103
  project_name = data[0].get("project", "Project") if data else "Project"
82
104
 
83
105
  # --- 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
106
+ project_dir = os.path.dirname(os.path.dirname(tracking_dir)) # project root
85
107
  # Claude Code slugifies paths as: replace every "/" with "-" (keeping leading slash → leading dash)
86
108
  transcripts_dir = os.path.expanduser(
87
109
  "~/.claude/projects/" + project_dir.replace("/", "-")
@@ -107,7 +129,7 @@ if os.path.isdir(transcripts_dir):
107
129
  os.path.getmtime(jf)).strftime("%Y-%m-%d")
108
130
 
109
131
  try:
110
- with open(jf) as f:
132
+ with open(jf, encoding='utf-8') as f:
111
133
  for line in f:
112
134
  try:
113
135
  obj = json.loads(line)
@@ -147,7 +169,7 @@ total_human_msgs = sum(human_by_date.values())
147
169
  total_trivial_msgs = sum(trivial_by_date.values())
148
170
 
149
171
  # --- Aggregate prompt data from key-prompts/ folder ---
150
- prompts_dir = os.path.join(os.path.dirname(tokens_file), "key-prompts")
172
+ prompts_dir = os.path.join(tracking_dir, "key-prompts")
151
173
  prompt_files = sorted(glob.glob(os.path.join(prompts_dir, "????-??-??.md")))
152
174
 
153
175
  prompt_by_date = {} # date -> {total, by_category}
@@ -155,7 +177,7 @@ all_categories = set()
155
177
 
156
178
  for f in prompt_files:
157
179
  date = os.path.splitext(os.path.basename(f))[0]
158
- content = open(f).read()
180
+ content = open(f, encoding='utf-8').read()
159
181
  cats = re.findall(r'^\*\*Category\*\*: (\S+)', content, re.MULTILINE)
160
182
  by_cat = defaultdict(int)
161
183
  for c in cats:
@@ -176,6 +198,8 @@ cache_read_by_date_js = json.dumps([by_date[d]["cache_read"] for d in dates])
176
198
  opus_by_date_js = json.dumps([round(by_date[d]["opus_cost"], 4) for d in dates])
177
199
  sonnet_by_date_js = json.dumps([round(by_date[d]["sonnet_cost"], 4) for d in dates])
178
200
  duration_by_date_js = json.dumps([by_date[d]["duration"] for d in dates])
201
+ input_by_date_js = json.dumps([by_date[d]["input"] for d in dates])
202
+ cache_create_by_date_js = json.dumps([by_date[d]["cache_create"] for d in dates])
179
203
 
180
204
  cumul_labels_js = json.dumps([f"{c['date']} {c['session_id']}#{c['turn_index']}" for c in cumulative])
181
205
  cumul_values_js = json.dumps([c["cumulative_cost"] for c in cumulative])
@@ -285,6 +309,490 @@ donut_labels_js = json.dumps(list(cat_totals.keys()))
285
309
  donut_values_js = json.dumps(list(cat_totals.values()))
286
310
  donut_colors_js = json.dumps([CAT_COLORS.get(c, DEFAULT_COLOR) for c in cat_totals])
287
311
 
312
+ # --- Agent data aggregation ---
313
+ by_agent_type = defaultdict(lambda: {"cost": 0, "count": 0, "turns": 0})
314
+ for a in agent_data:
315
+ t = a.get('agent_type', 'unknown')
316
+ by_agent_type[t]["cost"] += a.get('estimated_cost_usd', 0)
317
+ by_agent_type[t]["count"] += 1
318
+ by_agent_type[t]["turns"] += a.get('turns', 0)
319
+
320
+ agent_types_sorted = sorted(by_agent_type.keys(), key=lambda t: -by_agent_type[t]["cost"])
321
+ total_agent_cost = sum(a.get('estimated_cost_usd', 0) for a in agent_data)
322
+ total_agent_invocations = len(agent_data)
323
+
324
+ agent_labels_js = json.dumps(agent_types_sorted)
325
+ agent_costs_js = json.dumps([round(by_agent_type[t]["cost"], 4) for t in agent_types_sorted])
326
+ agent_counts_js = json.dumps([by_agent_type[t]["count"] for t in agent_types_sorted])
327
+ agent_cpt_js = json.dumps([
328
+ round(by_agent_type[t]["cost"] / by_agent_type[t]["turns"], 4)
329
+ if by_agent_type[t]["turns"] > 0 else 0
330
+ for t in agent_types_sorted
331
+ ])
332
+
333
+ # Conditional HTML blocks
334
+ if agent_data:
335
+ agents_stat_html = f''' <div class="stat">
336
+ <div class="stat-label">Agent cost</div>
337
+ <div class="stat-value">${total_agent_cost:.2f}</div>
338
+ <div class="stat-sub">{total_agent_invocations} invocations</div>
339
+ </div>'''
340
+ agents_section_html = f'''<div class="section">
341
+ <div class="section-header agents">Agents</div>
342
+ <div class="grid">
343
+ <div class="card">
344
+ <h2>Cost by agent type</h2>
345
+ <canvas id="agentCost"></canvas>
346
+ </div>
347
+ <div class="card">
348
+ <h2>Invocations by agent type</h2>
349
+ <canvas id="agentCount"></canvas>
350
+ </div>
351
+ <div class="card">
352
+ <h2>Cost per turn by agent type</h2>
353
+ <canvas id="agentCPT"></canvas>
354
+ </div>
355
+ </div>
356
+ </div>
357
+
358
+ '''
359
+ agents_js_constants = f'''const AGENT_LABELS = {agent_labels_js};
360
+ const AGENT_COSTS = {agent_costs_js};
361
+ const AGENT_COUNTS = {agent_counts_js};
362
+ const AGENT_CPT = {agent_cpt_js};'''
363
+ agents_js_charts = '''
364
+ new Chart(document.getElementById('agentCost'), {
365
+ type: 'bar',
366
+ data: { labels: AGENT_LABELS,
367
+ datasets: [{ label: 'Cost ($)', data: AGENT_COSTS,
368
+ backgroundColor: '#f97316', borderRadius: 4 }] },
369
+ options: { ...baseOpts, plugins: { ...baseOpts.plugins,
370
+ tooltip: { callbacks: { label: ctx => ' $' + ctx.parsed.y.toFixed(4) } } } }
371
+ });
372
+
373
+ new Chart(document.getElementById('agentCount'), {
374
+ type: 'bar',
375
+ data: { labels: AGENT_LABELS,
376
+ datasets: [{ label: 'Invocations', data: AGENT_COUNTS,
377
+ backgroundColor: '#fb923c', borderRadius: 4 }] },
378
+ options: baseOpts
379
+ });
380
+
381
+ new Chart(document.getElementById('agentCPT'), {
382
+ type: 'bar',
383
+ data: { labels: AGENT_LABELS,
384
+ datasets: [{ label: 'Cost/turn', data: AGENT_CPT,
385
+ backgroundColor: '#fbbf24', borderRadius: 4 }] },
386
+ options: { ...baseOpts, plugins: { ...baseOpts.plugins,
387
+ tooltip: { callbacks: { label: ctx => ' $' + ctx.parsed.y.toFixed(4) + '/turn' } } } }
388
+ });'''
389
+ else:
390
+ agents_stat_html = ''
391
+ agents_section_html = ''
392
+ agents_js_constants = ''
393
+ agents_js_charts = ''
394
+
395
+ # --- Skill data aggregation ---
396
+ by_skill_name = defaultdict(lambda: {"count": 0, "success": 0, "fail": 0, "duration": 0})
397
+ skill_by_date = defaultdict(int)
398
+ for sk in skill_data:
399
+ name = sk.get('skill_name', 'unknown')
400
+ by_skill_name[name]["count"] += 1
401
+ if sk.get('success', 1):
402
+ by_skill_name[name]["success"] += 1
403
+ else:
404
+ by_skill_name[name]["fail"] += 1
405
+ by_skill_name[name]["duration"] += sk.get('duration_seconds', 0)
406
+ skill_by_date[sk.get('date', 'unknown')] += 1
407
+
408
+ skill_names_sorted = sorted(by_skill_name.keys(), key=lambda n: -by_skill_name[n]["count"])
409
+ total_skill_invocations = sum(by_skill_name[n]["count"] for n in by_skill_name)
410
+ total_skill_success = sum(by_skill_name[n]["success"] for n in by_skill_name)
411
+ skill_success_rate = round(total_skill_success / total_skill_invocations * 100, 1) if total_skill_invocations > 0 else 0
412
+
413
+ skill_labels_js = json.dumps(skill_names_sorted)
414
+ skill_counts_js = json.dumps([by_skill_name[n]["count"] for n in skill_names_sorted])
415
+ skill_success_js = json.dumps([by_skill_name[n]["success"] for n in skill_names_sorted])
416
+ skill_fail_js = json.dumps([by_skill_name[n]["fail"] for n in skill_names_sorted])
417
+
418
+ skill_timeline_dates = sorted(skill_by_date.keys())
419
+ skill_timeline_dates_js = json.dumps(skill_timeline_dates)
420
+ skill_timeline_values_js = json.dumps([skill_by_date[d] for d in skill_timeline_dates])
421
+
422
+ if skill_data:
423
+ skills_stat_html = f''' <div class="stat">
424
+ <div class="stat-label">Skill invocations</div>
425
+ <div class="stat-value">{total_skill_invocations}</div>
426
+ <div class="stat-sub">{skill_success_rate}% success rate</div>
427
+ </div>'''
428
+ skills_section_html = f'''<div class="section">
429
+ <div class="section-header skills">Skills</div>
430
+ <div class="grid">
431
+ <div class="card">
432
+ <h2>Invocations by skill</h2>
433
+ <canvas id="skillCount"></canvas>
434
+ </div>
435
+ <div class="card">
436
+ <h2>Success / fail by skill</h2>
437
+ <canvas id="skillSuccess"></canvas>
438
+ </div>
439
+ <div class="card wide">
440
+ <h2>Skill invocations over time</h2>
441
+ <canvas id="skillTimeline"></canvas>
442
+ </div>
443
+ </div>
444
+ </div>
445
+
446
+ '''
447
+ skills_js_constants = f'''const SKILL_LABELS = {skill_labels_js};
448
+ const SKILL_COUNTS = {skill_counts_js};
449
+ const SKILL_SUCCESS = {skill_success_js};
450
+ const SKILL_FAIL = {skill_fail_js};
451
+ const SKILL_TIMELINE_DATES = {skill_timeline_dates_js};
452
+ const SKILL_TIMELINE_VALUES = {skill_timeline_values_js};'''
453
+ skills_js_charts = '''
454
+ new Chart(document.getElementById('skillCount'), {
455
+ type: 'bar',
456
+ data: { labels: SKILL_LABELS,
457
+ datasets: [{ label: 'Invocations', data: SKILL_COUNTS,
458
+ backgroundColor: '#f59e0b', borderRadius: 4 }] },
459
+ options: { ...baseOpts, indexAxis: 'y' }
460
+ });
461
+
462
+ new Chart(document.getElementById('skillSuccess'), {
463
+ type: 'bar',
464
+ data: { labels: SKILL_LABELS,
465
+ datasets: [
466
+ { label: 'Success', data: SKILL_SUCCESS, backgroundColor: '#f59e0b', borderRadius: 2 },
467
+ { label: 'Fail', data: SKILL_FAIL, backgroundColor: '#92400e', borderRadius: 2 }
468
+ ] },
469
+ options: { ...baseOpts, scales: { ...baseOpts.scales,
470
+ x: { ...baseOpts.scales.x, stacked: true },
471
+ y: { ...baseOpts.scales.y, stacked: true } } }
472
+ });
473
+
474
+ new Chart(document.getElementById('skillTimeline'), {
475
+ type: 'line',
476
+ data: {
477
+ labels: SKILL_TIMELINE_DATES,
478
+ datasets: [{ label: 'Invocations', data: SKILL_TIMELINE_VALUES,
479
+ borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.15)',
480
+ fill: true, tension: 0.3, pointRadius: 3 }]
481
+ },
482
+ options: baseOpts
483
+ });'''
484
+ else:
485
+ skills_stat_html = ''
486
+ skills_section_html = ''
487
+ skills_js_constants = ''
488
+ skills_js_charts = ''
489
+
490
+ # --- Friction data aggregation ---
491
+ FRICTION_CAT_COLORS = {
492
+ "permission_denied": "#dc2626",
493
+ "hook_blocked": "#b91c1c",
494
+ "cascade_error": "#f97316",
495
+ "command_failed": "#eab308",
496
+ "tool_error": "#ef4444",
497
+ "correction": "#8b5cf6",
498
+ "retry": "#06b6d4",
499
+ }
500
+ FRICTION_DEFAULT_COLOR = "#94a3b8"
501
+
502
+ friction_by_date = defaultdict(lambda: {"main": 0, "subagent": 0})
503
+ friction_by_category = defaultdict(int)
504
+ friction_by_tool = defaultdict(int)
505
+ for fe in friction_data:
506
+ d = fe.get('date', 'unknown')
507
+ src = fe.get('source', 'main')
508
+ if src == 'subagent':
509
+ friction_by_date[d]["subagent"] += 1
510
+ else:
511
+ friction_by_date[d]["main"] += 1
512
+ friction_by_category[fe.get('category', 'unknown')] += 1
513
+ tn = fe.get('tool_name')
514
+ if tn:
515
+ friction_by_tool[tn] += 1
516
+
517
+ friction_by_skill = defaultdict(int)
518
+ for fe in friction_data:
519
+ sk = fe.get('skill')
520
+ if sk:
521
+ friction_by_skill[sk] += 1
522
+ has_skill_data = bool(friction_by_skill)
523
+
524
+ retry_events = [fe for fe in friction_data if fe.get('category') == 'retry']
525
+ retry_total = len(retry_events)
526
+ retry_resolved = sum(1 for fe in retry_events if fe.get('resolved') is True)
527
+ retry_rate = round(retry_resolved / retry_total * 100, 1) if retry_total > 0 else 0
528
+
529
+ total_friction = len(friction_data)
530
+ friction_rate = round(total_friction / total_turns * 100, 1) if total_turns > 0 else 0
531
+
532
+ friction_dates_sorted = sorted(friction_by_date.keys())
533
+ friction_dates_js = json.dumps(friction_dates_sorted)
534
+ friction_main_by_date_js = json.dumps([friction_by_date[d]["main"] for d in friction_dates_sorted])
535
+ friction_sub_by_date_js = json.dumps([friction_by_date[d]["subagent"] for d in friction_dates_sorted])
536
+
537
+ friction_cats_sorted = sorted(friction_by_category.keys(), key=lambda c: -friction_by_category[c])
538
+ friction_cat_labels_js = json.dumps(friction_cats_sorted)
539
+ friction_cat_values_js = json.dumps([friction_by_category[c] for c in friction_cats_sorted])
540
+ friction_cat_colors_js = json.dumps([FRICTION_CAT_COLORS.get(c, FRICTION_DEFAULT_COLOR) for c in friction_cats_sorted])
541
+
542
+ friction_tools_sorted = sorted(friction_by_tool.keys(), key=lambda t: -friction_by_tool[t])
543
+ friction_tool_labels_js = json.dumps(friction_tools_sorted)
544
+ friction_tool_values_js = json.dumps([friction_by_tool[t] for t in friction_tools_sorted])
545
+
546
+ friction_skills_sorted = sorted(friction_by_skill.keys(), key=lambda s: -friction_by_skill[s])
547
+ friction_skill_labels_js = json.dumps(friction_skills_sorted)
548
+ friction_skill_values_js = json.dumps([friction_by_skill[s] for s in friction_skills_sorted])
549
+
550
+ # Friction rate trend per day (events per 100 prompts)
551
+ friction_rate_dates = sorted(set(friction_dates_sorted) & set(dates))
552
+ friction_rate_values = []
553
+ for d in friction_rate_dates:
554
+ day_friction = friction_by_date[d]["main"] + friction_by_date[d]["subagent"]
555
+ day_turns = by_date[d]["turns"] if d in by_date else 0
556
+ friction_rate_values.append(round(day_friction / day_turns * 100, 1) if day_turns > 0 else 0)
557
+ friction_rate_dates_js = json.dumps(friction_rate_dates)
558
+ friction_rate_values_js = json.dumps(friction_rate_values)
559
+
560
+ if friction_data:
561
+ friction_stat_html = f''' <div class="stat">
562
+ <div class="stat-label">Friction events</div>
563
+ <div class="stat-value">{total_friction}</div>
564
+ <div class="stat-sub">{friction_rate} per 100 prompts</div>
565
+ </div>'''
566
+ retry_stat_html = f''' <div class="stat">
567
+ <div class="stat-label">Retry resolution</div>
568
+ <div class="stat-value">{retry_rate}%</div>
569
+ <div class="stat-sub">{retry_resolved} of {retry_total} retries succeeded</div>
570
+ </div>''' if retry_total > 0 else ''
571
+ friction_skill_card = '''
572
+ <div class="card">
573
+ <h2>Friction by skill</h2>
574
+ <canvas id="frictionSkill"></canvas>
575
+ </div>''' if has_skill_data else ''
576
+ friction_section_html = f'''<div class="section">
577
+ <div class="section-header friction">Friction</div>
578
+ <div class="grid">
579
+ <div class="card">
580
+ <h2>Friction per day</h2>
581
+ <canvas id="frictionDay"></canvas>
582
+ </div>
583
+ <div class="card">
584
+ <h2>Friction by category</h2>
585
+ <canvas id="frictionCat"></canvas>
586
+ </div>
587
+ <div class="card">
588
+ <h2>Friction by tool</h2>
589
+ <canvas id="frictionTool"></canvas>
590
+ </div>
591
+ <div class="card">
592
+ <h2>Friction rate trend</h2>
593
+ <canvas id="frictionRate"></canvas>
594
+ </div>{friction_skill_card}
595
+ </div>
596
+ </div>
597
+
598
+ '''
599
+ friction_js_constants = f'''const FRICTION_DATES = {friction_dates_js};
600
+ const FRICTION_MAIN = {friction_main_by_date_js};
601
+ const FRICTION_SUB = {friction_sub_by_date_js};
602
+ const FRICTION_CAT_LABELS = {friction_cat_labels_js};
603
+ const FRICTION_CAT_VALUES = {friction_cat_values_js};
604
+ const FRICTION_CAT_COLORS = {friction_cat_colors_js};
605
+ const FRICTION_TOOL_LABELS = {friction_tool_labels_js};
606
+ const FRICTION_TOOL_VALUES = {friction_tool_values_js};
607
+ const FRICTION_RATE_DATES = {friction_rate_dates_js};
608
+ const FRICTION_RATE_VALUES = {friction_rate_values_js};''' + (f'''
609
+ const FRICTION_SKILL_LABELS = {friction_skill_labels_js};
610
+ const FRICTION_SKILL_VALUES = {friction_skill_values_js};''' if has_skill_data else '')
611
+ friction_js_charts = '''
612
+ // Friction per day (stacked bar)
613
+ new Chart(document.getElementById('frictionDay'), {
614
+ type: 'bar',
615
+ data: {
616
+ labels: FRICTION_DATES,
617
+ datasets: [
618
+ { label: 'Main session', data: FRICTION_MAIN, backgroundColor: '#ef4444', borderRadius: 2 },
619
+ { label: 'Subagent', data: FRICTION_SUB, backgroundColor: '#f97316', borderRadius: 2 }
620
+ ]
621
+ },
622
+ options: { ...baseOpts, scales: { ...baseOpts.scales,
623
+ x: { ...baseOpts.scales.x, stacked: true },
624
+ y: { ...baseOpts.scales.y, stacked: true } } }
625
+ });
626
+
627
+ // Friction by category (horizontal bar)
628
+ new Chart(document.getElementById('frictionCat'), {
629
+ type: 'bar',
630
+ data: {
631
+ labels: FRICTION_CAT_LABELS,
632
+ datasets: [{ label: 'Events', data: FRICTION_CAT_VALUES,
633
+ backgroundColor: FRICTION_CAT_COLORS, borderRadius: 4 }]
634
+ },
635
+ options: { ...baseOpts, indexAxis: 'y' }
636
+ });
637
+
638
+ // Friction by tool (bar)
639
+ new Chart(document.getElementById('frictionTool'), {
640
+ type: 'bar',
641
+ data: {
642
+ labels: FRICTION_TOOL_LABELS,
643
+ datasets: [{ label: 'Events', data: FRICTION_TOOL_VALUES,
644
+ backgroundColor: '#ef4444', borderRadius: 4 }]
645
+ },
646
+ options: baseOpts
647
+ });
648
+
649
+ // Friction rate trend (line)
650
+ new Chart(document.getElementById('frictionRate'), {
651
+ type: 'line',
652
+ data: {
653
+ labels: FRICTION_RATE_DATES,
654
+ datasets: [{ label: 'Per 100 prompts', data: FRICTION_RATE_VALUES,
655
+ borderColor: '#ef4444', backgroundColor: 'rgba(239,68,68,0.15)',
656
+ fill: true, tension: 0.3, pointRadius: 3 }]
657
+ },
658
+ options: { ...baseOpts, plugins: { ...baseOpts.plugins,
659
+ tooltip: { callbacks: { label: ctx => ' ' + ctx.parsed.y + ' per 100 prompts' } } } }
660
+ });''' + ('''
661
+
662
+ // Friction by skill (horizontal bar)
663
+ new Chart(document.getElementById('frictionSkill'), {
664
+ type: 'bar',
665
+ data: {
666
+ labels: FRICTION_SKILL_LABELS,
667
+ datasets: [{ label: 'Events', data: FRICTION_SKILL_VALUES,
668
+ backgroundColor: '#c084fc', borderRadius: 4 }]
669
+ },
670
+ options: { ...baseOpts, indexAxis: 'y' }
671
+ });''' if has_skill_data else '')
672
+ else:
673
+ friction_stat_html = ''
674
+ retry_stat_html = ''
675
+ friction_section_html = ''
676
+ friction_js_constants = ''
677
+ friction_js_charts = ''
678
+
679
+ # --- Error tracking ---
680
+ ERROR_CATEGORIES = {'tool_error', 'command_failed', 'cascade_error'}
681
+
682
+ def classify_error(event):
683
+ detail = (event.get('detail') or '').strip()
684
+ if 'InputValidationError' in detail:
685
+ return 'validation_error'
686
+ if 'No such file' in detail or 'FileNotFoundError' in detail:
687
+ return 'file_not_found'
688
+ if 'timed out' in detail.lower() or 'timeout' in detail.lower():
689
+ return 'timeout'
690
+ if 'UnicodeDecodeError' in detail or 'encoding' in detail.lower():
691
+ return 'encoding_error'
692
+ if event.get('category') == 'command_failed':
693
+ import re as _re
694
+ m = _re.search(r'Exit code (\d+)', detail)
695
+ if m:
696
+ return f'exit_code_{m.group(1)}'
697
+ return 'generic'
698
+
699
+ error_events = [fe for fe in friction_data if fe.get('category') in ERROR_CATEGORIES]
700
+
701
+ error_by_type = defaultdict(int)
702
+ error_by_tool = defaultdict(int)
703
+ error_by_date = defaultdict(int)
704
+ for ee in error_events:
705
+ error_by_type[classify_error(ee)] += 1
706
+ tn = ee.get('tool_name')
707
+ if tn:
708
+ error_by_tool[tn] += 1
709
+ error_by_date[ee.get('date', 'unknown')] += 1
710
+
711
+ total_errors = len(error_events)
712
+ error_rate = round(total_errors / total_turns * 100, 1) if total_turns > 0 else 0
713
+
714
+ if error_events:
715
+ error_types_sorted = sorted(error_by_type.keys(), key=lambda t: -error_by_type[t])
716
+ error_tools_sorted = sorted(error_by_tool.keys(), key=lambda t: -error_by_tool[t])
717
+ error_dates_sorted = sorted(error_by_date.keys())
718
+
719
+ error_type_labels_js = json.dumps(error_types_sorted)
720
+ error_type_values_js = json.dumps([error_by_type[t] for t in error_types_sorted])
721
+ error_tool_labels_js = json.dumps(error_tools_sorted)
722
+ error_tool_values_js = json.dumps([error_by_tool[t] for t in error_tools_sorted])
723
+ error_dates_js = json.dumps(error_dates_sorted)
724
+ error_date_values_js = json.dumps([error_by_date[d] for d in error_dates_sorted])
725
+
726
+ error_stat_html = f''' <div class="stat">
727
+ <div class="stat-label">Errors</div>
728
+ <div class="stat-value">{total_errors}</div>
729
+ <div class="stat-sub">{error_rate} per 100 prompts</div>
730
+ </div>'''
731
+ error_section_html = f'''<div class="section">
732
+ <div class="section-header errors">Errors</div>
733
+ <div class="grid">
734
+ <div class="card">
735
+ <h2>Error types</h2>
736
+ <canvas id="errorTypes"></canvas>
737
+ </div>
738
+ <div class="card">
739
+ <h2>Errors by tool</h2>
740
+ <canvas id="errorTools"></canvas>
741
+ </div>
742
+ <div class="card wide">
743
+ <h2>Error trend per day</h2>
744
+ <canvas id="errorTrend"></canvas>
745
+ </div>
746
+ </div>
747
+ </div>
748
+
749
+ '''
750
+ error_js_constants = f'''const ERROR_TYPE_LABELS = {error_type_labels_js};
751
+ const ERROR_TYPE_VALUES = {error_type_values_js};
752
+ const ERROR_TOOL_LABELS = {error_tool_labels_js};
753
+ const ERROR_TOOL_VALUES = {error_tool_values_js};
754
+ const ERROR_DATES = {error_dates_js};
755
+ const ERROR_DATE_VALUES = {error_date_values_js};'''
756
+ error_js_charts = '''
757
+ // Error types (horizontal bar)
758
+ new Chart(document.getElementById('errorTypes'), {
759
+ type: 'bar',
760
+ data: {
761
+ labels: ERROR_TYPE_LABELS,
762
+ datasets: [{ label: 'Errors', data: ERROR_TYPE_VALUES,
763
+ backgroundColor: '#fb7185', borderRadius: 4 }]
764
+ },
765
+ options: { ...baseOpts, indexAxis: 'y' }
766
+ });
767
+
768
+ // Errors by tool (bar)
769
+ new Chart(document.getElementById('errorTools'), {
770
+ type: 'bar',
771
+ data: {
772
+ labels: ERROR_TOOL_LABELS,
773
+ datasets: [{ label: 'Errors', data: ERROR_TOOL_VALUES,
774
+ backgroundColor: '#e11d48', borderRadius: 4 }]
775
+ },
776
+ options: baseOpts
777
+ });
778
+
779
+ // Error trend per day (line)
780
+ new Chart(document.getElementById('errorTrend'), {
781
+ type: 'line',
782
+ data: {
783
+ labels: ERROR_DATES,
784
+ datasets: [{ label: 'Errors', data: ERROR_DATE_VALUES,
785
+ borderColor: '#e11d48', backgroundColor: 'rgba(225,29,72,0.15)',
786
+ fill: true, tension: 0.3, pointRadius: 3 }]
787
+ },
788
+ options: baseOpts
789
+ });'''
790
+ else:
791
+ error_stat_html = ''
792
+ error_section_html = ''
793
+ error_js_constants = ''
794
+ error_js_charts = ''
795
+
288
796
  html = f"""<!DOCTYPE html>
289
797
  <html lang="en">
290
798
  <head>
@@ -314,6 +822,10 @@ html = f"""<!DOCTYPE html>
314
822
  .section-header.cost {{ border-left: 3px solid #6366f1; color: #818cf8; }}
315
823
  .section-header.time {{ border-left: 3px solid #34d399; color: #34d399; }}
316
824
  .section-header.prompts {{ border-left: 3px solid #a78bfa; color: #a78bfa; }}
825
+ .section-header.agents {{ border-left: 3px solid #f97316; color: #fb923c; }}
826
+ .section-header.friction {{ border-left: 3px solid #ef4444; color: #f87171; }}
827
+ .section-header.errors {{ border-left: 3px solid #e11d48; color: #fb7185; }}
828
+ .section-header.skills {{ border-left: 3px solid #f59e0b; color: #fbbf24; }}
317
829
  .grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }}
318
830
  .card {{ background: #1e2330; border: 1px solid #2d3748; border-radius: 10px;
319
831
  padding: 16px; }}
@@ -350,6 +862,11 @@ html = f"""<!DOCTYPE html>
350
862
  <div class="stat-value">{total_output:,}</div>
351
863
  <div class="stat-sub">&nbsp;</div>
352
864
  </div>
865
+ <div class="stat">
866
+ <div class="stat-label">Input tokens</div>
867
+ <div class="stat-value">{total_input:,}</div>
868
+ <div class="stat-sub">&nbsp;</div>
869
+ </div>
353
870
  <div class="stat">
354
871
  <div class="stat-label">Cache read share</div>
355
872
  <div class="stat-value">{cache_pct}%</div>
@@ -370,6 +887,11 @@ html = f"""<!DOCTYPE html>
370
887
  <div class="stat-value">{overall_efficiency}%</div>
371
888
  <div class="stat-sub">key / non-trivial (higher = better)</div>
372
889
  </div>
890
+ {agents_stat_html}
891
+ {skills_stat_html}
892
+ {friction_stat_html}
893
+ {retry_stat_html}
894
+ {error_stat_html}
373
895
  </div>
374
896
 
375
897
  <div class="section">
@@ -396,10 +918,15 @@ html = f"""<!DOCTYPE html>
396
918
  <canvas id="modelStack"></canvas>
397
919
  </div>
398
920
 
921
+ <div class="card wide">
922
+ <h2>Token composition per day</h2>
923
+ <canvas id="tokenComp"></canvas>
924
+ </div>
925
+
399
926
  </div>
400
927
  </div>
401
928
 
402
- <div class="section">
929
+ {agents_section_html}{skills_section_html}{friction_section_html}{error_section_html}<div class="section">
403
930
  <div class="section-header prompts">Key Prompts</div>
404
931
  <div class="grid">
405
932
 
@@ -477,6 +1004,9 @@ const DATES = {dates_js};
477
1004
  const COST_BY_DATE = {cost_by_date_js};
478
1005
  const SESSIONS_BY_DATE = {sessions_by_date_js};
479
1006
  const OUTPUT_BY_DATE = {output_by_date_js};
1007
+ const INPUT_BY_DATE = {input_by_date_js};
1008
+ const CACHE_CREATE_BY_DATE = {cache_create_by_date_js};
1009
+ const CACHE_READ_BY_DATE = {cache_read_by_date_js};
480
1010
  const OPUS_BY_DATE = {opus_by_date_js};
481
1011
  const SONNET_BY_DATE = {sonnet_by_date_js};
482
1012
  const CUMUL_LABELS = {cumul_labels_js};
@@ -501,6 +1031,10 @@ const AVG_DURATION_BY_DATE = {avg_duration_by_date_js};
501
1031
  const SCATTER_DATA = {scatter_data_js};
502
1032
  const TPM_DATA = {tpm_data_js};
503
1033
  const DUR_HIST_RANGES = {dur_hist_ranges_js};
1034
+ {agents_js_constants}
1035
+ {skills_js_constants}
1036
+ {friction_js_constants}
1037
+ {error_js_constants}
504
1038
 
505
1039
  function formatDuration(s) {{
506
1040
  if (s <= 0) return '0s';
@@ -576,6 +1110,23 @@ new Chart(document.getElementById('modelStack'), {{
576
1110
  tooltip: {{ callbacks: {{ label: ctx => ' $' + ctx.parsed.y.toFixed(2) }} }} }} }}
577
1111
  }});
578
1112
 
1113
+ // Token composition per day (stacked bar)
1114
+ new Chart(document.getElementById('tokenComp'), {{
1115
+ type: 'bar',
1116
+ data: {{
1117
+ labels: DATES,
1118
+ datasets: [
1119
+ {{ label: 'Input', data: INPUT_BY_DATE, backgroundColor: '#6366f1', borderRadius: 2 }},
1120
+ {{ label: 'Cache creation', data: CACHE_CREATE_BY_DATE, backgroundColor: '#f59e0b', borderRadius: 2 }},
1121
+ {{ label: 'Cache read', data: CACHE_READ_BY_DATE, backgroundColor: '#22d3ee', borderRadius: 2 }},
1122
+ {{ label: 'Output', data: OUTPUT_BY_DATE, backgroundColor: '#a78bfa', borderRadius: 2 }}
1123
+ ]
1124
+ }},
1125
+ options: {{ ...baseOpts, scales: {{ ...baseOpts.scales,
1126
+ x: {{ ...baseOpts.scales.x, stacked: true }},
1127
+ y: {{ ...baseOpts.scales.y, stacked: true }} }} }}
1128
+ }});
1129
+
579
1130
  // Session duration per day
580
1131
  new Chart(document.getElementById('durationDay'), {{
581
1132
  type: 'bar',
@@ -757,10 +1308,14 @@ new Chart(document.getElementById('promptStack'), {{
757
1308
  }}
758
1309
  }}
759
1310
  }});
1311
+ {agents_js_charts}
1312
+ {skills_js_charts}
1313
+ {friction_js_charts}
1314
+ {error_js_charts}
760
1315
  </script>
761
1316
  </body>
762
1317
  </html>
763
1318
  """
764
1319
 
765
- with open(output_file, "w") as f:
1320
+ with open(output_file, "w", encoding='utf-8') as f:
766
1321
  f.write(html)