Commit 739a394f6535
Changed files (1)
tools
usage-metrics
tools/usage-metrics/usage-report
@@ -7,9 +7,10 @@
usage-report: Generate usage reports from collected metrics.
Reads JSON files from ~/.local/share/usage-metrics/ and produces
-a markdown summary for awareness, pruning, and optimization.
+markdown or HTML reports for awareness, pruning, and optimization.
"""
import argparse
+import html
import json
import os
import sys
@@ -17,19 +18,15 @@ from collections import Counter
from datetime import date, timedelta
from pathlib import Path
-
METRICS_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "usage-metrics"
def load_host_data(days: int) -> dict[str, list[dict]]:
- """Load host metrics for the last N days, grouped by hostname."""
hosts_dir = METRICS_DIR / "hosts"
if not hosts_dir.exists():
return {}
-
cutoff = date.today() - timedelta(days=days)
result: dict[str, list[dict]] = {}
-
for host_dir in hosts_dir.iterdir():
if not host_dir.is_dir():
continue
@@ -48,19 +45,15 @@ def load_host_data(days: int) -> dict[str, list[dict]]:
result[hostname].append(json.loads(f.read_text()))
except (json.JSONDecodeError, OSError):
continue
-
return result
def load_shared_data(days: int) -> list[dict]:
- """Load shared metrics for the last N days."""
shared_dir = METRICS_DIR / "shared"
if not shared_dir.exists():
return []
-
cutoff = date.today() - timedelta(days=days)
result = []
-
for f in sorted(shared_dir.iterdir()):
if not f.name.endswith(".json"):
continue
@@ -74,12 +67,10 @@ def load_shared_data(days: int) -> list[dict]:
result.append(json.loads(f.read_text()))
except (json.JSONDecodeError, OSError):
continue
-
return result
def aggregate_commands(data_list: list[dict], key: str = "shell") -> Counter:
- """Aggregate command counts across multiple days."""
total = Counter()
for data in data_list:
section = data.get(key, {})
@@ -90,13 +81,11 @@ def aggregate_commands(data_list: list[dict], key: str = "shell") -> Counter:
def aggregate_pi(shared_list: list[dict]) -> dict:
- """Aggregate pi session data across days."""
tools = Counter()
skills = Counter()
models = Counter()
providers = Counter()
total_sessions = 0
-
for data in shared_list:
pi = data.get("pi", {})
total_sessions += pi.get("sessions_count", 0)
@@ -104,15 +93,12 @@ def aggregate_pi(shared_list: list[dict]) -> dict:
skills.update(pi.get("skills_loaded", {}))
models.update(pi.get("models_used", {}))
providers.update(pi.get("providers_used", {}))
-
- # Compute never-used from aggregate: skills in directory but never loaded
all_declared = set()
if shared_list:
for data in shared_list:
all_declared.update(data.get("pi", {}).get("skills_never_used", []))
all_declared.update(data.get("pi", {}).get("skills_loaded", {}).keys())
never_used = sorted(s for s in all_declared if s not in skills)
-
return {
"sessions": total_sessions,
"tools": tools,
@@ -123,148 +109,404 @@ def aggregate_pi(shared_list: list[dict]) -> dict:
}
-def format_counter(counter: Counter, limit: int = 20) -> str:
- """Format a counter as a readable list."""
+# ── Markdown report ──────────────────────────────────────────────────
+
+def format_counter_md(counter: Counter, limit: int = 20) -> str:
items = counter.most_common(limit)
if not items:
return " (no data)\n"
+ max_val = items[0][1] if items else 1
max_name = max(len(name) for name, _ in items)
lines = []
for name, count in items:
- bar = "█" * min(count, 50)
- lines.append(f" {name:<{max_name}} {count:>5} {bar}")
+ bar_len = max(1, int(50 * count / max_val))
+ bar = "█" * bar_len
+ lines.append(f" {name:<{max_name}} {count:>7} {bar}")
return "\n".join(lines) + "\n"
-def generate_report(days: int, format: str = "md") -> str:
- """Generate the full usage report."""
- host_data = load_host_data(days)
- shared_data = load_shared_data(days)
-
- lines = []
- lines.append(f"# Usage Report — last {days} days")
- lines.append(f"Generated: {date.today()}\n")
-
- # Per-host sections
+def generate_md(days: int, host_data: dict, shared_data: list) -> str:
+ lines = [f"# Usage Report — last {days} days", f"Generated: {date.today()}\n"]
for hostname, data_list in sorted(host_data.items()):
if not data_list:
continue
-
lines.append(f"## Host: {hostname} ({len(data_list)} days of data)\n")
-
- # Shell commands
shell_cmds = aggregate_commands(data_list, "shell")
total_cmds = sum(shell_cmds.values())
- lines.append(f"### Shell Commands")
- lines.append(f"Total: {total_cmds} commands, {len(shell_cmds)} unique\n")
- lines.append(format_counter(shell_cmds))
-
- # Process accounting
+ lines.append(f"### Shell Commands\nTotal: {total_cmds} commands, {len(shell_cmds)} unique\n")
+ lines.append(format_counter_md(shell_cmds))
acct_cmds = aggregate_commands(data_list, "process_accounting")
if acct_cmds:
- lines.append(f"### Process Accounting (all exec'd binaries)")
- lines.append(f"Total: {sum(acct_cmds.values())} executions, {len(acct_cmds)} unique\n")
- lines.append(format_counter(acct_cmds))
-
- # Nix packages (from most recent snapshot)
+ lines.append(f"### Process Accounting\nTotal: {sum(acct_cmds.values())} executions, {len(acct_cmds)} unique\n")
+ lines.append(format_counter_md(acct_cmds))
latest = data_list[-1]
nix = latest.get("nix_packages", {})
if isinstance(nix, dict) and "total_bins" in nix:
- lines.append(f"### Nix Packages")
- lines.append(f"Installed bins: {nix.get('total_bins', '?')}")
- lines.append(f"Used (today): {nix.get('used_count', '?')}")
- lines.append(f"Unused (today): {nix.get('unused_count', '?')}\n")
-
- # Cross-reference with historical shell usage for better pruning
all_used = set(shell_cmds.keys()) | set(acct_cmds.keys())
unused_bins = set(nix.get("unused_bins", [])) - all_used
- if unused_bins:
- lines.append(f"**Never used in {days} days** ({len(unused_bins)} bins):")
- # Show first 30
- for b in sorted(unused_bins)[:30]:
- lines.append(f" - {b}")
- if len(unused_bins) > 30:
- lines.append(f" ... and {len(unused_bins) - 30} more")
- lines.append("")
-
- # Emacs
+ lines.append(f"### Nix Packages\nInstalled: {nix['total_bins']} | Used: {nix['total_bins'] - len(unused_bins)} | Never used: {len(unused_bins)}\n")
emacs = latest.get("emacs", {})
if isinstance(emacs, dict) and "declared_count" in emacs:
- lines.append(f"### Emacs Packages")
- lines.append(f"Declared: {emacs.get('declared_count', '?')}")
- lines.append(f"Loaded features: {emacs.get('loaded_count', '?')}")
unused_pkgs = emacs.get("unused_packages", [])
- if unused_pkgs:
- lines.append(f"Potentially unused ({len(unused_pkgs)}):")
- for p in sorted(unused_pkgs):
- lines.append(f" - {p}")
- lines.append("")
-
- # Custom tools
+ lines.append(f"### Emacs Packages\nDeclared: {emacs['declared_count']} | Unused: {len(unused_pkgs)}\n")
custom = latest.get("custom_tools", {})
if isinstance(custom, dict) and "defined" in custom:
- lines.append(f"### Custom Tools (from pkgs/)")
- lines.append(f"Defined: {len(custom.get('defined', []))}")
+ used = custom.get("used", [])
+ unused = custom.get("unused", [])
+ lines.append(f"### Custom Tools\nDefined: {len(custom['defined'])} | Used: {len(used)} | Unused: {len(unused)}\n")
+ if shared_data:
+ pi = aggregate_pi(shared_data)
+ lines.append(f"## Pi Agent\nSessions: {pi['sessions']}\n")
+ lines.append("### Tools\n" + format_counter_md(pi["tools"]))
+ lines.append("### Skills\n" + format_counter_md(pi["skills"]))
+ if pi["skills_never_used"]:
+ lines.append(f"### Never Used Skills: {', '.join(pi['skills_never_used'])}\n")
+ lines.append("### Models\n" + format_counter_md(pi["models"]))
+ lines.append("### Providers\n" + format_counter_md(pi["providers"]))
+ return "\n".join(lines)
+
+
+# ── HTML report ──────────────────────────────────────────────────────
+
+HTML_STYLE = """
+:root {
+ --bg: #0d1117; --surface: #161b22; --border: #30363d;
+ --text: #e6edf3; --text-dim: #8b949e; --text-bright: #f0f6fc;
+ --accent: #58a6ff; --green: #3fb950; --red: #f85149;
+ --orange: #d29922; --purple: #bc8cff;
+ --bar-bg: #21262d;
+}
+* { box-sizing: border-box; margin: 0; padding: 0; }
+body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
+ background: var(--bg); color: var(--text); line-height: 1.5; padding: 2rem; max-width: 1200px; margin: 0 auto; }
+h1 { color: var(--text-bright); margin-bottom: 0.5rem; font-size: 1.8rem; }
+h2 { color: var(--accent); margin: 2rem 0 1rem; font-size: 1.4rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }
+h3 { color: var(--text); margin: 1.5rem 0 0.75rem; font-size: 1.1rem; }
+.subtitle { color: var(--text-dim); margin-bottom: 2rem; }
+.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin: 1rem 0; }
+.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; }
+.stat-value { font-size: 1.8rem; font-weight: 700; color: var(--text-bright); }
+.stat-label { font-size: 0.85rem; color: var(--text-dim); }
+.stat-value.green { color: var(--green); }
+.stat-value.red { color: var(--red); }
+.stat-value.orange { color: var(--orange); }
+.stat-value.purple { color: var(--purple); }
+.bar-chart { margin: 0.5rem 0; }
+.bar-row { display: flex; align-items: center; margin: 2px 0; font-size: 0.85rem; font-family: 'SF Mono', 'Fira Code', monospace; }
+.bar-name { width: 220px; text-align: right; padding-right: 12px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; }
+.bar-track { flex: 1; height: 20px; background: var(--bar-bg); border-radius: 3px; overflow: hidden; border: 1px solid var(--border); }
+.bar-fill { display: block; height: 100%; min-width: 2px; }
+.bar-fill.blue { background: var(--accent); }
+.bar-fill.green { background: var(--green); }
+.bar-fill.purple { background: var(--purple); }
+.bar-fill.orange { background: var(--orange); }
+.bar-count { width: 70px; text-align: right; padding-left: 8px; color: var(--text-dim); flex-shrink: 0; }
+details { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; margin: 0.75rem 0; }
+details > summary { padding: 0.75rem 1rem; cursor: pointer; font-weight: 600; color: var(--text); user-select: none; }
+details > summary:hover { color: var(--accent); }
+details > .content { padding: 0 1rem 1rem; }
+.tag-list { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.5rem 0; }
+.tag { background: var(--bar-bg); border: 1px solid var(--border); border-radius: 4px; padding: 2px 8px; font-size: 0.8rem; color: var(--text-dim); }
+.tag.unused { border-color: var(--red); color: var(--red); opacity: 0.8; }
+.tag.used { border-color: var(--green); color: var(--green); }
+.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin: 1.5rem 0 1rem; }
+.tab { padding: 0.5rem 1.2rem; cursor: pointer; color: var(--text-dim); border-bottom: 2px solid transparent; font-weight: 500; }
+.tab:hover { color: var(--text); }
+.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
+.tab-content { display: none; }
+.tab-content.active { display: block; }
+.services-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.3rem; margin: 0.5rem 0; }
+.service-item { font-size: 0.8rem; color: var(--text-dim); font-family: monospace; }
+"""
+
+HTML_SCRIPT = """
+function switchTab(group, tabName) {
+ document.querySelectorAll(`[data-tab-group="${group}"] .tab`).forEach(t => t.classList.remove('active'));
+ document.querySelectorAll(`[data-tab-content="${group}"]`).forEach(c => c.classList.remove('active'));
+ document.querySelector(`[data-tab-group="${group}"] [data-tab="${tabName}"]`).classList.add('active');
+ document.querySelector(`[data-tab-content="${group}"][data-tab="${tabName}"]`).classList.add('active');
+}
+"""
+
+
+def h(text: str) -> str:
+ return html.escape(str(text))
+
+
+def html_bar_chart(counter: Counter, limit: int = 20, color: str = "blue", show_all: bool = False) -> str:
+ items = counter.most_common()
+ if not items:
+ return '<div class="bar-chart"><em style="color:var(--text-dim)">No data</em></div>'
+ max_val = items[0][1]
+ top_items = items[:limit]
+ rest_items = items[limit:]
+
+ def render_rows(rows):
+ out = []
+ for name, count in rows:
+ pct = max(0.5, 100 * count / max_val) if max_val else 0
+ out.append(f'''<div class="bar-row">
+ <span class="bar-name" title="{h(name)}">{h(name)}</span>
+ <span class="bar-track"><span class="bar-fill {color}" style="width:{pct:.1f}%"></span></span>
+ <span class="bar-count">{count:,}</span>
+</div>''')
+ return "\n".join(out)
+
+ result = f'<div class="bar-chart">{render_rows(top_items)}</div>'
+
+ if rest_items:
+ result += f'''<details><summary>Show all ({len(items)} total)</summary>
+<div class="content"><div class="bar-chart">{render_rows(items)}</div></div></details>'''
+
+ return result
+
+
+def html_stat_card(value, label: str, color: str = "") -> str:
+ cls = f" {color}" if color else ""
+ return f'<div class="stat-card"><div class="stat-value{cls}">{h(str(value))}</div><div class="stat-label">{h(label)}</div></div>'
+
+
+def html_tag_list(items: list[str], css_class: str = "") -> str:
+ cls = f" {css_class}" if css_class else ""
+ return '<div class="tag-list">' + "".join(f'<span class="tag{cls}">{h(i)}</span>' for i in sorted(items)) + '</div>'
+
+
+def generate_html(days: int, host_data: dict, shared_data: list) -> str:
+ parts = []
+ parts.append(f'''<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Usage Report — last {days} days</title>
+<style>{HTML_STYLE}</style></head><body>
+<h1>Usage Report</h1>
+<p class="subtitle">Last {days} days · Generated {date.today()}</p>''')
+
+ # ── Aggregate cross-host view ──
+ all_shell = Counter()
+ all_acct = Counter()
+ all_nix_total = 0
+ all_nix_unused = set()
+ all_custom_defined = set()
+ all_custom_used = set()
+ all_services = set()
+ host_count = 0
+
+ for hostname, data_list in host_data.items():
+ if not data_list:
+ continue
+ host_count += 1
+ all_shell += aggregate_commands(data_list, "shell")
+ all_acct += aggregate_commands(data_list, "process_accounting")
+ latest = data_list[-1]
+ nix = latest.get("nix_packages", {})
+ if isinstance(nix, dict) and "total_bins" in nix:
+ all_nix_total = max(all_nix_total, nix.get("total_bins", 0))
+ unused_set = set(nix.get("unused_bins", [])) - set(all_shell.keys()) - set(all_acct.keys())
+ all_nix_unused |= unused_set
+ custom = latest.get("custom_tools", {})
+ if isinstance(custom, dict):
+ all_custom_defined.update(custom.get("defined", []))
+ all_custom_used.update(custom.get("used", []))
+ services = latest.get("services", {})
+ if isinstance(services, dict):
+ all_services.update(services.get("running", []))
+
+ pi = aggregate_pi(shared_data) if shared_data else None
+
+ # ── Overview cards ──
+ parts.append('<h2>Overview</h2>')
+ parts.append('<div class="stats-grid">')
+ parts.append(html_stat_card(host_count, "Hosts reporting"))
+ parts.append(html_stat_card(f"{sum(all_shell.values()):,}", "Shell commands"))
+ parts.append(html_stat_card(len(all_shell), "Unique commands"))
+ parts.append(html_stat_card(f"{sum(all_acct.values()):,}", "Process executions"))
+ if pi:
+ parts.append(html_stat_card(f"{pi['sessions']:,}", "Pi sessions", "purple"))
+ parts.append(html_stat_card(len(all_custom_used), f"Custom tools used", "green"))
+ parts.append(html_stat_card(len(all_custom_defined - all_custom_used), f"Custom tools unused", "red"))
+ parts.append('</div>')
+
+ # ── Tabs: All Hosts / per-host ──
+ tab_names = ["all"] + sorted(host_data.keys())
+ parts.append('<div class="tabs" data-tab-group="hosts">')
+ parts.append(f'<div class="tab active" data-tab="all" onclick="switchTab(\'hosts\',\'all\')">All Hosts</div>')
+ for hn in sorted(host_data.keys()):
+ parts.append(f'<div class="tab" data-tab="{h(hn)}" onclick="switchTab(\'hosts\',\'{h(hn)}\')">{h(hn)}</div>')
+ parts.append('</div>')
+
+ # ── All Hosts tab ──
+ parts.append('<div class="tab-content active" data-tab-content="hosts" data-tab="all">')
+ parts.append('<h3>Shell Commands (all hosts)</h3>')
+ parts.append(html_bar_chart(all_shell, 25, "blue"))
+ if all_acct:
+ parts.append('<h3>Process Accounting (all hosts)</h3>')
+ parts.append(html_bar_chart(all_acct, 25, "green"))
+
+ # Nix packages
+ all_used_bins = set(all_shell.keys()) | set(all_acct.keys())
+ nix_used_count = all_nix_total - len(all_nix_unused) if all_nix_total else 0
+ parts.append('<h3>Nix Packages</h3>')
+ parts.append('<div class="stats-grid">')
+ parts.append(html_stat_card(all_nix_total, "Installed bins"))
+ parts.append(html_stat_card(nix_used_count, "Used (ever)", "green"))
+ parts.append(html_stat_card(len(all_nix_unused), "Never used", "orange"))
+ parts.append('</div>')
+ if all_nix_unused:
+ parts.append(f'<details><summary>Never used bins ({len(all_nix_unused)})</summary><div class="content">')
+ parts.append(html_tag_list(sorted(all_nix_unused), "unused"))
+ parts.append('</div></details>')
+
+ # Custom tools
+ parts.append('<h3>Custom Tools</h3>')
+ if all_custom_used:
+ parts.append(f'<p style="margin:0.5rem 0"><strong>Used:</strong></p>')
+ parts.append(html_tag_list(sorted(all_custom_used), "used"))
+ unused_custom = all_custom_defined - all_custom_used
+ if unused_custom:
+ parts.append(f'<p style="margin:0.5rem 0"><strong>Unused:</strong></p>')
+ parts.append(html_tag_list(sorted(unused_custom), "unused"))
+
+ # Emacs (from any host with data)
+ for hostname, data_list in sorted(host_data.items()):
+ if not data_list:
+ continue
+ emacs = data_list[-1].get("emacs", {})
+ if isinstance(emacs, dict) and emacs.get("declared_count", 0) > 0:
+ unused_pkgs = emacs.get("unused_packages", [])
+ parts.append('<h3>Emacs Packages</h3>')
+ parts.append('<div class="stats-grid">')
+ parts.append(html_stat_card(emacs["declared_count"], "Declared"))
+ parts.append(html_stat_card(emacs.get("loaded_count", 0), "Loaded features", "green"))
+ parts.append(html_stat_card(len(unused_pkgs), "Potentially unused", "orange"))
+ parts.append('</div>')
+ if unused_pkgs:
+ parts.append(f'<details><summary>Potentially unused packages ({len(unused_pkgs)})</summary><div class="content">')
+ parts.append(html_tag_list(unused_pkgs, "unused"))
+ parts.append('</div></details>')
+ cmd_freq = emacs.get("command_frequency", {})
+ if cmd_freq:
+ parts.append('<details><summary>Emacs command frequency</summary><div class="content">')
+ parts.append(html_bar_chart(Counter(cmd_freq), 30, "purple"))
+ parts.append('</div></details>')
+ break # Same emacs config across hosts
+
+ # Services
+ parts.append('<h3>System Services</h3>')
+ parts.append(f'<details><summary>{len(all_services)} unique services across all hosts</summary><div class="content">')
+ parts.append('<div class="services-grid">')
+ for s in sorted(all_services):
+ parts.append(f'<div class="service-item">{h(s)}</div>')
+ parts.append('</div></div></details>')
+
+ parts.append('</div>') # end all-hosts tab
+
+ # ── Per-host tabs ──
+ for hostname, data_list in sorted(host_data.items()):
+ if not data_list:
+ continue
+ parts.append(f'<div class="tab-content" data-tab-content="hosts" data-tab="{h(hostname)}">')
+ parts.append(f'<h3>{h(hostname)} — {len(data_list)} days of data</h3>')
+
+ shell_cmds = aggregate_commands(data_list, "shell")
+ parts.append(f'<h3>Shell Commands ({sum(shell_cmds.values()):,} total, {len(shell_cmds)} unique)</h3>')
+ parts.append(html_bar_chart(shell_cmds, 25, "blue"))
+
+ acct_cmds = aggregate_commands(data_list, "process_accounting")
+ if acct_cmds:
+ parts.append(f'<h3>Process Accounting ({sum(acct_cmds.values()):,} execs, {len(acct_cmds)} unique)</h3>')
+ parts.append(html_bar_chart(acct_cmds, 25, "green"))
+
+ latest = data_list[-1]
+ nix = latest.get("nix_packages", {})
+ if isinstance(nix, dict) and "total_bins" in nix:
+ host_used = set(shell_cmds.keys()) | set(acct_cmds.keys())
+ host_unused = set(nix.get("unused_bins", [])) - host_used
+ parts.append('<h3>Nix Packages</h3>')
+ parts.append('<div class="stats-grid">')
+ parts.append(html_stat_card(nix["total_bins"], "Installed"))
+ parts.append(html_stat_card(nix["total_bins"] - len(host_unused), "Used", "green"))
+ parts.append(html_stat_card(len(host_unused), "Never used", "orange"))
+ parts.append('</div>')
+ if host_unused:
+ parts.append(f'<details><summary>Never used ({len(host_unused)})</summary><div class="content">')
+ parts.append(html_tag_list(sorted(host_unused), "unused"))
+ parts.append('</div></details>')
+
+ custom = latest.get("custom_tools", {})
+ if isinstance(custom, dict) and "defined" in custom:
+ parts.append('<h3>Custom Tools</h3>')
used = custom.get("used", [])
unused = custom.get("unused", [])
if used:
- lines.append(f"Used: {', '.join(sorted(used))}")
+ parts.append(html_tag_list(sorted(used), "used"))
if unused:
- lines.append(f"Unused: {', '.join(sorted(unused))}")
- lines.append("")
+ parts.append(html_tag_list(sorted(unused), "unused"))
- # Services
services = latest.get("services", {})
if isinstance(services, dict) and "running" in services:
- lines.append(f"### System Services")
- lines.append(f"Running: {services.get('total', '?')} services")
- for s in services.get("running", []):
- lines.append(f" - {s}")
- lines.append("")
+ running = services.get("running", [])
+ parts.append(f'<details><summary>Services ({len(running)} running)</summary><div class="content">')
+ parts.append('<div class="services-grid">')
+ for s in running:
+ parts.append(f'<div class="service-item">{h(s)}</div>')
+ parts.append('</div></div></details>')
- # Shared / Pi section
- if shared_data:
- pi = aggregate_pi(shared_data)
- lines.append(f"## Pi Agent (across all hosts)\n")
- lines.append(f"Total sessions: {pi['sessions']}\n")
+ parts.append('</div>') # end host tab
- lines.append("### Tools Used")
- lines.append(format_counter(pi["tools"]))
+ # ── Pi Agent section ──
+ if pi:
+ parts.append('<h2>Pi Agent</h2>')
+ parts.append('<div class="stats-grid">')
+ parts.append(html_stat_card(f"{pi['sessions']:,}", "Sessions"))
+ parts.append(html_stat_card(len(pi['tools']), "Unique tools"))
+ parts.append(html_stat_card(len(pi['skills']), "Skills used"))
+ parts.append(html_stat_card(len(pi['skills_never_used']), "Skills never used", "orange"))
+ parts.append(html_stat_card(len(pi['models']), "Models used"))
+ parts.append(html_stat_card(len(pi['providers']), "Providers"))
+ parts.append('</div>')
- lines.append("### Skills Loaded")
- lines.append(format_counter(pi["skills"]))
+ parts.append('<h3>Tools</h3>')
+ parts.append(html_bar_chart(pi["tools"], 20, "blue"))
+ parts.append('<h3>Skills</h3>')
+ parts.append(html_bar_chart(pi["skills"], 20, "purple"))
if pi["skills_never_used"]:
- lines.append(f"### Skills Never Used ({len(pi['skills_never_used'])})")
- for s in pi["skills_never_used"]:
- lines.append(f" - {s}")
- lines.append("")
+ parts.append(f'<p style="margin:0.5rem 0;color:var(--text-dim)">Never used:</p>')
+ parts.append(html_tag_list(pi["skills_never_used"], "unused"))
- lines.append("### Models Used")
- lines.append(format_counter(pi["models"]))
+ parts.append('<h3>Models</h3>')
+ parts.append(html_bar_chart(pi["models"], 20, "orange"))
- lines.append("### Providers")
- lines.append(format_counter(pi["providers"]))
+ parts.append('<h3>Providers</h3>')
+ parts.append(html_bar_chart(pi["providers"], 15, "green"))
- report = "\n".join(lines)
+ parts.append(f'<script>{HTML_SCRIPT}</script></body></html>')
+ return "\n".join(parts)
- if format == "json":
- # Re-export as structured JSON for LLM consumption
+
+# ── Main ──────────────────────────────────────────────────────────────
+
+def generate_report(days: int, fmt: str) -> str:
+ host_data = load_host_data(days)
+ shared_data = load_shared_data(days)
+ if fmt == "html":
+ return generate_html(days, host_data, shared_data)
+ elif fmt == "json":
return json.dumps({
"period_days": days,
"generated": str(date.today()),
"hosts": {h: d for h, d in host_data.items()},
"shared": shared_data,
}, indent=2, default=str)
-
- return report
+ else:
+ return generate_md(days, host_data, shared_data)
def main():
parser = argparse.ArgumentParser(description="Generate usage report")
parser.add_argument("--days", type=int, default=30, help="Number of days to include (default: 30)")
- parser.add_argument("--format", choices=["md", "json"], default="md", help="Output format")
+ parser.add_argument("--format", choices=["md", "html", "json"], default="md", help="Output format")
parser.add_argument("--output", "-o", type=str, help="Write to file instead of stdout")
+ parser.add_argument("--open", action="store_true", help="Open HTML report in browser")
args = parser.parse_args()
report = generate_report(args.days, args.format)
@@ -272,6 +514,13 @@ def main():
if args.output:
Path(args.output).write_text(report)
print(f"Report written to {args.output}", file=sys.stderr)
+ elif args.open and args.format == "html":
+ import subprocess
+ import tempfile
+ tmp = Path(tempfile.mktemp(suffix=".html", prefix="usage-report-"))
+ tmp.write_text(report)
+ print(f"Report written to {tmp}", file=sys.stderr)
+ subprocess.run(["xdg-open", str(tmp)])
else:
print(report)