Last active
July 20, 2026 04:08
-
-
Save strayge/54b36c29b3d54b943fd401b0c33fb639 to your computer and use it in GitHub Desktop.
Analytics for claude code / opencode / codex
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Report token usage from Claude Code transcripts, Codex CLI rollouts, or OpenCode SQLite.""" | |
| import argparse | |
| import json | |
| import math | |
| import os | |
| import re | |
| import sqlite3 | |
| import sys | |
| from collections import defaultdict | |
| from datetime import datetime, time, timedelta | |
| from pathlib import Path | |
| MODEL_PRICES_PER_MILLION = { | |
| # input, output, cache read, cache write | |
| # Claude cache writes use the 1h-TTL rate (2x input, not the 5m 1.25x): | |
| # Claude Code caches with a 1-hour TTL. | |
| "claude-fable-5": (10.00, 50.00, 1.00, 20.00), | |
| "claude-opus-4-8": (5.00, 25.00, 0.50, 10.00), | |
| "claude-opus-4-7": (5.00, 25.00, 0.50, 10.00), | |
| "claude-opus-4-6": (5.00, 25.00, 0.50, 10.00), | |
| "claude-opus-4-5": (5.00, 25.00, 0.50, 10.00), | |
| "claude-sonnet-5": (2.00, 10.00, 0.20, 4.00), | |
| "claude-sonnet-4-6": (3.00, 15.00, 0.30, 6.00), | |
| "claude-sonnet-4-5": (3.00, 15.00, 0.30, 6.00), | |
| "claude-haiku-4-5": (1.00, 5.00, 0.10, 2.00), | |
| "openai/gpt-5.6-sol": (5.00, 30.00, 0.50, 6.25), | |
| "openai/gpt-5.6-terra": (2.50, 15.00, 0.25, 3.125), | |
| "openai/gpt-5.6-luna": (1.00, 6.00, 0.10, 1.25), | |
| "openai/gpt-5.5": (5.00, 30.00, 0.50, 0.00), | |
| } | |
| def parse_timestamp(value: str | None) -> float | None: | |
| if not value: | |
| return None | |
| try: | |
| return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() | |
| except ValueError: | |
| return None | |
| def parse_bound(value: str, end: bool = False) -> float: | |
| try: | |
| parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) | |
| except ValueError: | |
| raise ValueError(f"Invalid ISO date or timestamp: {value}") from None | |
| if end and len(value) == 10: | |
| parsed = datetime.combine(parsed.date(), time.max, parsed.tzinfo) | |
| return parsed.timestamp() | |
| def parse_range_bound(value: str, end: bool = False) -> float: | |
| if len(value) >= 2 and value[:-1].isdigit() and value[-1].lower() in {"s", "m", "h", "d"}: | |
| return datetime.now().astimezone().timestamp() - parse_interval(value) | |
| return parse_bound(value, end=end) | |
| def parse_interval(value: str) -> int: | |
| units = {"s": 1, "m": 60, "h": 3600, "d": 86400} | |
| if len(value) < 2 or not value[:-1].isdigit() or value[-1].lower() not in units: | |
| raise ValueError(f"Invalid interval: {value} (use values such as 10m, 1h, or 1d)") | |
| seconds = int(value[:-1]) * units[value[-1].lower()] | |
| if not seconds: | |
| raise ValueError("Interval must be greater than zero") | |
| return seconds | |
| def default_interval(duration: float) -> tuple[int, str]: | |
| choices = ( | |
| (20 * 60, "1m"), | |
| (100 * 60, "5m"), | |
| (200 * 60, "10m"), | |
| (10 * 3600, "30m"), | |
| (20 * 3600, "1h"), | |
| (40 * 3600, "2h"), | |
| (5 * 86400, "6h"), | |
| (10 * 86400, "12h"), | |
| (20 * 86400, "1d"), | |
| (40 * 86400, "2d"), | |
| (float("inf"), "5d"), | |
| ) | |
| for maximum, label in choices: | |
| if duration <= maximum: | |
| return parse_interval(label), label | |
| raise AssertionError("unreachable") | |
| def empty_time_stats() -> dict: | |
| return {"text": 0.0, "reasoning": 0.0, "tools": 0.0, "tool_calls": 0, | |
| "sub_calls": 0, "wall": 0.0, "per_model_think": defaultdict(float)} | |
| def usage(calls: list[dict]) -> dict[str, int]: | |
| result = {"calls": len(calls), "input": 0, "cache_read": 0, "cache_write": 0, "output": 0} | |
| for call in calls: | |
| tokens = call["usage"] | |
| result["input"] += tokens.get("input_tokens", 0) | |
| result["cache_read"] += tokens.get("cache_read_input_tokens", 0) | |
| result["cache_write"] += tokens.get("cache_creation_input_tokens", 0) | |
| result["output"] += tokens.get("output_tokens", 0) | |
| result["total"] = sum(result[key] for key in ("input", "cache_read", "cache_write", "output")) | |
| return result | |
| def add_usage(rows: list[dict[str, int]]) -> dict[str, int]: | |
| keys = ("calls", "input", "cache_read", "cache_write", "output", "total") | |
| return {key: sum(row[key] for row in rows) for key in keys} | |
| def cache_hit_rate_rows(session_calls: dict) -> list[list[str]]: | |
| intervals = ( | |
| (0, 60), | |
| (60, 300), | |
| (300, 600), | |
| (600, 1800), | |
| (1800, 3600), | |
| (3600, float("inf")), | |
| ) | |
| models = {call["model"] for calls in session_calls.values() for call in calls} | |
| counts = {model: [[0, 0] for _ in intervals] for model in models} | |
| for calls in session_calls.values(): | |
| ordered = sorted((call for call in calls if call.get("timestamp") is not None), key=lambda call: call["timestamp"]) | |
| for previous, current in zip(ordered, ordered[1:]): | |
| if previous["model"] != current["model"]: | |
| continue | |
| gap = current["timestamp"] - previous["timestamp"] | |
| if gap < 0: | |
| continue | |
| for index, (lower, upper) in enumerate(intervals): | |
| if lower <= gap < upper: | |
| counts[current["model"]][index][1] += 1 | |
| current_usage = current["usage"] | |
| previous_usage = previous["usage"] | |
| cache_read = current_usage.get("cache_read_input_tokens", 0) | |
| previous_context = ( | |
| previous_usage.get("input_tokens", 0) | |
| + previous_usage.get("cache_read_input_tokens", 0) | |
| + previous_usage.get("cache_creation_input_tokens", 0) | |
| + previous_usage.get("output_tokens", 0) | |
| ) | |
| if previous_context and cache_read * 2 >= previous_context: | |
| counts[current["model"]][index][0] += 1 | |
| break | |
| rows = [] | |
| for model in sorted(models): | |
| cells = [] | |
| for hits, total in counts[model]: | |
| cells.append(f"{hits / total * 100:.1f}%" if total else "-") | |
| rows.append([model, *cells]) | |
| return rows | |
| def print_cache_hit_rates(session_calls: dict) -> None: | |
| print("\nCache Hit Rate By Message Gap\n") | |
| print_table( | |
| ["Model", "<1m", "1-5m", "5-10m", "10-30m", "30-60m", "1h+"], | |
| cache_hit_rate_rows(session_calls), | |
| {1, 2, 3, 4, 5, 6}, | |
| ) | |
| def short_id(value: str, limit: int = 10) -> str: | |
| return value if len(value) <= limit else value[:limit] + "…" | |
| def short_title(value: str, limit: int = 45) -> str: | |
| return value if len(value) <= limit else value[: limit - 1] + "…" | |
| def number(value: int) -> str: | |
| return f"{value:,}" | |
| def humanize_seconds(value: float) -> str: | |
| seconds = int(round(value)) | |
| if seconds < 60: | |
| return f"{seconds}s" | |
| if seconds < 3600: | |
| return f"{seconds // 60}m {seconds % 60:02d}s" | |
| if seconds < 86400: | |
| return f"{seconds // 3600}h {(seconds % 3600) // 60:02d}m {seconds % 60:02d}s" | |
| return f"{seconds // 86400}d {(seconds % 86400) // 3600:02d}h {(seconds % 3600) // 60:02d}m {seconds % 60:02d}s" | |
| class Tee: | |
| def __init__(self, *streams): | |
| self.streams = streams | |
| def write(self, data: str) -> None: | |
| for stream in self.streams: | |
| stream.write(data) | |
| def flush(self) -> None: | |
| for stream in self.streams: | |
| stream.flush() | |
| def print_table(headers: list[str], rows: list[list[str]], right_aligned: set[int]) -> None: | |
| widths = [max(len(headers[index]), *(len(row[index]) for row in rows)) for index in range(len(headers))] | |
| def render(row): | |
| return " ".join(value.rjust(widths[index]) if index in right_aligned else value.ljust(widths[index]) | |
| for index, value in enumerate(row)) | |
| print(render(headers)) | |
| print(render(["-" * width for width in widths])) | |
| for row in rows: | |
| print(render(row)) | |
| def token_buckets(calls, since, until, interval): | |
| count = max(1, math.ceil((until - since) / interval)) | |
| buckets = [0] * count | |
| model_buckets = {} | |
| for call in calls: | |
| timestamp = call["timestamp"] | |
| if timestamp is None or not since <= timestamp <= until: | |
| continue | |
| index = min(int((timestamp - since) // interval), count - 1) | |
| tokens = usage([call])["total"] | |
| buckets[index] += tokens | |
| model_buckets.setdefault(call["model"], [0] * count)[index] += tokens | |
| return buckets, model_buckets | |
| def model_symbols(model_buckets): | |
| palette = "█░◆●▲▼◇○■□◉" | |
| models = sorted(model_buckets, key=lambda model: (-sum(model_buckets[model]), model)) | |
| if len(models) > len(palette): | |
| raise SystemExit(f"Too many models to chart: {len(models)}") | |
| return {model: palette[index] for index, model in enumerate(models)} | |
| def print_legend(symbols): | |
| print("\nLegend") | |
| if not symbols: | |
| print(" (no model usage)") | |
| for model, symbol in symbols.items(): | |
| print(f" {symbol} {model}") | |
| def print_timeline(buckets, model_buckets, symbols, since, interval, label): | |
| maximum, width = max(buckets, default=0), 40 | |
| print(f"\nToken Usage Timeline ({label} intervals)\n") | |
| for index, tokens in enumerate(buckets): | |
| bar, cumulative = "", 0 | |
| for model, symbol in symbols.items(): | |
| cumulative += model_buckets[model][index] | |
| target = max(1, round(cumulative / maximum * width)) if cumulative else 0 | |
| bar += symbol * max(0, target - len(bar)) | |
| start = datetime.fromtimestamp(since + index * interval).astimezone() | |
| print(f"{start:%Y-%m-%d %H:%M} {bar:<{width}} {number(tokens)}") | |
| print_legend(symbols) | |
| def compact_number(value): | |
| for threshold, suffix in ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K")): | |
| if value >= threshold: | |
| return f"{value / threshold:.1f}".rstrip("0").rstrip(".") + suffix | |
| return str(round(value)) | |
| def print_chart(buckets, model_buckets, symbols, since, interval, label): | |
| height, maximum = 10, max(buckets, default=0) | |
| scales = [maximum * level / height for level in range(height, 0, -1)] | |
| axis_width = max(len(compact_number(scale)) for scale in scales) | |
| columns = [] | |
| for index in range(len(buckets)): | |
| column, cumulative = [], 0 | |
| for model, symbol in symbols.items(): | |
| cumulative += model_buckets[model][index] | |
| target = math.ceil(cumulative / maximum * height) if cumulative else 0 | |
| column.extend(symbol for _ in range(max(0, target - len(column)))) | |
| columns.append(column) | |
| print(f"\nToken Usage Chart ({label} intervals)\n") | |
| for level, scale in zip(range(height, 0, -1), scales): | |
| bars = "".join(f" {column[level - 1]}" if len(column) >= level else " " for column in columns).rstrip() | |
| print(f"{compact_number(scale) if maximum else '':>{axis_width}} |{bars}") | |
| print(f"{'0':>{axis_width}} +{'--' * len(buckets)}") | |
| first = datetime.fromtimestamp(since).astimezone() | |
| last = datetime.fromtimestamp(since + (len(buckets) - 1) * interval).astimezone() | |
| print(f"{'':>{axis_width + 2}} {first:%m-%d %H:%M} ... {last:%m-%d %H:%M}") | |
| print_legend(symbols) | |
| def limit_window_label(minutes: int) -> str: | |
| if not minutes: | |
| return "?" | |
| if minutes % 1440 == 0: | |
| return f"{minutes // 1440}d" | |
| if minutes % 60 == 0: | |
| return f"{minutes // 60}h" | |
| return f"{minutes}m" | |
| def print_cost_approximation(calls, used, limits=None): | |
| cost, skipped = 0.0, set() | |
| for call in calls: | |
| prices = MODEL_PRICES_PER_MILLION.get(call["model"]) | |
| if prices is None: | |
| skipped.add(call["model"]) | |
| continue | |
| tokens = call["usage"] | |
| counts = (tokens.get("input_tokens", 0), tokens.get("output_tokens", 0), | |
| tokens.get("cache_read_input_tokens", 0), tokens.get("cache_creation_input_tokens", 0)) | |
| cost += sum(count * price for count, price in zip(counts, prices)) / 1_000_000 | |
| print(f"\nCost Approximation\n\nSelected usage cost: ${cost:,.2f}") | |
| if used != "auto": | |
| print(f"Reported usage: {used:g}%\nEstimated 100% value: ${cost * 100 / used:,.2f}") | |
| elif limits: | |
| for name, slot in limits.items(): | |
| first, last = slot["first"], slot["last"] | |
| consumed = slot["consumed"] | |
| label = limit_window_label(last.get("window_minutes")) | |
| if first is last: | |
| span = f"{last.get('used_percent') or 0:g}% used (single snapshot)" | |
| elif slot["resets"]: | |
| plural = "s" if slot["resets"] > 1 else "" | |
| span = f"{consumed:g}% used (across {slot['resets']} window reset{plural})" | |
| else: | |
| span = f"{consumed:g}% used" | |
| estimate = f", estimated 100% is ${cost * 100 / consumed:,.2f}" if consumed > 0 else "" | |
| print(f"{name.capitalize()} limit ({label} window): {span}{estimate}") | |
| else: | |
| print("No provider-reported usage found; showing cost without extrapolation") | |
| if skipped: | |
| print(f"Skipped as free: {', '.join(sorted(skipped))}") | |
| def projects_root(value: str | None) -> Path: | |
| if value: | |
| return Path(value).expanduser() | |
| return Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude")) / "projects" | |
| def read_lines(path: Path) -> list[dict]: | |
| result = [] | |
| with path.open(encoding="utf-8") as handle: | |
| for line in handle: | |
| try: | |
| if line.strip(): | |
| result.append(json.loads(line)) | |
| except json.JSONDecodeError: | |
| continue | |
| return result | |
| def claude_title(lines: list[dict]) -> str: | |
| for entry in reversed(lines): | |
| if entry.get("type") == "ai-title" and entry.get("aiTitle"): | |
| return entry["aiTitle"] | |
| for entry in lines: | |
| content = entry.get("message", {}).get("content") if entry.get("type") == "user" else None | |
| if isinstance(content, str) and content.strip(): | |
| return " ".join(content.split()) | |
| return "(untitled)" | |
| def claude_recent_sessions(root: Path) -> list[dict]: | |
| files = [path for path in root.glob("*/*.jsonl") if path.stat().st_size] | |
| files.sort(key=lambda path: path.stat().st_mtime, reverse=True) | |
| return [{"path": path, "id": path.stem, "project": path.parent.name.split("-")[-1] or path.parent.name, | |
| "title": claude_title(read_lines(path)), "updated": path.stat().st_mtime} for path in files[:10]] | |
| def print_claude_sessions(rows: list[dict], label: str = "Claude Code") -> None: | |
| if not rows: | |
| raise SystemExit(f"No {label} sessions found") | |
| print(f"\nRecent {label} sessions:\n") | |
| for index, row in enumerate(rows, 1): | |
| updated = datetime.fromtimestamp(row["updated"]).astimezone().strftime("%Y-%m-%d %H:%M") | |
| print(f"{index:>2}. {updated} [{row['project']}] {short_title(row['title'])} ({row['id']})") | |
| def choose_claude_session(rows: list[dict], label: str = "Claude Code") -> Path: | |
| print_claude_sessions(rows, label) | |
| while True: | |
| try: | |
| selected = input("\nSelect session [1-10, q to quit]: ").strip() | |
| except (EOFError, KeyboardInterrupt): | |
| print() | |
| raise SystemExit(1) | |
| if selected.lower() in {"q", "quit"}: | |
| raise SystemExit(0) | |
| if selected.isdigit() and 1 <= int(selected) <= len(rows): | |
| return rows[int(selected) - 1]["path"] | |
| print(f"Enter a number from 1 to {len(rows)}.") | |
| def find_claude_session(root: Path, value: str) -> Path: | |
| matches = [path for path in root.glob("*/*.jsonl") if path.stem.startswith(value)] | |
| if not matches: | |
| raise SystemExit(f"Session not found: {value}") | |
| if len(matches) > 1: | |
| raise SystemExit(f"Ambiguous session id {value}:\n" + "\n".join(f" {path}" for path in matches)) | |
| return matches[0] | |
| def claude_subagent_title(path: Path) -> str: | |
| meta_path = path.parent / (path.stem + ".meta.json") | |
| if meta_path.exists(): | |
| try: | |
| meta = json.loads(meta_path.read_text(encoding="utf-8")) | |
| return f"[{meta.get('agentType') or 'agent'}] {meta.get('description') or ''}".strip() | |
| except (json.JSONDecodeError, OSError): | |
| pass | |
| return "(subagent)" | |
| def claude_tree(path: Path, since=None, until=None) -> list[dict]: | |
| result = [] | |
| def visit(file: Path, depth: int, title=None): | |
| all_lines = read_lines(file) | |
| lines = all_lines | |
| if since is not None: | |
| lines = [entry for entry in all_lines | |
| if (timestamp := parse_timestamp(entry.get("timestamp"))) is not None | |
| and since <= timestamp <= until] | |
| result.append({"path": file, "id": file.stem, "depth": depth, | |
| "title": title or claude_title(all_lines), "lines": lines}) | |
| for child in sorted((file.parent / file.stem / "subagents").glob("agent-*.jsonl")): | |
| visit(child, depth + 1, claude_subagent_title(child)) | |
| visit(path, 0) | |
| return result | |
| def claude_sessions_in_range(root: Path, since: float, until: float) -> list[dict]: | |
| result = [] | |
| for path in sorted(root.glob("*/*.jsonl")): | |
| sessions = claude_tree(path, since, until) | |
| if any(session["lines"] for session in sessions): | |
| result.extend(session for session in sessions if session["lines"] or session["depth"] == 0) | |
| return result | |
| def claude_calls(lines: list[dict]) -> list[dict]: | |
| calls_by_id = {} | |
| for entry in lines: | |
| message = entry.get("message", {}) | |
| usage_data = message.get("usage") | |
| model = message.get("model", "unknown") | |
| if entry.get("type") != "assistant" or not usage_data or model == "<synthetic>": | |
| continue | |
| key = entry.get("requestId") or message.get("id") or entry.get("uuid") | |
| calls_by_id[key] = {"id": key or "(unknown)", "model": model, "usage": usage_data, | |
| "timestamp": parse_timestamp(entry.get("timestamp"))} | |
| return list(calls_by_id.values()) | |
| def block_type(entry: dict): | |
| content = entry.get("message", {}).get("content") | |
| if entry.get("type") == "assistant": | |
| if isinstance(content, list) and content: | |
| if content[0].get("type") == "thinking": | |
| return "reasoning" | |
| if content[0].get("type") == "tool_use": | |
| return "tool_use" | |
| return "text" | |
| if entry.get("type") == "user" and isinstance(content, list) and any( | |
| isinstance(item, dict) and item.get("type") == "tool_result" for item in content): | |
| return "tool_result" | |
| return None | |
| def claude_time_stats(lines: list[dict]) -> dict: | |
| stats, timestamps, previous = empty_time_stats(), [], None | |
| for entry in lines: | |
| timestamp = parse_timestamp(entry.get("timestamp")) | |
| if timestamp is None: | |
| continue | |
| timestamps.append(timestamp) | |
| kind = block_type(entry) | |
| delta = max(0.0, timestamp - previous) if previous is not None else 0.0 | |
| if kind in ("text", "reasoning", "tool_use"): | |
| stats["text" if kind == "tool_use" else kind] += delta | |
| model = entry.get("message", {}).get("model", "unknown") | |
| stats["per_model_think"][model] += delta | |
| if kind == "tool_use": | |
| content = entry.get("message", {}).get("content") | |
| name = content[0].get("name", "") if content else "" | |
| stats["sub_calls" if name in ("Task", "Agent") else "tool_calls"] += 1 | |
| elif kind == "tool_result": | |
| stats["tools"] += delta | |
| previous = timestamp | |
| if timestamps: | |
| stats["wall"] = max(timestamps) - min(timestamps) | |
| return stats | |
| def claude_bounds(sessions: list[dict]) -> tuple[float, float]: | |
| timestamps = [timestamp for session in sessions for entry in session["lines"] | |
| if (timestamp := parse_timestamp(entry.get("timestamp"))) is not None] | |
| if not timestamps: | |
| raise SystemExit("Selected session has no timestamped messages") | |
| return min(timestamps), max(timestamps) | |
| CODEX_ROLLOUT_STEM = re.compile(r"^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)$") | |
| def codex_root(value: str | None) -> Path: | |
| if value: | |
| return Path(value).expanduser() | |
| return Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")) / "sessions" | |
| def codex_session_id(path: Path) -> str: | |
| match = CODEX_ROLLOUT_STEM.match(path.stem) | |
| return match.group(1) if match else path.stem | |
| def codex_files(root: Path) -> list[Path]: | |
| return [path for path in root.rglob("rollout-*.jsonl") if path.stat().st_size] | |
| def codex_title(lines: list[dict]) -> str: | |
| for entry in lines: | |
| payload = entry.get("payload") or {} | |
| if entry.get("type") == "event_msg" and payload.get("type") == "user_message": | |
| message = payload.get("message") or "" | |
| if message.strip(): | |
| return " ".join(message.split()) | |
| return "(untitled)" | |
| def codex_project(lines: list[dict]) -> str: | |
| for entry in lines: | |
| if entry.get("type") == "session_meta": | |
| cwd = (entry.get("payload") or {}).get("cwd") or "" | |
| return Path(cwd).name if cwd else "" | |
| return "" | |
| def codex_recent_sessions(root: Path) -> list[dict]: | |
| files = codex_files(root) | |
| files.sort(key=lambda path: path.stat().st_mtime, reverse=True) | |
| result = [] | |
| for path in files[:10]: | |
| lines = read_lines(path) | |
| result.append({"path": path, "id": codex_session_id(path), "project": codex_project(lines), | |
| "title": codex_title(lines), "updated": path.stat().st_mtime}) | |
| return result | |
| def find_codex_session(root: Path, value: str) -> Path: | |
| matches = [path for path in codex_files(root) if codex_session_id(path).startswith(value)] | |
| if not matches: | |
| raise SystemExit(f"Session not found: {value}") | |
| if len(matches) > 1: | |
| raise SystemExit(f"Ambiguous session id {value}:\n" + "\n".join(f" {path}" for path in matches)) | |
| return matches[0] | |
| def codex_tree(path: Path, since=None, until=None) -> list[dict]: | |
| all_lines = read_lines(path) | |
| lines = all_lines | |
| if since is not None: | |
| lines = [entry for entry in all_lines | |
| if (timestamp := parse_timestamp(entry.get("timestamp"))) is not None | |
| and since <= timestamp <= until] | |
| return [{"path": path, "id": codex_session_id(path), "depth": 0, | |
| "title": codex_title(all_lines), "lines": lines}] | |
| def codex_sessions_in_range(root: Path, since: float, until: float) -> list[dict]: | |
| result = [] | |
| for path in sorted(codex_files(root)): | |
| sessions = codex_tree(path, since, until) | |
| if any(session["lines"] for session in sessions): | |
| result.extend(sessions) | |
| return result | |
| def codex_calls(lines: list[dict]) -> list[dict]: | |
| """Turn token_count events into per-call usage records. | |
| Each token_count event carrying an `info` payload corresponds to one API | |
| call; its `last_token_usage` values sum exactly to the session's final | |
| `total_token_usage`. Codex counts cached tokens inside `input_tokens` | |
| (OpenAI convention), so they are subtracted to match the Anthropic-style | |
| split used everywhere else in this script. | |
| """ | |
| result, provider, model = [], "openai", "unknown" | |
| for entry in lines: | |
| payload = entry.get("payload") or {} | |
| kind = entry.get("type") | |
| if kind == "session_meta": | |
| provider = payload.get("model_provider") or provider | |
| elif kind == "turn_context": | |
| model = payload.get("model") or model | |
| elif kind == "event_msg" and payload.get("type") == "token_count": | |
| last = (payload.get("info") or {}).get("last_token_usage") or {} | |
| cached = last.get("cached_input_tokens", 0) | |
| usage_data = {"input_tokens": max(0, last.get("input_tokens", 0) - cached), | |
| "cache_read_input_tokens": cached, | |
| "cache_creation_input_tokens": 0, | |
| "output_tokens": last.get("output_tokens", 0)} | |
| if not any(usage_data.values()): | |
| continue | |
| result.append({"id": f"call-{len(result) + 1}", "model": f"{provider}/{model}", | |
| "usage": usage_data, "timestamp": parse_timestamp(entry.get("timestamp"))}) | |
| return result | |
| def codex_rate_limit_snapshots(lines: list[dict]) -> list[tuple]: | |
| """Collect provider-reported quota snapshots as (timestamp, name, window). | |
| Codex attaches account-wide rolling-window rate limits (primary and, | |
| when the plan has one, secondary) to token_count events. Each snapshot | |
| reflects cumulative usage of its window at that moment, so the percent | |
| consumed within a report's timeframe is the difference between the first | |
| and last snapshot in it. | |
| """ | |
| result = [] | |
| for entry in lines: | |
| payload = entry.get("payload") or {} | |
| if entry.get("type") != "event_msg" or payload.get("type") != "token_count": | |
| continue | |
| rate = payload.get("rate_limits") or {} | |
| timestamp = parse_timestamp(entry.get("timestamp")) or 0 | |
| for name in ("primary", "secondary"): | |
| if rate.get(name): | |
| result.append((timestamp, name, rate[name])) | |
| return result | |
| def codex_quota_usage(snapshots: list[tuple]) -> dict: | |
| """Reduce quota snapshots to the percent consumed per limit window. | |
| used_percent grows within a provider window and drops when the window | |
| resets, so consumption across the whole timeframe is the sum of each | |
| monotonic segment's growth. A segment after a reset counts from zero: | |
| whatever was consumed before its first snapshot still happened inside | |
| the selected range. | |
| """ | |
| limits = {} | |
| for _, name, window in sorted(snapshots, key=lambda snapshot: snapshot[0]): | |
| percent = window.get("used_percent") or 0 | |
| slot = limits.get(name) | |
| if slot is None: | |
| limits[name] = {"first": window, "last": window, "segment_start": percent, | |
| "consumed": 0.0, "resets": 0} | |
| continue | |
| previous = slot["last"].get("used_percent") or 0 | |
| if percent < previous: | |
| slot["consumed"] += previous - slot["segment_start"] | |
| slot["segment_start"] = 0.0 | |
| slot["resets"] += 1 | |
| slot["last"] = window | |
| for slot in limits.values(): | |
| slot["consumed"] += (slot["last"].get("used_percent") or 0) - slot["segment_start"] | |
| return limits | |
| def codex_block_type(entry: dict): | |
| if entry.get("type") != "response_item": | |
| return None | |
| payload = entry.get("payload") or {} | |
| kind = payload.get("type") or "" | |
| if kind == "reasoning": | |
| return "reasoning" | |
| if kind == "message": | |
| return "text" if payload.get("role") == "assistant" else None | |
| # Covers custom_tool_call, function_call, local_shell_call, and their outputs. | |
| if kind.endswith("_call_output"): | |
| return "tool_result" | |
| if kind.endswith("_call"): | |
| return "tool_use" | |
| return None | |
| def codex_time_stats(lines: list[dict]) -> dict: | |
| stats, timestamps, previous = empty_time_stats(), [], None | |
| provider, model = "openai", "unknown" | |
| for entry in lines: | |
| payload = entry.get("payload") or {} | |
| if entry.get("type") == "session_meta": | |
| provider = payload.get("model_provider") or provider | |
| elif entry.get("type") == "turn_context": | |
| model = payload.get("model") or model | |
| timestamp = parse_timestamp(entry.get("timestamp")) | |
| if timestamp is None: | |
| continue | |
| timestamps.append(timestamp) | |
| kind = codex_block_type(entry) | |
| delta = max(0.0, timestamp - previous) if previous is not None else 0.0 | |
| if kind in ("text", "reasoning", "tool_use"): | |
| stats["text" if kind == "tool_use" else kind] += delta | |
| stats["per_model_think"][f"{provider}/{model}"] += delta | |
| if kind == "tool_use": | |
| stats["tool_calls"] += 1 | |
| elif kind == "tool_result": | |
| stats["tools"] += delta | |
| previous = timestamp | |
| if timestamps: | |
| stats["wall"] = max(timestamps) - min(timestamps) | |
| return stats | |
| def database_path(value: str | None) -> Path: | |
| data = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "opencode" | |
| configured = value or os.environ.get("OPENCODE_DB") | |
| if configured: | |
| path = Path(configured).expanduser() | |
| return path if path.is_absolute() else data / path | |
| default = data / "opencode.db" | |
| if default.exists(): | |
| return default | |
| candidates = sorted(data.glob("opencode*.db"), key=lambda path: path.stat().st_mtime, reverse=True) | |
| return candidates[0] if candidates else default | |
| def open_database(path: Path) -> sqlite3.Connection: | |
| if not path.is_file(): | |
| raise SystemExit(f"OpenCode database not found: {path}") | |
| connection = sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True) | |
| connection.row_factory = sqlite3.Row | |
| return connection | |
| def table_exists(connection: sqlite3.Connection, name: str) -> bool: | |
| return connection.execute( | |
| "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", (name,) | |
| ).fetchone() is not None | |
| def session_rows(connection: sqlite3.Connection) -> list[dict]: | |
| return [dict(row) for row in connection.execute( | |
| "SELECT id, parent_id, title, directory, time_created, time_updated FROM session" | |
| ).fetchall()] | |
| def recent_sessions(connection: sqlite3.Connection) -> list[dict]: | |
| rows = sorted((row for row in session_rows(connection) if row["parent_id"] is None), | |
| key=lambda row: row["time_updated"], reverse=True)[:10] | |
| return [{"id": row["id"], "title": row["title"] or "(untitled)", | |
| "project": row["directory"] or "", "updated": row["time_updated"] / 1000} | |
| for row in rows] | |
| def choose_session(rows: list[dict]) -> str: | |
| print_sessions(rows) | |
| while True: | |
| try: | |
| selected = input("\nSelect session [1-10, q to quit]: ").strip() | |
| except (EOFError, KeyboardInterrupt): | |
| print() | |
| raise SystemExit(1) | |
| if selected.lower() in {"q", "quit"}: | |
| raise SystemExit(0) | |
| if selected.isdigit() and 1 <= int(selected) <= len(rows): | |
| return rows[int(selected) - 1]["id"] | |
| print(f"Enter a number from 1 to {len(rows)}.") | |
| def print_sessions(rows: list[dict]) -> None: | |
| if not rows: | |
| raise SystemExit("No OpenCode sessions found") | |
| print("\nRecent OpenCode sessions:\n") | |
| for index, row in enumerate(rows, 1): | |
| updated = datetime.fromtimestamp(row["updated"]).astimezone().strftime("%Y-%m-%d %H:%M") | |
| print(f"{index:>2}. {updated} {row['title']} ({row['id']})") | |
| def find_session(connection: sqlite3.Connection, value: str) -> str: | |
| rows = connection.execute("SELECT id FROM session WHERE id LIKE ?", (value + "%",)).fetchall() | |
| if not rows: | |
| raise SystemExit(f"Session not found: {value}") | |
| if len(rows) > 1: | |
| raise SystemExit(f"Ambiguous session id {value}:\n" + "\n".join(f" {row['id']}" for row in rows)) | |
| return rows[0]["id"] | |
| def tree(connection: sqlite3.Connection, root: str) -> list[dict]: | |
| rows = connection.execute(""" | |
| WITH RECURSIVE tree(id, depth) AS ( | |
| SELECT id, 0 FROM session WHERE id = ? | |
| UNION ALL | |
| SELECT session.id, tree.depth + 1 FROM session JOIN tree ON session.parent_id = tree.id | |
| ) | |
| SELECT session.*, tree.depth FROM session JOIN tree ON session.id = tree.id | |
| ORDER BY tree.depth, session.time_created | |
| """, (root,)).fetchall() | |
| return [{"path": row["id"], "id": row["id"], "depth": row["depth"], | |
| "title": row["title"] or "(untitled)", "row": dict(row)} for row in rows] | |
| def messages(connection: sqlite3.Connection, session_ids: list[str], since=None, until=None) -> list[dict]: | |
| if not session_ids or not table_exists(connection, "message"): | |
| return [] | |
| marks = ",".join("?" for _ in session_ids) | |
| result = [] | |
| for row in connection.execute( | |
| f"SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id IN ({marks})", | |
| session_ids): | |
| try: | |
| data = json.loads(row["data"]) | |
| except (TypeError, json.JSONDecodeError): | |
| continue | |
| timestamp = row["time_created"] / 1000 | |
| if data.get("role") != "assistant" or "tokens" not in data: | |
| continue | |
| if since is not None and not since <= timestamp <= until: | |
| continue | |
| result.append({"id": row["id"], "sessionID": row["session_id"], | |
| "time_created": row["time_created"], "time_updated": row["time_updated"], **data}) | |
| return result | |
| def sessions_in_range(connection: sqlite3.Connection, since: float, until: float) -> list[dict]: | |
| rows = session_rows(connection) | |
| by_id = {row["id"]: row for row in rows} | |
| matching = {message["sessionID"] for message in messages(connection, list(by_id), since, until)} | |
| selected = set(matching) | |
| for session_id in matching: | |
| row = by_id.get(session_id) | |
| while row and row["parent_id"]: | |
| selected.add(row["parent_id"]) | |
| row = by_id.get(row["parent_id"]) | |
| result = [] | |
| for row in sorted((by_id[value] for value in selected), key=lambda item: (item["time_created"], item["id"])): | |
| depth, parent = 0, row["parent_id"] | |
| while parent in selected: | |
| depth += 1 | |
| parent = by_id[parent]["parent_id"] | |
| result.append({"path": row["id"], "id": row["id"], "depth": depth, | |
| "title": row["title"] or "(untitled)", "row": row}) | |
| return result | |
| def model_name(message: dict) -> str: | |
| return f"{message.get('providerID', 'unknown')}/{message.get('modelID', 'unknown')}" | |
| def calls(messages: list[dict]) -> list[dict]: | |
| result = [] | |
| for message in messages: | |
| tokens = message["tokens"] | |
| cache = tokens.get("cache") or {} | |
| usage = {"input_tokens": tokens.get("input", 0), | |
| "cache_read_input_tokens": cache.get("read", 0), | |
| "cache_creation_input_tokens": cache.get("write", 0), | |
| "output_tokens": tokens.get("output", 0) + tokens.get("reasoning", 0)} | |
| result.append({"id": message["id"], "model": model_name(message), "usage": usage, | |
| "timestamp": message["time_created"] / 1000}) | |
| return result | |
| def opencode_time_stats(connection, assistant_messages: list[dict], since, until) -> dict: | |
| stats = {message["sessionID"]: empty_time_stats() for message in assistant_messages} | |
| if not assistant_messages or not table_exists(connection, "part"): | |
| return stats | |
| by_id = {message["id"]: message for message in assistant_messages} | |
| marks = ",".join("?" for _ in by_id) | |
| low = since * 1000 if since is not None else None | |
| high = until * 1000 if until is not None else None | |
| for row in connection.execute( | |
| f"SELECT message_id, time_created, time_updated, data FROM part WHERE message_id IN ({marks})", | |
| list(by_id)): | |
| message = by_id[row["message_id"]] | |
| start, end = row["time_created"], max(row["time_created"], row["time_updated"]) | |
| if low is not None: | |
| start = max(start, low) | |
| if high is not None: | |
| end = min(end, high) | |
| if end <= start: | |
| continue | |
| try: | |
| data = json.loads(row["data"]) | |
| except (TypeError, json.JSONDecodeError): | |
| continue | |
| current, duration = stats[message["sessionID"]], (end - start) / 1000 | |
| if data.get("type") == "text": | |
| current["text"] += duration | |
| current["per_model_think"][model_name(message)] += duration | |
| elif data.get("type") == "reasoning": | |
| current["reasoning"] += duration | |
| current["per_model_think"][model_name(message)] += duration | |
| elif data.get("type") == "tool": | |
| current["tools"] += duration | |
| current["sub_calls" if data.get("tool") == "task" else "tool_calls"] += 1 | |
| current.setdefault("intervals", []).append((start, end)) | |
| for current in stats.values(): | |
| intervals = current.pop("intervals", []) | |
| if intervals: | |
| current["wall"] = (max(end for _, end in intervals) - min(start for start, _ in intervals)) / 1000 | |
| return stats | |
| def opencode_bounds(connection: sqlite3.Connection, session_ids: list[str]) -> tuple[float, float]: | |
| marks = ",".join("?" for _ in session_ids) | |
| row = connection.execute( | |
| f"SELECT MIN(time_created), MAX(time_updated) FROM message WHERE session_id IN ({marks})", | |
| session_ids, | |
| ).fetchone() | |
| if not row or row[0] is None: | |
| raise SystemExit("Selected session has no timestamped messages") | |
| return row[0] / 1000, row[1] / 1000 | |
| def render(location, path, args, sessions, session_calls, session_times, all_calls, since, until, interval, limits=None): | |
| interval_report = args.timeline or args.chart | |
| grouped = defaultdict(list) | |
| for values in session_calls.values(): | |
| for call in values: | |
| grouped[call["model"]].append(call) | |
| think = defaultdict(float) | |
| for stats in session_times.values(): | |
| for model, seconds in stats["per_model_think"].items(): | |
| think[model] += seconds | |
| model_rows, model_totals = [], [] | |
| keys = ("calls", "input", "cache_read", "cache_write", "output", "total") | |
| for model, values in grouped.items(): | |
| totals = usage(values) | |
| model_totals.append(totals) | |
| model_rows.append([model, humanize_seconds(think[model]), *[number(totals[key]) for key in keys]]) | |
| model_rows.sort(key=lambda row: int(row[-1].replace(",", "")), reverse=True) | |
| totals = add_usage(model_totals) | |
| model_rows.append(["TOTAL", humanize_seconds(sum(think.values())), *[number(totals[key]) for key in keys]]) | |
| per_session, session_rows, message_rows = [], [], [] | |
| for session in sessions: | |
| values, totals, stats = session_calls[session["path"]], usage(session_calls[session["path"]]), session_times[session["path"]] | |
| per_session.append(totals) | |
| models = sorted({call["model"] for call in values}) | |
| session_rows.append([f"{' ' * session['depth']}{short_title(session['title'])} ({short_id(session['id'])})", | |
| ", ".join(models) or "-", humanize_seconds(stats["text"] + stats["reasoning"]), | |
| *[number(totals[key]) for key in keys]]) | |
| for call in values: | |
| call_totals = usage([call]) | |
| timestamp = call.get("timestamp") | |
| updated = datetime.fromtimestamp(timestamp).astimezone().strftime("%Y-%m-%d %H:%M:%S") if timestamp is not None else "-" | |
| message_rows.append((timestamp or 0, [ | |
| updated, | |
| short_id(str(call.get("id", "(unknown)")), 18), | |
| f"{short_title(session['title'], 30)} ({short_id(session['id'])})", | |
| call["model"], | |
| *[number(call_totals[key]) for key in ("input", "cache_read", "cache_write", "output", "total")], | |
| ])) | |
| total = add_usage(per_session) | |
| session_rows.append(["TOTAL", "", humanize_seconds(sum(stats["text"] + stats["reasoning"] for stats in session_times.values())), *[number(total[key]) for key in keys]]) | |
| print(f"\n{location}: {path}") | |
| if since is not None: | |
| roots = sum(session["depth"] == 0 for session in sessions) | |
| print(f"Range: {args.since} through {args.until}") | |
| if not interval_report: | |
| print(f"Sessions: {roots} roots, {len(sessions) - roots} subagents") | |
| else: | |
| print(f"Session: {sessions[0]['title']}\nID: {sessions[0]['id']}\nTree: {len(sessions)} sessions ({len(sessions) - 1} descendants)") | |
| if interval_report: | |
| buckets, model_buckets = token_buckets(all_calls, since, until, interval) | |
| symbols = model_symbols(model_buckets) | |
| if args.timeline: | |
| print_timeline(buckets, model_buckets, symbols, since, interval, args.interval) | |
| if args.chart: | |
| print_chart(buckets, model_buckets, symbols, since, interval, args.interval) | |
| if args.models: | |
| print("\nUsage By Model\n") | |
| print_table(["Model", "Think", "Calls", "Input", "Cache read", "Cache write", "Output", "Total"], model_rows, {1, 2, 3, 4, 5, 6, 7}) | |
| if args.messages: | |
| rows = [row for _, row in sorted(message_rows, key=lambda item: item[0])] | |
| rows.append(["TOTAL", "", "", "", *[number(total[key]) for key in ("input", "cache_read", "cache_write", "output", "total")]]) | |
| print("\nUsage By Message\n") | |
| print_table( | |
| ["Time", "Message", "Session", "Model", "Input", "Cache read", "Cache write", "Output", "Total"], | |
| rows, | |
| {4, 5, 6, 7, 8}, | |
| ) | |
| if args.sessions: | |
| print("\nUsage By Session\n") | |
| print_table(["Session", "Model", "Think", "Calls", "Input", "Cache read", "Cache write", "Output", "Total"], session_rows, {2, 3, 4, 5, 6, 7, 8}) | |
| if args.cache: | |
| print_cache_hit_rates(session_calls) | |
| if not interval_report: | |
| input_tokens = total["input"] + total["cache_read"] + total["cache_write"] | |
| print(f"\nTotals\n\nInput: {number(total['input'])}\nCached input: {number(total['cache_read'])}\nCache writes: {number(total['cache_write'])}\nOutput: {number(total['output'])}\nOverall tokens: {number(total['total'])}\nInput cache rate: {(total['cache_read'] / input_tokens * 100 if input_tokens else 0):.2f}%") | |
| if args.usage is not None: | |
| print_cost_approximation(all_calls, args.usage, limits) | |
| if args.time: | |
| rows, total_text, total_reason, total_tools = [], 0.0, 0.0, 0.0 | |
| sub_calls = tool_calls = 0 | |
| for session in sessions: | |
| stats = session_times[session["path"]] | |
| covered = stats["text"] + stats["reasoning"] + stats["tools"] | |
| total_text += stats["text"] | |
| total_reason += stats["reasoning"] | |
| total_tools += stats["tools"] | |
| sub_calls += stats["sub_calls"] | |
| tool_calls += stats["tool_calls"] | |
| rows.append([f"{' ' * session['depth']}{short_title(session['title'])} ({short_id(session['id'])})", humanize_seconds(stats["text"]), humanize_seconds(stats["reasoning"]), f"{humanize_seconds(stats['tools'])} ({stats['tool_calls']})", str(stats["sub_calls"]), humanize_seconds(stats["wall"]), humanize_seconds(max(0, stats["wall"] - covered))]) | |
| rows.append(["TOTAL", humanize_seconds(total_text), humanize_seconds(total_reason), f"{humanize_seconds(total_tools)} ({tool_calls})", str(sub_calls), "-", "-"]) | |
| print("\nTime Breakdown By Session\n") | |
| print_table(["Session", "text", "reasoning", "tools", "subagents", "wall", "unaccounted"], rows, {1, 2, 3, 4, 5, 6}) | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Show token usage from Claude Code or OpenCode") | |
| source = parser.add_mutually_exclusive_group(required=True) | |
| source.add_argument("--claude", action="store_true", help="Read Claude Code JSONL transcripts") | |
| source.add_argument("--codex", action="store_true", help="Read Codex CLI JSONL rollouts") | |
| source.add_argument("--opencode", action="store_true", help="Read the OpenCode SQLite database") | |
| parser.add_argument("--dir", help="Claude projects or Codex sessions directory") | |
| parser.add_argument("--db", help="OpenCode SQLite database path") | |
| parser.add_argument("-s", "--session", help="Session ID or unique prefix") | |
| parser.add_argument("--since", help="Start of range (ISO value or relative duration such as 1d)") | |
| parser.add_argument("--until", help="End of range (ISO value or duration ago; default: one hour from now)") | |
| parser.add_argument("--timeline", action="store_true", help="Show horizontal token bars by interval") | |
| parser.add_argument("--chart", action="store_true", help="Show a vertical token chart by interval") | |
| parser.add_argument("--interval", help="Override the automatic chart interval, such as 10m, 1h, or 1d") | |
| parser.add_argument("--usage", nargs="?", const="auto", metavar="PERCENT", | |
| help="Estimate 100%% value from quota usage (omit the percent to use provider-reported usage)") | |
| parser.add_argument("--messages", action="store_true", help="Show usage per API message") | |
| parser.add_argument("--sessions", action="store_true", help="Show usage per session") | |
| parser.add_argument("--models", action="store_true", help="Show usage per model") | |
| parser.add_argument("--cache", action="store_true", help="Show cache hit rates by message gap") | |
| parser.add_argument("-t", "--time", action="store_true", help="Show time breakdown by session") | |
| parser.add_argument("-e", "--export", metavar="FILENAME", help="Also save the report to a file") | |
| parser.add_argument("-l", "--list", action="store_true", help="List recent sessions and exit") | |
| args = parser.parse_args() | |
| if args.usage not in (None, "auto"): | |
| try: | |
| args.usage = float(args.usage) | |
| except ValueError: | |
| parser.error(f"--usage expects a number, got: {args.usage}") | |
| if not 0 < args.usage <= 100: | |
| parser.error("--usage must be greater than 0 and no more than 100") | |
| if args.since and not args.until: | |
| args.until = (datetime.now().astimezone() + timedelta(hours=1)).isoformat(timespec="seconds") | |
| interval_report = args.timeline or args.chart | |
| if args.session and (args.since or args.until): | |
| parser.error("--session cannot be combined with --since/--until") | |
| if interval_report and not args.session and not any((args.since, args.until, args.interval)): | |
| now = datetime.now().astimezone() | |
| start = datetime.combine(now.date() - timedelta(days=1), time.min, now.tzinfo) | |
| args.since, args.until = start.isoformat(timespec="minutes"), now.isoformat(timespec="minutes") | |
| elif interval_report and not args.session and not (args.since and args.until): | |
| parser.error("timeline/chart mode requires --since (or --session)") | |
| elif not interval_report and bool(args.since) != bool(args.until): | |
| parser.error("--since and --until must be provided together") | |
| if args.interval and not interval_report: | |
| parser.error("--interval requires --timeline or --chart") | |
| try: | |
| since = parse_range_bound(args.since) if args.since else None | |
| until = parse_range_bound(args.until, end=True) if args.until else None | |
| interval = parse_interval(args.interval) if args.interval else None | |
| except ValueError as error: | |
| parser.error(str(error)) | |
| if since is not None and since > until: | |
| parser.error("--since must not be later than --until") | |
| if interval_report and since is not None and interval is None: | |
| interval, args.interval = default_interval(until - since) | |
| if args.claude or args.codex: | |
| if args.db: | |
| parser.error("--db is only valid with --opencode") | |
| if args.claude: | |
| label, root = "Claude Code", projects_root(args.dir) | |
| recent_fn, find_fn, tree_fn = claude_recent_sessions, find_claude_session, claude_tree | |
| range_fn, calls_fn, times_fn = claude_sessions_in_range, claude_calls, claude_time_stats | |
| else: | |
| label, root = "Codex", codex_root(args.dir) | |
| recent_fn, find_fn, tree_fn = codex_recent_sessions, find_codex_session, codex_tree | |
| range_fn, calls_fn, times_fn = codex_sessions_in_range, codex_calls, codex_time_stats | |
| if not root.is_dir(): | |
| raise SystemExit(f"{label} sessions directory not found: {root}") | |
| if args.list: | |
| print_claude_sessions(recent_fn(root), label) | |
| return | |
| selected = find_fn(root, args.session) if args.session else ( | |
| None if since is not None else choose_claude_session(recent_fn(root), label)) | |
| sessions = range_fn(root, since, until) if since is not None else tree_fn(selected) | |
| if interval_report and args.session: | |
| since, until = claude_bounds(sessions) | |
| args.since = datetime.fromtimestamp(since).astimezone().isoformat(timespec="seconds") | |
| args.until = datetime.fromtimestamp(until).astimezone().isoformat(timespec="seconds") | |
| if interval is None: | |
| interval, args.interval = default_interval(until - since) | |
| if not sessions and not interval_report: | |
| raise SystemExit(f"No {label} messages found in the specified range") | |
| export = None | |
| try: | |
| if args.export: | |
| export = open(Path(args.export).expanduser(), "w", encoding="utf-8") | |
| sys.stdout = Tee(sys.stdout, export) | |
| session_calls = {session["path"]: calls_fn(session["lines"]) for session in sessions} | |
| session_times = {session["path"]: times_fn(session["lines"]) for session in sessions} | |
| all_calls = [call for values in session_calls.values() for call in values] | |
| limits = None | |
| if args.codex and args.usage == "auto": | |
| limits = codex_quota_usage([snapshot for session in sessions | |
| for snapshot in codex_rate_limit_snapshots(session["lines"])]) | |
| report_path = root if selected is None else selected.parent | |
| render("Directory", report_path, args, sessions, session_calls, session_times, | |
| all_calls, since, until, interval, limits) | |
| finally: | |
| if export is not None: | |
| sys.stdout = sys.__stdout__ | |
| export.close() | |
| return | |
| if args.dir: | |
| parser.error("--dir is only valid with --claude or --codex") | |
| path = database_path(args.db) | |
| connection = open_database(path) | |
| export = None | |
| try: | |
| if args.list: | |
| print_sessions(recent_sessions(connection)) | |
| return | |
| root = find_session(connection, args.session) if args.session else (None if since is not None else choose_session(recent_sessions(connection))) | |
| sessions = sessions_in_range(connection, since, until) if since is not None else tree(connection, root) | |
| ids = [session["id"] for session in sessions] | |
| if interval_report and args.session: | |
| since, until = opencode_bounds(connection, ids) | |
| args.since = datetime.fromtimestamp(since).astimezone().isoformat(timespec="seconds") | |
| args.until = datetime.fromtimestamp(until).astimezone().isoformat(timespec="seconds") | |
| if interval is None: | |
| interval, args.interval = default_interval(until - since) | |
| assistant_messages = messages(connection, ids, since, until) | |
| if not sessions and not interval_report: | |
| raise SystemExit("No OpenCode messages found in the specified range") | |
| if args.export: | |
| export = open(Path(args.export).expanduser(), "w", encoding="utf-8") | |
| sys.stdout = Tee(sys.stdout, export) | |
| by_session = defaultdict(list) | |
| for message in assistant_messages: | |
| by_session[message["sessionID"]].append(message) | |
| session_calls = {session["path"]: calls(by_session[session["id"]]) for session in sessions} | |
| raw_times = opencode_time_stats(connection, assistant_messages, since, until) | |
| session_times = {session["path"]: raw_times.get(session["id"], empty_time_stats()) for session in sessions} | |
| all_calls = [call for values in session_calls.values() for call in values] | |
| render("Database", path, args, sessions, session_calls, session_times, all_calls, since, until, interval) | |
| finally: | |
| if export is not None: | |
| sys.stdout = sys.__stdout__ | |
| export.close() | |
| connection.close() | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment