codex-stats 1.4.1__tar.gz → 1.4.2__tar.gz

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.
Files changed (26) hide show
  1. {codex_stats-1.4.1/src/codex_stats.egg-info → codex_stats-1.4.2}/PKG-INFO +5 -5
  2. {codex_stats-1.4.1 → codex_stats-1.4.2}/README.md +4 -4
  3. {codex_stats-1.4.1 → codex_stats-1.4.2}/pyproject.toml +1 -1
  4. {codex_stats-1.4.1 → codex_stats-1.4.2}/src/codex_stats/__init__.py +1 -1
  5. {codex_stats-1.4.1 → codex_stats-1.4.2}/src/codex_stats/cli.py +31 -3
  6. {codex_stats-1.4.1 → codex_stats-1.4.2}/src/codex_stats/display.py +138 -100
  7. {codex_stats-1.4.1 → codex_stats-1.4.2/src/codex_stats.egg-info}/PKG-INFO +5 -5
  8. {codex_stats-1.4.1 → codex_stats-1.4.2}/tests/test_cli.py +17 -4
  9. {codex_stats-1.4.1 → codex_stats-1.4.2}/tests/test_metrics.py +6 -3
  10. {codex_stats-1.4.1 → codex_stats-1.4.2}/LICENSE +0 -0
  11. {codex_stats-1.4.1 → codex_stats-1.4.2}/setup.cfg +0 -0
  12. {codex_stats-1.4.1 → codex_stats-1.4.2}/src/codex_stats/__main__.py +0 -0
  13. {codex_stats-1.4.1 → codex_stats-1.4.2}/src/codex_stats/completions.py +0 -0
  14. {codex_stats-1.4.1 → codex_stats-1.4.2}/src/codex_stats/config.py +0 -0
  15. {codex_stats-1.4.1 → codex_stats-1.4.2}/src/codex_stats/ingest.py +0 -0
  16. {codex_stats-1.4.1 → codex_stats-1.4.2}/src/codex_stats/metrics.py +0 -0
  17. {codex_stats-1.4.1 → codex_stats-1.4.2}/src/codex_stats/models.py +0 -0
  18. {codex_stats-1.4.1 → codex_stats-1.4.2}/src/codex_stats/otel.py +0 -0
  19. {codex_stats-1.4.1 → codex_stats-1.4.2}/src/codex_stats/transfer.py +0 -0
  20. {codex_stats-1.4.1 → codex_stats-1.4.2}/src/codex_stats/watch_state.py +0 -0
  21. {codex_stats-1.4.1 → codex_stats-1.4.2}/src/codex_stats.egg-info/SOURCES.txt +0 -0
  22. {codex_stats-1.4.1 → codex_stats-1.4.2}/src/codex_stats.egg-info/dependency_links.txt +0 -0
  23. {codex_stats-1.4.1 → codex_stats-1.4.2}/src/codex_stats.egg-info/entry_points.txt +0 -0
  24. {codex_stats-1.4.1 → codex_stats-1.4.2}/src/codex_stats.egg-info/requires.txt +0 -0
  25. {codex_stats-1.4.1 → codex_stats-1.4.2}/src/codex_stats.egg-info/top_level.txt +0 -0
  26. {codex_stats-1.4.1 → codex_stats-1.4.2}/tests/test_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-stats
3
- Version: 1.4.1
3
+ Version: 1.4.2
4
4
  Summary: Local AI usage observability CLI for Codex sessions.
5
5
  Author: Vivek
6
6
  License: MIT
@@ -110,15 +110,15 @@ python3 -m pip install codex-stats
110
110
  - `codex-stats report weekly --format html`
111
111
  Generate a standalone HTML report for sharing, including inline charts.
112
112
  - `codex-stats report weekly --format svg`
113
- Generate a poster-style SVG asset for sharing in docs, social posts, or release notes. By default it writes `codex-stats-weekly.svg` in the current working directory.
113
+ Generate a bundle of smaller standalone SVG assets in the current working directory, including a summary card plus graph-focused assets.
114
114
  - `codex-stats report weekly --project backend-api`
115
115
  Generate a weekly report for one project.
116
116
  - `codex-stats report weekly --format markdown --output weekly-report.md`
117
117
  Write a formatted report to a file.
118
118
  - `codex-stats report weekly --format html --output weekly-report.html`
119
119
  Write a polished standalone HTML report to a file.
120
- - `codex-stats report weekly --format svg --output weekly-report.svg`
121
- Write a standalone SVG share card to a file.
120
+ - `codex-stats report weekly --format svg --output weekly-report-assets`
121
+ Write the SVG asset bundle into the output directory you provide.
122
122
  - `codex-stats report weekly --format html --render`
123
123
  Generate an HTML report, write it to a temp file, and open it in the default browser.
124
124
  - `codex-stats report weekly --format svg --render`
@@ -227,7 +227,7 @@ The current priority list lives in [docs/roadmap.md](/Users/vivek/Desktop/Salad/
227
227
 
228
228
  ## Shareable Assets
229
229
 
230
- Share-ready assets now live under [docs/assets/codex-stats-share-card.svg](/Users/vivek/Desktop/Salad/codex_stats/docs/assets/codex-stats-share-card.svg), and `report --format svg` can generate poster-style report visuals directly from local usage data.
230
+ Share-ready assets now live under [docs/assets/codex-stats-share-card.svg](/Users/vivek/Desktop/Salad/codex_stats/docs/assets/codex-stats-share-card.svg), and `report --format svg` can generate a full bundle of report assets directly from local usage data.
231
231
 
232
232
  ## Watch Alerts
233
233
 
@@ -83,15 +83,15 @@ python3 -m pip install codex-stats
83
83
  - `codex-stats report weekly --format html`
84
84
  Generate a standalone HTML report for sharing, including inline charts.
85
85
  - `codex-stats report weekly --format svg`
86
- Generate a poster-style SVG asset for sharing in docs, social posts, or release notes. By default it writes `codex-stats-weekly.svg` in the current working directory.
86
+ Generate a bundle of smaller standalone SVG assets in the current working directory, including a summary card plus graph-focused assets.
87
87
  - `codex-stats report weekly --project backend-api`
88
88
  Generate a weekly report for one project.
89
89
  - `codex-stats report weekly --format markdown --output weekly-report.md`
90
90
  Write a formatted report to a file.
91
91
  - `codex-stats report weekly --format html --output weekly-report.html`
92
92
  Write a polished standalone HTML report to a file.
93
- - `codex-stats report weekly --format svg --output weekly-report.svg`
94
- Write a standalone SVG share card to a file.
93
+ - `codex-stats report weekly --format svg --output weekly-report-assets`
94
+ Write the SVG asset bundle into the output directory you provide.
95
95
  - `codex-stats report weekly --format html --render`
96
96
  Generate an HTML report, write it to a temp file, and open it in the default browser.
97
97
  - `codex-stats report weekly --format svg --render`
@@ -200,7 +200,7 @@ The current priority list lives in [docs/roadmap.md](/Users/vivek/Desktop/Salad/
200
200
 
201
201
  ## Shareable Assets
202
202
 
203
- Share-ready assets now live under [docs/assets/codex-stats-share-card.svg](/Users/vivek/Desktop/Salad/codex_stats/docs/assets/codex-stats-share-card.svg), and `report --format svg` can generate poster-style report visuals directly from local usage data.
203
+ Share-ready assets now live under [docs/assets/codex-stats-share-card.svg](/Users/vivek/Desktop/Salad/codex_stats/docs/assets/codex-stats-share-card.svg), and `report --format svg` can generate a full bundle of report assets directly from local usage data.
204
204
 
205
205
  ## Watch Alerts
206
206
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codex-stats"
7
- version = "1.4.1"
7
+ version = "1.4.2"
8
8
  description = "Local AI usage observability CLI for Codex sessions."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "1.4.1"
3
+ __version__ = "1.4.2"
@@ -25,6 +25,7 @@ from .display import (
25
25
  format_report,
26
26
  format_report_html,
27
27
  format_report_markdown,
28
+ format_report_svg_assets,
28
29
  format_report_svg,
29
30
  format_session,
30
31
  format_summary,
@@ -416,17 +417,27 @@ def main(argv: list[str] | None = None) -> int:
416
417
  elif args.format == "html":
417
418
  content = format_report_html(report, daily_points=daily_points)
418
419
  elif args.format == "svg":
419
- content = format_report_svg(report, daily_points=daily_points)
420
+ assets = format_report_svg_assets(report, daily_points=daily_points)
421
+ output_dir = _write_report_svg_assets(
422
+ assets,
423
+ args.output,
424
+ base_name=_default_report_basename(args.period, args.project_name),
425
+ )
426
+ print(f"Wrote SVG assets to {output_dir}")
427
+ if args.render:
428
+ summary_path = output_dir / f"{_default_report_basename(args.period, args.project_name)}-summary-card.svg"
429
+ _open_report_in_browser(summary_path)
430
+ print(f"Opened report in browser: {summary_path}")
431
+ return 0
420
432
  elif args.format == "markdown":
421
433
  content = format_report_markdown(report)
422
434
  else:
423
435
  content = format_report(report, options)
424
- if args.output or args.render or args.format == "svg":
436
+ if args.output or args.render:
425
437
  output_path = _write_report_output(
426
438
  content,
427
439
  args.output,
428
440
  suffix=f".{args.format}" if args.format in {"html", "svg"} else ".txt",
429
- default_name=_default_report_filename(args.period, args.project_name, args.format) if args.format == "svg" else None,
430
441
  )
431
442
  print(f"Wrote report to {output_path}")
432
443
  if args.render:
@@ -635,5 +646,22 @@ def _default_report_filename(period: str, project_name: str | None, fmt: str) ->
635
646
  return f"codex-stats-{period}.{fmt}"
636
647
 
637
648
 
649
+ def _default_report_basename(period: str, project_name: str | None) -> str:
650
+ if project_name:
651
+ safe_project = "".join(char.lower() if char.isalnum() else "-" for char in project_name).strip("-")
652
+ safe_project = safe_project or "project"
653
+ return f"codex-stats-{period}-{safe_project}"
654
+ return f"codex-stats-{period}"
655
+
656
+
657
+ def _write_report_svg_assets(assets: dict[str, str], output: str | None, *, base_name: str) -> Path:
658
+ output_dir = Path(output).expanduser() if output else Path.cwd()
659
+ output_dir.mkdir(parents=True, exist_ok=True)
660
+ for asset_name, content in assets.items():
661
+ asset_path = output_dir / f"{base_name}-{asset_name}.svg"
662
+ asset_path.write_text(content + ("" if content.endswith("\n") else "\n"), encoding="utf-8")
663
+ return output_dir
664
+
665
+
638
666
  if __name__ == "__main__":
639
667
  raise SystemExit(main())
@@ -788,110 +788,59 @@ def format_report_html(report: ReportData, daily_points: list[DailyPoint] | None
788
788
  """
789
789
 
790
790
 
791
- def format_report_svg(report: ReportData, daily_points: list[DailyPoint] | None = None) -> str:
791
+ def format_report_svg_assets(report: ReportData, daily_points: list[DailyPoint] | None = None) -> dict[str, str]:
792
792
  title = f"Codex Stats {report.period.title()} Report"
793
793
  if report.project_name:
794
794
  title = f"{title}: {report.project_name}"
795
795
  daily_points = daily_points or []
796
- token_trend_svg = _svg_line_chart(
797
- [(point.day[5:], float(point.total_tokens)) for point in daily_points],
798
- stroke="#0f766e",
799
- fill="rgba(15, 118, 110, 0.14)",
800
- value_formatter=lambda value: f"{int(value):,}",
801
- )
802
- project_share_svg = _svg_bar_chart(
803
- [(entry.name, float(entry.total_tokens)) for entry in report.projects[:4]],
804
- bar_color="#b45309",
805
- value_formatter=lambda value: f"{int(value):,}",
806
- empty_label="No project breakdown for this scoped report.",
807
- )
808
- anomalies = report.insights.anomalies[:3] or ["No active anomalies"]
809
- recommendations = report.insights.recommendations[:3] or ["Usage looks healthy."]
810
- top_sessions = report.top_sessions[:3]
811
- top_sessions_text = "".join(
812
- f'<text x="64" y="{776 + index * 30}" font-size="18" fill="#1f1a17">{index + 1}. {escape(entry.project_name)} / {escape(entry.model or "unknown")} {entry.total_tokens:,} tokens</text>'
813
- for index, entry in enumerate(top_sessions)
814
- ) or '<text x="64" y="776" font-size="18" fill="#1f1a17">No top sessions available.</text>'
815
- anomaly_badges = "".join(
816
- f'<rect x="{64 + index * 250}" y="846" width="220" height="44" rx="22" fill="rgba(180,83,9,0.12)" />'
817
- f'<text x="{174 + index * 250}" y="874" text-anchor="middle" font-size="16" fill="#8a4b0f">{escape(item)}</text>'
818
- for index, item in enumerate(anomalies)
819
- )
820
- recommendation_lines = "".join(
821
- f'<text x="64" y="{948 + index * 28}" font-size="18" fill="#4b4036">{escape(item)}</text>'
822
- for index, item in enumerate(recommendations)
823
- )
824
-
825
- return f"""<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" viewBox="0 0 1200 1200" role="img" aria-label="{escape(title)}">
826
- <defs>
827
- <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
828
- <stop offset="0%" stop-color="#f9f3ea"/>
829
- <stop offset="100%" stop-color="#efe6d8"/>
830
- </linearGradient>
831
- <radialGradient id="glowA" cx="0%" cy="0%" r="90%">
832
- <stop offset="0%" stop-color="rgba(15,118,110,0.24)"/>
833
- <stop offset="100%" stop-color="rgba(15,118,110,0)"/>
834
- </radialGradient>
835
- <radialGradient id="glowB" cx="100%" cy="0%" r="90%">
836
- <stop offset="0%" stop-color="rgba(180,83,9,0.18)"/>
837
- <stop offset="100%" stop-color="rgba(180,83,9,0)"/>
838
- </radialGradient>
839
- <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
840
- <feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="rgba(66,41,18,0.14)"/>
841
- </filter>
842
- </defs>
843
- <rect width="1200" height="1200" fill="url(#bg)"/>
844
- <rect width="1200" height="1200" fill="url(#glowA)"/>
845
- <rect width="1200" height="1200" fill="url(#glowB)"/>
796
+ assets = {
797
+ "summary-card": _format_summary_card_svg(report, title),
798
+ "daily-tokens": _wrap_chart_svg(
799
+ title="Daily Token Trend",
800
+ subtitle=title,
801
+ chart_svg=_svg_line_chart(
802
+ [(point.day[5:], float(point.total_tokens)) for point in daily_points],
803
+ stroke="#0f766e",
804
+ fill="rgba(15, 118, 110, 0.14)",
805
+ value_formatter=lambda value: f"{int(value):,}",
806
+ ),
807
+ ),
808
+ "daily-cost": _wrap_chart_svg(
809
+ title="Daily Cost Trend",
810
+ subtitle=title,
811
+ chart_svg=_svg_line_chart(
812
+ [(point.day[5:], float(point.estimated_cost_usd)) for point in daily_points],
813
+ stroke="#b45309",
814
+ fill="rgba(180, 83, 9, 0.14)",
815
+ value_formatter=lambda value: f"${value:.2f}",
816
+ ),
817
+ ),
818
+ "project-share": _wrap_chart_svg(
819
+ title="Project Share",
820
+ subtitle=title,
821
+ chart_svg=_svg_bar_chart(
822
+ [(entry.name, float(entry.total_tokens)) for entry in report.projects[:5]],
823
+ bar_color="#0f766e",
824
+ value_formatter=lambda value: f"{int(value):,}",
825
+ empty_label="No project breakdown for this scoped report.",
826
+ ),
827
+ ),
828
+ "top-sessions": _wrap_chart_svg(
829
+ title="Top Sessions",
830
+ subtitle=title,
831
+ chart_svg=_svg_bar_chart(
832
+ [(f"{entry.project_name} / {entry.model or 'unknown'}", float(entry.total_tokens)) for entry in report.top_sessions[:5]],
833
+ bar_color="#b45309",
834
+ value_formatter=lambda value: f"{int(value):,}",
835
+ empty_label="No top session data available.",
836
+ ),
837
+ ),
838
+ }
839
+ return assets
846
840
 
847
- <rect x="38" y="34" width="1124" height="1132" rx="36" fill="rgba(255,250,241,0.88)" stroke="rgba(72,53,36,0.10)" filter="url(#shadow)"/>
848
841
 
849
- <text x="64" y="90" font-size="18" letter-spacing="3" fill="#0f766e">CODEX STATS</text>
850
- <text x="64" y="154" font-size="52" font-weight="700" fill="#1f1a17">{escape(title)}</text>
851
- <text x="64" y="194" font-size="22" fill="#6d645d">Standalone shareable report asset</text>
852
- <text x="64" y="240" font-size="22" fill="#4b4036">Top model: {escape(report.summary.top_model or "unknown")} • Trend: {escape("n/a" if report.comparison.total_tokens_delta_pct is None else f"{report.comparison.total_tokens_delta_pct:+.1f}%")} • Cache ratio: {escape(_fmt_percent(report.summary.cache_ratio))}</text>
853
-
854
- <rect x="64" y="282" width="240" height="128" rx="24" fill="#fffaf1" stroke="rgba(72,53,36,0.10)"/>
855
- <text x="88" y="320" font-size="16" letter-spacing="2" fill="#6d645d">TOKENS</text>
856
- <text x="88" y="372" font-size="44" font-weight="700" fill="#1f1a17">{report.summary.total_tokens:,}</text>
857
- <text x="88" y="398" font-size="18" fill="#6d645d">{report.summary.requests} requests</text>
858
-
859
- <rect x="320" y="282" width="240" height="128" rx="24" fill="#fffaf1" stroke="rgba(72,53,36,0.10)"/>
860
- <text x="344" y="320" font-size="16" letter-spacing="2" fill="#6d645d">EST. COST</text>
861
- <text x="344" y="372" font-size="44" font-weight="700" fill="#1f1a17">${report.summary.estimated_cost_usd:.2f}</text>
862
- <text x="344" y="398" font-size="18" fill="#6d645d">Projected ${report.costs.projected_monthly_cost_usd:.2f}</text>
863
-
864
- <rect x="576" y="282" width="240" height="128" rx="24" fill="#fffaf1" stroke="rgba(72,53,36,0.10)"/>
865
- <text x="600" y="320" font-size="16" letter-spacing="2" fill="#6d645d">AVG / REQUEST</text>
866
- <text x="600" y="372" font-size="44" font-weight="700" fill="#1f1a17">{report.summary.average_tokens_per_request:,.0f}</text>
867
- <text x="600" y="398" font-size="18" fill="#6d645d">Largest {report.summary.largest_session_tokens:,}</text>
868
-
869
- <rect x="832" y="282" width="304" height="128" rx="24" fill="#0f766e" opacity="0.96"/>
870
- <text x="856" y="320" font-size="16" letter-spacing="2" fill="#d8f3ef">RECOMMENDATION</text>
871
- <text x="856" y="360" font-size="24" font-weight="700" fill="#ffffff">{escape(report.insights.suggestion[:34])}</text>
872
- <text x="856" y="390" font-size="18" fill="#d8f3ef">{escape(report.insights.suggestion[34:68])}</text>
873
-
874
- <rect x="64" y="442" width="520" height="310" rx="28" fill="#fffaf1" stroke="rgba(72,53,36,0.10)"/>
875
- <text x="88" y="480" font-size="24" font-weight="700" fill="#1f1a17">Daily Token Trend</text>
876
- <g transform="translate(0 10)">{token_trend_svg}</g>
877
-
878
- <rect x="616" y="442" width="520" height="310" rx="28" fill="#fffaf1" stroke="rgba(72,53,36,0.10)"/>
879
- <text x="640" y="480" font-size="24" font-weight="700" fill="#1f1a17">Project Share</text>
880
- <g transform="translate(0 28)">{project_share_svg}</g>
881
-
882
- <rect x="64" y="782" width="1072" height="110" rx="28" fill="#fffaf1" stroke="rgba(72,53,36,0.10)"/>
883
- <text x="88" y="820" font-size="24" font-weight="700" fill="#1f1a17">Top Sessions</text>
884
- {top_sessions_text}
885
-
886
- <text x="64" y="930" font-size="24" font-weight="700" fill="#1f1a17">Anomalies</text>
887
- {anomaly_badges}
888
-
889
- <text x="64" y="1036" font-size="24" font-weight="700" fill="#1f1a17">Recommended Actions</text>
890
- {recommendation_lines}
891
-
892
- <text x="1136" y="1134" text-anchor="end" font-size="18" fill="#6d645d">Generated by codex-stats</text>
893
- </svg>
894
- """
842
+ def format_report_svg(report: ReportData, daily_points: list[DailyPoint] | None = None) -> str:
843
+ return format_report_svg_assets(report, daily_points)["summary-card"]
895
844
 
896
845
 
897
846
  def _svg_line_chart(
@@ -941,7 +890,7 @@ def _svg_line_chart(
941
890
  for step in range(4)
942
891
  )
943
892
  return (
944
- f'<svg class="chart-svg" viewBox="0 0 {width} {height}" role="img" aria-label="Trend chart">'
893
+ f'<svg xmlns="http://www.w3.org/2000/svg" class="chart-svg" viewBox="0 0 {width} {height}" role="img" aria-label="Trend chart">'
945
894
  f'{grid}'
946
895
  f'<polygon points="{area_points}" fill="{fill}" />'
947
896
  f'<polyline points="{line_points}" fill="none" stroke="{stroke}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />'
@@ -975,7 +924,96 @@ def _svg_bar_chart(
975
924
  f'<rect x="{padding + label_width}" y="{y + 6}" width="{bar_width:.1f}" height="16" rx="8" fill="{bar_color}" />'
976
925
  f'<text x="{padding + label_width + bar_max_width + 10}" y="{y + 19}" font-size="12" fill="#6d645d">{escape(value_formatter(value))}</text>'
977
926
  )
978
- return f'<svg class="chart-svg" viewBox="0 0 {width} {height}" role="img" aria-label="Bar chart">{"".join(rows)}</svg>'
927
+ return f'<svg xmlns="http://www.w3.org/2000/svg" class="chart-svg" viewBox="0 0 {width} {height}" role="img" aria-label="Bar chart">{"".join(rows)}</svg>'
928
+
929
+
930
+ def _format_summary_card_svg(report: ReportData, title: str) -> str:
931
+ delta_text = "n/a" if report.comparison.total_tokens_delta_pct is None else f"{report.comparison.total_tokens_delta_pct:+.1f}%"
932
+ anomalies = report.insights.anomalies[:2] or ["No active anomalies"]
933
+ recommendations = report.insights.recommendations[:2] or ["Usage looks healthy."]
934
+ first_action = recommendations[0]
935
+ second_action = recommendations[1] if len(recommendations) > 1 else recommendations[0]
936
+ first_anomaly = anomalies[0]
937
+ second_anomaly = anomalies[1] if len(anomalies) > 1 else anomalies[0]
938
+ return f"""<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-label="{escape(title)}">
939
+ <defs>
940
+ <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
941
+ <stop offset="0%" stop-color="#fbf5ec"/>
942
+ <stop offset="100%" stop-color="#efe2cf"/>
943
+ </linearGradient>
944
+ <linearGradient id="teal" x1="0%" y1="0%" x2="100%" y2="100%">
945
+ <stop offset="0%" stop-color="#115e59"/>
946
+ <stop offset="100%" stop-color="#0f766e"/>
947
+ </linearGradient>
948
+ <linearGradient id="amber" x1="0%" y1="0%" x2="100%" y2="100%">
949
+ <stop offset="0%" stop-color="#b45309"/>
950
+ <stop offset="100%" stop-color="#d97706"/>
951
+ </linearGradient>
952
+ </defs>
953
+ <rect width="1200" height="630" fill="url(#bg)"/>
954
+ <rect x="34" y="28" width="1132" height="574" rx="32" fill="#fffaf2" stroke="rgba(66,48,31,0.10)"/>
955
+
956
+ <circle cx="1056" cy="126" r="118" fill="rgba(15,118,110,0.10)"/>
957
+ <circle cx="1116" cy="206" r="72" fill="rgba(217,119,6,0.10)"/>
958
+ <circle cx="1012" cy="214" r="18" fill="#0f766e"/>
959
+ <circle cx="1066" cy="164" r="18" fill="#d97706"/>
960
+ <circle cx="1120" cy="114" r="18" fill="#0f766e"/>
961
+
962
+ <text x="74" y="86" font-size="18" letter-spacing="3" fill="#0f766e">CODEX STATS</text>
963
+ <text x="74" y="150" font-size="60" font-weight="700" fill="#1f1a17">{escape(title)}</text>
964
+ <text x="74" y="194" font-size="24" fill="#6d645d">Compact share card for release notes, README embeds, and social posts.</text>
965
+
966
+ <rect x="74" y="226" width="336" height="52" rx="26" fill="rgba(15,118,110,0.08)"/>
967
+ <text x="102" y="258" font-size="20" fill="#134e4a">Top model {escape(report.summary.top_model or "unknown")}</text>
968
+ <rect x="426" y="226" width="190" height="52" rx="26" fill="rgba(180,83,9,0.10)"/>
969
+ <text x="454" y="258" font-size="20" fill="#92400e">Trend {escape(delta_text)}</text>
970
+ <rect x="632" y="226" width="214" height="52" rx="26" fill="rgba(66,48,31,0.06)"/>
971
+ <text x="660" y="258" font-size="20" fill="#4b4036">Cache {escape(_fmt_percent(report.summary.cache_ratio))}</text>
972
+
973
+ <rect x="74" y="314" width="240" height="120" rx="24" fill="url(#teal)"/>
974
+ <text x="98" y="350" font-size="16" letter-spacing="2" fill="#d7faf5">TOKENS</text>
975
+ <text x="98" y="397" font-size="40" font-weight="700" fill="#ffffff">{report.summary.total_tokens:,}</text>
976
+ <text x="98" y="423" font-size="18" fill="#d7faf5">{report.summary.sessions} sessions</text>
977
+
978
+ <rect x="334" y="314" width="220" height="120" rx="24" fill="#fffaf2" stroke="rgba(66,48,31,0.10)"/>
979
+ <text x="358" y="350" font-size="16" letter-spacing="2" fill="#6d645d">EST. COST</text>
980
+ <text x="358" y="397" font-size="40" font-weight="700" fill="#1f1a17">${report.summary.estimated_cost_usd:.2f}</text>
981
+ <text x="358" y="423" font-size="18" fill="#6d645d">Projected ${report.costs.projected_monthly_cost_usd:.2f}</text>
982
+
983
+ <rect x="574" y="314" width="220" height="120" rx="24" fill="#fffaf2" stroke="rgba(66,48,31,0.10)"/>
984
+ <text x="598" y="350" font-size="16" letter-spacing="2" fill="#6d645d">REQUESTS</text>
985
+ <text x="598" y="397" font-size="40" font-weight="700" fill="#1f1a17">{report.summary.requests}</text>
986
+ <text x="598" y="423" font-size="18" fill="#6d645d">Largest {report.summary.largest_session_tokens:,}</text>
987
+
988
+ <rect x="814" y="314" width="312" height="120" rx="24" fill="url(#amber)"/>
989
+ <text x="838" y="350" font-size="16" letter-spacing="2" fill="#ffedd5">AVG / REQUEST</text>
990
+ <text x="838" y="397" font-size="40" font-weight="700" fill="#ffffff">{report.summary.average_tokens_per_request:,.0f}</text>
991
+ <text x="838" y="423" font-size="18" fill="#ffedd5">{escape(report.insights.suggestion[:34])}</text>
992
+
993
+ <rect x="74" y="466" width="492" height="94" rx="24" fill="#fffaf2" stroke="rgba(66,48,31,0.10)"/>
994
+ <text x="98" y="500" font-size="18" letter-spacing="2" fill="#8a4b0f">ANOMALIES</text>
995
+ <text x="98" y="530" font-size="20" fill="#8a4b0f">• {escape(first_anomaly)}</text>
996
+ <text x="98" y="556" font-size="20" fill="#8a4b0f">• {escape(second_anomaly)}</text>
997
+
998
+ <rect x="592" y="466" width="534" height="94" rx="24" fill="#fffaf2" stroke="rgba(66,48,31,0.10)"/>
999
+ <text x="616" y="500" font-size="18" letter-spacing="2" fill="#4b4036">DO NEXT</text>
1000
+ <text x="616" y="530" font-size="20" fill="#4b4036">• {escape(first_action)}</text>
1001
+ <text x="616" y="556" font-size="20" fill="#4b4036">• {escape(second_action)}</text>
1002
+
1003
+ <text x="1128" y="590" text-anchor="end" font-size="18" fill="#6d645d">Generated by codex-stats</text>
1004
+ </svg>"""
1005
+
1006
+
1007
+ def _wrap_chart_svg(title: str, subtitle: str, chart_svg: str) -> str:
1008
+ chart_body = chart_svg.strip()
1009
+ return f"""<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="720" viewBox="0 0 1200 720" role="img" aria-label="{escape(title)}">
1010
+ <rect width="1200" height="720" fill="#f6efe4"/>
1011
+ <rect x="40" y="36" width="1120" height="648" rx="30" fill="#fffaf2" stroke="rgba(66,48,31,0.10)"/>
1012
+ <text x="72" y="94" font-size="18" letter-spacing="3" fill="#0f766e">CODEX STATS</text>
1013
+ <text x="72" y="148" font-size="42" font-weight="700" fill="#1f1a17">{escape(title)}</text>
1014
+ <text x="72" y="184" font-size="22" fill="#6d645d">{escape(subtitle)}</text>
1015
+ <g transform="translate(72 220)">{chart_body}</g>
1016
+ </svg>"""
979
1017
 
980
1018
 
981
1019
  def format_watch_dashboard(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-stats
3
- Version: 1.4.1
3
+ Version: 1.4.2
4
4
  Summary: Local AI usage observability CLI for Codex sessions.
5
5
  Author: Vivek
6
6
  License: MIT
@@ -110,15 +110,15 @@ python3 -m pip install codex-stats
110
110
  - `codex-stats report weekly --format html`
111
111
  Generate a standalone HTML report for sharing, including inline charts.
112
112
  - `codex-stats report weekly --format svg`
113
- Generate a poster-style SVG asset for sharing in docs, social posts, or release notes. By default it writes `codex-stats-weekly.svg` in the current working directory.
113
+ Generate a bundle of smaller standalone SVG assets in the current working directory, including a summary card plus graph-focused assets.
114
114
  - `codex-stats report weekly --project backend-api`
115
115
  Generate a weekly report for one project.
116
116
  - `codex-stats report weekly --format markdown --output weekly-report.md`
117
117
  Write a formatted report to a file.
118
118
  - `codex-stats report weekly --format html --output weekly-report.html`
119
119
  Write a polished standalone HTML report to a file.
120
- - `codex-stats report weekly --format svg --output weekly-report.svg`
121
- Write a standalone SVG share card to a file.
120
+ - `codex-stats report weekly --format svg --output weekly-report-assets`
121
+ Write the SVG asset bundle into the output directory you provide.
122
122
  - `codex-stats report weekly --format html --render`
123
123
  Generate an HTML report, write it to a temp file, and open it in the default browser.
124
124
  - `codex-stats report weekly --format svg --render`
@@ -227,7 +227,7 @@ The current priority list lives in [docs/roadmap.md](/Users/vivek/Desktop/Salad/
227
227
 
228
228
  ## Shareable Assets
229
229
 
230
- Share-ready assets now live under [docs/assets/codex-stats-share-card.svg](/Users/vivek/Desktop/Salad/codex_stats/docs/assets/codex-stats-share-card.svg), and `report --format svg` can generate poster-style report visuals directly from local usage data.
230
+ Share-ready assets now live under [docs/assets/codex-stats-share-card.svg](/Users/vivek/Desktop/Salad/codex_stats/docs/assets/codex-stats-share-card.svg), and `report --format svg` can generate a full bundle of report assets directly from local usage data.
231
231
 
232
232
  ## Watch Alerts
233
233
 
@@ -8,7 +8,14 @@ from unittest import mock
8
8
 
9
9
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
10
10
 
11
- from codex_stats.cli import _default_report_filename, _open_report_in_browser, _write_report_output, build_parser
11
+ from codex_stats.cli import (
12
+ _default_report_basename,
13
+ _default_report_filename,
14
+ _open_report_in_browser,
15
+ _write_report_output,
16
+ _write_report_svg_assets,
17
+ build_parser,
18
+ )
12
19
 
13
20
 
14
21
  class CliTestCase(unittest.TestCase):
@@ -197,10 +204,14 @@ class CliTestCase(unittest.TestCase):
197
204
  self.assertTrue(output_path.exists())
198
205
  self.assertIn("report.html", str(output_path))
199
206
  with tempfile.TemporaryDirectory() as tmpdir:
200
- with mock.patch("pathlib.Path.cwd", return_value=Path(tmpdir)):
201
- svg_output = _write_report_output("<svg></svg>", None, suffix=".svg", default_name="codex-stats-weekly.svg")
207
+ svg_dir = _write_report_svg_assets(
208
+ {"summary-card": "<svg></svg>", "daily-tokens": "<svg></svg>"},
209
+ tmpdir,
210
+ base_name="codex-stats-weekly",
211
+ )
212
+ self.assertEqual(svg_dir, Path(tmpdir))
213
+ svg_output = Path(tmpdir) / "codex-stats-weekly-summary-card.svg"
202
214
  self.assertTrue(svg_output.exists())
203
- self.assertEqual(svg_output, Path(tmpdir) / "codex-stats-weekly.svg")
204
215
  with mock.patch("codex_stats.cli.webbrowser.open") as open_mock:
205
216
  _open_report_in_browser(svg_output)
206
217
  open_mock.assert_called_once()
@@ -208,6 +219,8 @@ class CliTestCase(unittest.TestCase):
208
219
  def test_default_report_filename(self) -> None:
209
220
  self.assertEqual(_default_report_filename("weekly", None, "svg"), "codex-stats-weekly.svg")
210
221
  self.assertEqual(_default_report_filename("monthly", "Backend API", "svg"), "codex-stats-monthly-backend-api.svg")
222
+ self.assertEqual(_default_report_basename("weekly", None), "codex-stats-weekly")
223
+ self.assertEqual(_default_report_basename("monthly", "Backend API"), "codex-stats-monthly-backend-api")
211
224
 
212
225
 
213
226
  if __name__ == "__main__":
@@ -11,7 +11,7 @@ from pathlib import Path
11
11
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
12
12
 
13
13
  from codex_stats.config import Paths
14
- from codex_stats.display import format_report_html, format_report_svg, format_watch_dashboard
14
+ from codex_stats.display import format_report_html, format_report_svg, format_report_svg_assets, format_watch_dashboard
15
15
  from codex_stats.ingest import get_session, get_session_details
16
16
  from codex_stats.metrics import (
17
17
  apply_watch_state,
@@ -460,10 +460,13 @@ class MetricsTestCase(unittest.TestCase):
460
460
  report = build_report(self.paths, "weekly", now=now)
461
461
  daily_points = summarize_daily(self.paths, days=7, now=now)
462
462
  svg = format_report_svg(report, daily_points=daily_points)
463
+ assets = format_report_svg_assets(report, daily_points=daily_points)
463
464
  self.assertIn("<svg", svg)
464
- self.assertIn("Standalone shareable report asset", svg)
465
- self.assertIn("Top Sessions", svg)
465
+ self.assertIn("Compact share card for release notes, README embeds, and social posts.", svg)
466
+ self.assertIn("ANOMALIES", svg)
466
467
  self.assertIn("Generated by codex-stats", svg)
468
+ self.assertEqual(set(assets), {"summary-card", "daily-tokens", "daily-cost", "project-share", "top-sessions"})
469
+ self.assertIn("Daily Token Trend", assets["daily-tokens"])
467
470
 
468
471
 
469
472
  if __name__ == "__main__":
File without changes
File without changes