#!/usr/bin/env python3 """Render the insights HTML report from stats JSON + LLM narrative JSON. Usage: render.py narrative.json is optional (pass "-" to skip). It may contain: { "summary": str, "highlights": [str], "suggestions": [{"title","detail"}], "friction": [str] } """ import sys, os, json, html def esc(s): return html.escape(str(s if s is not None else "")) DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] def bar_chart(values, labels, color="#7c5cff", height=120): mx = max(values) if values and max(values) > 0 else 1 cells = [] for v, lab in zip(values, labels): h = int((v / mx) * height) if mx else 0 cells.append( f'
{esc(lab)}
' ) return f'
' + "".join(cells) + "
" def stat_card(label, value, sub=""): sub_html = f'
{esc(sub)}
' if sub else "" return ( f'
{esc(value)}
' f'
{esc(label)}
{sub_html}
' ) def fmt_num(n): try: n = float(n) except Exception: return str(n) if n >= 1_000_000_000: return f"{n/1_000_000_000:.1f}B" if n >= 1_000_000: return f"{n/1_000_000:.1f}M" if n >= 1_000: return f"{n/1_000:.1f}K" if n == int(n): return str(int(n)) return f"{n:.2f}" def build(stats, narr): t = stats["totals"] parts = [] A = parts.append A('
') A(f'

pi insights

') A(f'

Last {stats["window_days"]} days · generated {esc(stats["generated_at"][:16].replace("T"," "))} UTC

') if narr.get("summary"): A(f'

{esc(narr["summary"])}

') A("
") # Top stats A('
') A(stat_card("Sessions", fmt_num(t["sessions"]), f'{stats["active_days"]} active days')) A(stat_card("Prompts sent", fmt_num(t["user_msgs"]))) A(stat_card("Assistant turns", fmt_num(t["assistant_msgs"]))) A(stat_card("Tool calls", fmt_num(t["tool_calls"]))) A(stat_card("Total tokens", fmt_num(t["total_tokens"]))) A(stat_card("Total cost", f'${t["cost"]:.2f}')) if stats.get("avg_session_duration_min"): A(stat_card("Avg session", f'{stats["avg_session_duration_min"]:.0f} min')) cache = t["cache_read"] + t["cache_write"] if cache: rate = (t["cache_read"] / cache * 100) if cache else 0 A(stat_card("Cache hit", f'{rate:.0f}%', f'{fmt_num(t["cache_read"])} read')) A("
") return parts def build_charts(stats, narr, parts): A = parts.append # Activity by hour hours = stats["hour_histogram"] hour_labels = [str(h) if h % 3 == 0 else "" for h in range(24)] A('

Activity by hour of day

') A(bar_chart(hours, hour_labels, "#7c5cff")) A("
") # Activity by weekday dow = stats["dow_histogram"] A('

Activity by weekday

') A(bar_chart(dow, DOW, "#21c7a8")) A("
") # Daily daily = stats.get("daily_histogram", {}) if daily: keys = list(daily.keys()) vals = [daily[k] for k in keys] labs = [k[5:] if i % max(1, len(keys)//8) == 0 else "" for i, k in enumerate(keys)] A('

Sessions per day

') A(bar_chart(vals, labs, "#f5a623")) A("
") # Projects table A('

Projects

') A('') for p in stats["projects"][:15]: A(f'' f'') A("
ProjectSessionsPromptsTool callsTokensCost
{esc(p["project"])}{p["sessions"]}{p["user_msgs"]}{p["tool_calls"]}{fmt_num(p["tokens"])}${p["cost"]:.2f}
") # Tool usage tu = stats["tool_usage"] if tu: names = [x["name"] for x in tu] counts = [x["count"] for x in tu] A('

Tool usage

') A(bar_chart(counts, names, "#ff6b9d")) A("
") # Models mu = stats["model_usage"] if mu: A('

Models used

') for m in mu: short = m["model"].split("/")[-1] A(f'') A("
ModelSessions
{esc(short)}{m["sessions"]}
") def build_narrative(stats, narr, parts): A = parts.append if narr.get("highlights"): A('

Highlights

    ') for h in narr["highlights"]: A(f"
  • {esc(h)}
  • ") A("
") if narr.get("friction"): A('

Friction patterns

    ') for h in narr["friction"]: A(f"
  • {esc(h)}
  • ") A("
") if narr.get("suggestions"): A('

Suggestions

') for s in narr["suggestions"]: if isinstance(s, dict): A(f'
{esc(s.get("title",""))}
' f'
{esc(s.get("detail",""))}
') else: A(f'
{esc(s)}
') A("
") # Longest sessions ls = stats.get("longest_sessions", []) if ls: A('

Longest sessions

') A('') for s in ls: A(f'' f'') A("
ProjectStartedDurationPromptsCost
{esc(s["project"])}{esc((s["start"] or "")[:16].replace("T"," "))}{s["duration_min"]:.0f} min{s["user_msgs"]}${s["cost"]:.2f}
") CSS = """ :root { --bg:#0e0e14; --card:#181822; --fg:#e8e8f0; --muted:#9090a8; --accent:#7c5cff; } * { box-sizing:border-box; } body { margin:0; background:var(--bg); color:var(--fg); font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; } .wrap { max-width:1080px; margin:0 auto; padding:32px 20px 80px; } .hero { text-align:center; padding:24px 0 8px; } .hero h1 { font-size:42px; margin:0; background:linear-gradient(90deg,#7c5cff,#21c7a8); -webkit-background-clip:text; background-clip:text; color:transparent; } .hero .sub { color:var(--muted); margin:4px 0 16px; } .hero .summary { max-width:720px; margin:0 auto; font-size:17px; color:#d0d0e0; } .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:14px; margin:28px 0; } .card { background:var(--card); border:1px solid #25252f; border-radius:14px; padding:18px 20px; } .card.wide { margin:18px 0; } .card h2 { font-size:15px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin:0 0 16px; font-weight:600; } .stat { text-align:center; } .stat-val { font-size:30px; font-weight:700; } .stat-lab { color:var(--muted); font-size:13px; margin-top:4px; } .stat-sub { color:#6a6a82; font-size:11px; margin-top:2px; } .bars { display:flex; align-items:flex-end; gap:4px; } .bar-col { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:flex-end; height:100%; } .bar { width:100%; border-radius:4px 4px 0 0; min-height:2px; transition:opacity .15s; } .bar:hover { opacity:.7; } .bar-lab { font-size:10px; color:var(--muted); margin-top:6px; white-space:nowrap; } table { width:100%; border-collapse:collapse; font-size:14px; } th { text-align:left; color:var(--muted); font-weight:600; padding:8px 10px; border-bottom:1px solid #2a2a38; font-size:12px; text-transform:uppercase; } td { padding:8px 10px; border-bottom:1px solid #1f1f29; } tr:hover td { background:#1d1d28; } .narr ul { margin:0; padding-left:20px; } .narr li { margin:8px 0; } .suggestions { display:grid; gap:12px; } .sugg { background:#1f1f2b; border-left:3px solid var(--accent); border-radius:8px; padding:12px 16px; } .sugg-title { font-weight:600; margin-bottom:4px; } .sugg-detail { color:#c0c0d4; font-size:14px; } """ def render(stats, narr): parts = build(stats, narr) build_narrative(stats, narr, parts) build_charts(stats, narr, parts) body = "\n".join(parts) return f""" pi insights
{body}
""" if __name__ == "__main__": stats_path, narr_path, out_path = sys.argv[1], sys.argv[2], sys.argv[3] with open(stats_path) as f: stats = json.load(f) narr = {} if narr_path and narr_path != "-" and os.path.exists(narr_path): try: with open(narr_path) as f: narr = json.load(f) except Exception: narr = {} html_out = render(stats, narr) with open(out_path, "w") as f: f.write(html_out) print(out_path)