ai-usage-analyzer 0.1.0 → 0.2.1

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.
package/README.md CHANGED
@@ -17,166 +17,62 @@ TUI analyzer for local AI coding agent token consumption. Auto-detects
17
17
 
18
18
  Requires **Node.js ≥ 22.5** (for built-in `node:sqlite`).
19
19
 
20
- Pick whichever fits your workflow:
21
-
22
- ### Option 1: `npx` from GitHub (zero install, fastest to try)
23
-
24
20
  ```bash
21
+ # Try it (no install)
25
22
  npx -y github:adetxt/ai-usage-analyzer
26
- # or with a specific ref
27
- npx -y github:adetxt/ai-usage-analyzer#v0.1.0
28
- ```
29
23
 
30
- No clone, no `node_modules`, no global install. npx downloads the repo
31
- on first run and caches it. Subsequent runs are instant.
32
-
33
- ### Option 2: Clone + install (best for development / contribution)
34
-
35
- ```bash
36
- git clone https://github.com/adetxt/ai-usage-analyzer.git
37
- cd ai-usage-analyzer
38
- pnpm install
39
- pnpm link --global # exposes the `ai-usage` command globally
40
- ```
41
-
42
- > Uses `pnpm` (declared in the `packageManager` field). `pnpm-lock.yaml`
43
- > is committed as the source of truth for reproducible installs.
44
-
45
- ### Option 3: Install globally from GitHub (no clone)
46
-
47
- ```bash
24
+ # Or install globally
48
25
  pnpm add -g git+https://github.com/adetxt/ai-usage-analyzer.git
49
- # or with npm
50
- npm install -g git+https://github.com/adetxt/ai-usage-analyzer.git
51
- # then
52
- ai-usage
53
- ```
54
-
55
- ### Option 4: From npm registry (when published)
56
-
57
- ```bash
58
- npx -y ai-usage-analyzer
59
- # or
60
- pnpm add -g ai-usage-analyzer
61
- ```
62
-
63
- To publish your own copy: `npm login && npm publish --access public`
64
- (requires the `ai-usage-analyzer` name to be available on npmjs.com,
65
- or use a scoped name like `@yourname/ai-usage-analyzer` and update
66
- the `name` field in `package.json` first).
67
-
68
- ### Verify install
69
-
70
- ```bash
71
26
  ai-usage --help
72
- # or via npx
73
- npx -y github:adetxt/ai-usage-analyzer --help
74
27
  ```
75
28
 
76
29
  ## Usage
77
30
 
78
31
  ```bash
79
- ai-usage # default TUI
80
- ai-usage --top 10 # show top 10 heaviest sessions
81
- ai-usage --json # machine-readable JSON output
82
- ai-usage --help
32
+ ai-usage # default TUI
33
+ ai-usage --top 10 # show top 10 heaviest sessions
34
+ ai-usage --json # machine-readable JSON
35
+ ai-usage --md > report.md # save as markdown
83
36
  ```
84
37
 
85
- ### Environment overrides
38
+ ## Supported tools
86
39
 
87
- If a tool's data lives outside the default location, override its base path:
40
+ | Tool | Default path | Tokens |
41
+ |---|---|---|
42
+ | Claude Code | `~/.claude/projects` | presence only |
43
+ | Codex | `~/.codex/sessions/YYYY/MM/DD/` | yes |
44
+ | OpenCode | `~/.local/share/opencode/opencode.db` | yes (+cost) |
45
+ | MimoCode | `~/.local/share/mimocode/mimocode.db` | yes (+cost) |
46
+ | GitHub Copilot | `~/.copilot/session-state/` | presence only |
47
+ | Antigravity | `~/Library/Application Support/Antigravity` | presence only |
48
+ | Gemini CLI | `~/.gemini/antigravity/conversations` | presence only |
88
49
 
89
- ```bash
90
- export CLAUDE_HOME=/custom/path/to/.claude
91
- export CODEX_HOME=/custom/path/to/.codex
92
- export OPENCODE_HOME=/custom/path/to/.local/share/opencode
93
- export MIMOCODE_HOME=/custom/path/to/.local/share/mimocode
94
- export COPILOT_HOME=/custom/path/to/.copilot
95
- export ANTIGRAVITY_HOME=/custom/path/to/Antigravity
96
- export GEMINI_HOME=/custom/path/to/.gemini
97
- ```
50
+ ## Path overrides
98
51
 
99
- Or pass all at once via JSON:
52
+ Override any tool's base path with an env var (`CLAUDE_HOME`, `CODEX_HOME`,
53
+ `OPENCODE_HOME`, `MIMOCODE_HOME`, `COPILOT_HOME`, `ANTIGRAVITY_HOME`,
54
+ `GEMINI_HOME`), or pass all at once via JSON:
100
55
 
101
56
  ```bash
102
57
  export AI_USAGE_PATHS_JSON='{"codex":"/data/codex","opencode":"/data/oc.db"}'
103
58
  ```
104
59
 
105
- ## Supported tools
106
-
107
- | Tool | Path | Token data | Source format |
108
- |---|---|---|---|
109
- | Claude Code | `~/.claude/transcripts` | presence only | `ses_*.jsonl` |
110
- | Codex | `~/.codex/sessions/YYYY/MM/DD/` | **yes** | `rollout-*.jsonl` `token_count` events |
111
- | OpenCode | `~/.local/share/opencode/opencode.db` | **yes** (+cost) | SQLite |
112
- | MimoCode | `~/.local/share/mimocode/mimocode.db` | **yes** (+cost, when present) | SQLite |
113
- | GitHub Copilot | `~/.copilot/session-state/` | presence only | `events.jsonl` |
114
- | Antigravity | `~/Library/Application Support/Antigravity` | presence only | dir scan |
115
- | Gemini CLI | `~/.gemini/antigravity/conversations` | presence only | `*.pb` protobuf |
116
-
117
60
  ## Token breakdown
118
61
 
119
- For tools that record token data, the analyzer shows the full breakdown:
120
-
121
- - **Input** — prompt tokens sent to the model
122
- - **Output** — completion tokens generated by the model
123
- - **Cache Read** — prompt tokens served from the provider's cache (cheap)
124
- - **Cache Write** — prompt tokens cached for future use (OpenCode only)
125
- - **Reasoning** — extended thinking / chain-of-thought tokens
126
-
127
- The TUI is adaptive: at terminal widths below 110 columns it drops
128
- non-essential columns; at 110+ it shows the full breakdown with project
129
- paths, per-tool token columns, and longer distribution bars.
62
+ For tools that record token data, the analyzer shows input, output, cache
63
+ read, cache write, and reasoning tokens. Cache hits are cheap; reasoning is
64
+ the extended-thinking/chain-of-thought cost.
130
65
 
131
- ## Architecture
66
+ ## How it works
132
67
 
133
68
  ```
134
69
  src/
135
- ├── detectors.js auto-path discovery (env → well-known locations)
136
- ├── loaders.js SQLite + JSONL parsers → unified session record
137
- ├── aggregate.js per-project / per-week / per-month grouping
138
- ├── render.js TUI (chalk + cli-table3 + boxen + gradient-string)
70
+ ├── detectors.js auto-path discovery (env → well-known locations)
71
+ ├── loaders.js SQLite + JSONL parsers → unified session record
72
+ ├── aggregate.js per-project / per-week / per-month grouping
73
+ ├── render.js TUI • markdown.js → Markdown report
139
74
  bin/
140
- └── ai-usage.js entry point
141
- ```
142
-
143
- The loader produces a unified record shape:
144
-
145
- ```ts
146
- {
147
- tool, sessionId, project, title, week, month, ts,
148
- tokensInput, tokensOutput, tokensCacheRead, tokensCacheWrite,
149
- tokensReasoning, tokensTotal, cost, model
150
- }
151
- ```
152
-
153
- The aggregator then groups by project / week / month, and the renderer
154
- turns each grouping into a colored table with a distribution bar.
155
-
156
- ## Output modes
157
-
158
- ### TUI (default)
159
-
160
- Color-coded, boxed tables with bar charts. Adapts to terminal width.
161
-
162
- ### JSON (`--json`)
163
-
164
- Single JSON object with detection results, summary, raw sessions, and errors.
165
- Suitable for piping into `jq` or feeding a dashboard.
166
-
167
- ```bash
168
- ai-usage --json | jq '.summary'
169
- # {
170
- # "n": 411,
171
- # "tokensTotal": 1558000000,
172
- # "tokensInput": 420900000,
173
- # "tokensOutput": 4450000,
174
- # "tokensCacheRead": 1130000000,
175
- # "tokensCacheWrite": 49000,
176
- # "tokensReasoning": 2210000,
177
- # "cost": 29.4,
178
- # "avg": 3790000
179
- # }
75
+ └── ai-usage.js entry point
180
76
  ```
181
77
 
182
78
  ## License
package/bin/ai-usage.js CHANGED
@@ -10,14 +10,18 @@ import {
10
10
  renderPerProject, renderPerMonth, renderPerWeek,
11
11
  renderTopSessions, renderNotes,
12
12
  } from '../src/render.js';
13
+ import { renderMarkdown } from '../src/markdown.js';
13
14
 
14
15
  const args = process.argv.slice(2);
15
- const flags = new Set(args.filter(a => a.startsWith('--')));
16
- const showHelp = flags.has('--help') || flags.has('-h');
17
- const jsonOut = flags.has('--json');
16
+ const hasFlag = (name) => args.includes(`--${name}`) || args.includes(`-${name}`);
17
+ const showHelp = hasFlag('help') || hasFlag('h');
18
+ const jsonOut = hasFlag('json');
19
+ const mdOut = hasFlag('markdown') || hasFlag('md');
18
20
  const topN = (() => {
19
21
  const i = args.indexOf('--top');
20
- return i >= 0 ? parseInt(args[i + 1], 10) || 5 : 5;
22
+ if (i < 0) return 5;
23
+ const v = parseInt(args[i + 1], 10);
24
+ return Number.isFinite(v) && v > 0 ? v : 5;
21
25
  })();
22
26
 
23
27
  if (showHelp) {
@@ -28,9 +32,16 @@ Usage:
28
32
  ai-usage [options]
29
33
 
30
34
  Options:
31
- --top N Show top N heaviest sessions (default: 5)
32
- --json Output machine-readable JSON instead of TUI
33
35
  -h, --help Show this help
36
+ --json Output machine-readable JSON instead of TUI
37
+ --markdown, --md Output as a Markdown report (GitHub-flavored tables)
38
+ --top N Show top N heaviest sessions (default: 5)
39
+
40
+ Examples:
41
+ ai-usage # default TUI
42
+ ai-usage --json | jq .summary # pipe to jq
43
+ ai-usage --md > report.md # save as markdown
44
+ ai-usage --top 20 # show top 20 sessions
34
45
 
35
46
  Environment overrides (per-tool data path):
36
47
  CLAUDE_HOME, CODEX_HOME, OPENCODE_HOME, MIMOCODE_HOME,
@@ -38,7 +49,7 @@ Environment overrides (per-tool data path):
38
49
  AI_USAGE_PATHS_JSON='{"codex":"/custom/path",...}'
39
50
 
40
51
  Supported tools:
41
- • Claude Code — ~/.claude/transcripts (presence only)
52
+ • Claude Code — ~/.claude/projects (presence only)
42
53
  • Codex — ~/.codex/sessions (tokens from token_count events)
43
54
  • OpenCode — ~/.local/share/opencode/opencode.db (tokens + cost)
44
55
  • MimoCode — ~/.local/share/mimocode/mimocode.db (tokens + cost)
@@ -49,6 +60,11 @@ Supported tools:
49
60
  process.exit(0);
50
61
  }
51
62
 
63
+ if (jsonOut && mdOut) {
64
+ console.error('Error: --json and --markdown are mutually exclusive.');
65
+ process.exit(2);
66
+ }
67
+
52
68
  async function main() {
53
69
  const t0 = Date.now();
54
70
  const detections = detectAll();
@@ -70,6 +86,15 @@ async function main() {
70
86
  return;
71
87
  }
72
88
 
89
+ if (mdOut) {
90
+ const out = renderMarkdown({
91
+ records, detections, errors,
92
+ dateRange: range, topN, generatedAt: new Date().toISOString(),
93
+ });
94
+ process.stdout.write(out);
95
+ return;
96
+ }
97
+
73
98
  // TUI render
74
99
  const sections = [
75
100
  renderHeader({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-usage-analyzer",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "TUI analyzer for local AI coding agent token usage. Auto-detects Claude Code, Codex, OpenCode, MimoCode, Copilot, Antigravity, and Gemini.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.33.0",
package/src/detectors.js CHANGED
@@ -56,25 +56,28 @@ const DETECTORS = [
56
56
  name: 'Claude Code',
57
57
  kind: 'jsonl',
58
58
  envVar: 'CLAUDE_HOME',
59
- candidatePaths: () => {
60
- const base = env.CLAUDE_HOME || join(HOME, '.claude');
61
- return [
62
- join(base, 'transcripts'),
63
- join(base, 'projects'),
64
- ];
65
- },
59
+ candidatePaths: () => [
60
+ env.CLAUDE_HOME
61
+ ? join(env.CLAUDE_HOME, 'projects')
62
+ : join(HOME, '.claude', 'projects'),
63
+ ],
66
64
  count: (p) => {
67
65
  if (!p) return 0;
68
66
  let n = 0;
69
- try {
70
- for (const f of readdirSync(p)) {
71
- if (f.startsWith('ses_') && f.endsWith('.jsonl')) n++;
72
- }
73
- } catch {}
67
+ function walk(dir) {
68
+ try {
69
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
70
+ const full = join(dir, e.name);
71
+ if (e.isDirectory()) walk(full);
72
+ else if (e.isFile() && e.name.endsWith('.jsonl')) n++;
73
+ }
74
+ } catch {}
75
+ }
76
+ walk(p);
74
77
  return n;
75
78
  },
76
79
  hasTokens: false, // transcripts only contain text, no token counts
77
- description: '~/.claude/transcripts/*.jsonl (no token data stored locally)',
80
+ description: '~/.claude/projects/*/<UUID>.jsonl (no token data stored locally)',
78
81
  },
79
82
 
80
83
  // -----------------------------------------------------------------------
@@ -0,0 +1,257 @@
1
+ // Markdown report renderer for AI token usage analysis.
2
+ // Produces a self-contained GitHub-flavored markdown document that
3
+ // copies cleanly into issues, PRs, Notion, etc.
4
+
5
+ import {
6
+ perProject, perMonth, perWeek, perTool, overall, topSessions, tokenBreakdown,
7
+ MONTH_NAMES,
8
+ } from './aggregate.js';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Number formatting helpers (re-implemented locally to avoid TUI deps)
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export function fmtInt(n) {
15
+ return Number(n || 0).toLocaleString('en-US');
16
+ }
17
+
18
+ export function fmtCompact(n) {
19
+ const a = Math.abs(n);
20
+ if (a >= 1e9) return (n / 1e9).toFixed(2) + 'B';
21
+ if (a >= 1e6) return (n / 1e6).toFixed(2) + 'M';
22
+ if (a >= 1e3) return (n / 1e3).toFixed(1) + 'K';
23
+ return String(n || 0);
24
+ }
25
+
26
+ function fmtCost(n) {
27
+ if (!n) return '—';
28
+ return '$' + Number(n).toFixed(2);
29
+ }
30
+
31
+ function compactHome(p) {
32
+ if (!p) return '—';
33
+ const home = process.env.HOME || '';
34
+ return p.startsWith(home) ? '~' + p.slice(home.length) : p;
35
+ }
36
+
37
+ function bar(value, max, width) {
38
+ if (!max || max <= 0) return '░'.repeat(width);
39
+ const pct = Math.max(0, Math.min(1, value / max));
40
+ const filled = Math.round(pct * width);
41
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
42
+ }
43
+
44
+ function pct(x) { return (x * 100).toFixed(1) + '%'; }
45
+
46
+ function mdEscape(s) {
47
+ if (s == null) return '';
48
+ return String(s).replace(/\|/g, '\\|').replace(/\n/g, ' ');
49
+ }
50
+
51
+ function shortModel(m) {
52
+ if (!m) return '—';
53
+ if (typeof m === 'string' && m.trim().startsWith('{')) {
54
+ try {
55
+ const d = JSON.parse(m);
56
+ const id = d.id || d.model || m;
57
+ const prov = d.providerId || d.provider;
58
+ return id + (prov ? ` (${prov})` : '');
59
+ } catch { return m.slice(0, 30); }
60
+ }
61
+ return m;
62
+ }
63
+
64
+ function today() {
65
+ return new Date().toISOString().split('T')[0];
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Public: renderMarkdown
70
+ // ---------------------------------------------------------------------------
71
+
72
+ export function renderMarkdown({
73
+ records = [], detections = [], errors = [],
74
+ dateRange = [null, null], topN = 5, generatedAt = null,
75
+ }) {
76
+ const out = [];
77
+ const tot = overall(records);
78
+ const breakdown = tokenBreakdown(tot);
79
+ const tools = perTool(records);
80
+ const hasData = records.length > 0;
81
+
82
+ // Header --------------------------------------------------------------
83
+ out.push(`# AI Token Usage Report`);
84
+ out.push(``);
85
+ out.push(`> Generated by [\`ai-usage-analyzer\`](https://github.com/adetxt/ai-usage-analyzer) on ${generatedAt ? generatedAt.split('T')[0] : today()}`);
86
+ out.push(``);
87
+
88
+ if (hasData) {
89
+ out.push(`**Range**: ${dateRange[0] ?? '—'} → ${dateRange[1] ?? '—'} `);
90
+ out.push(`**Sessions**: ${fmtInt(records.length)} `);
91
+ out.push(`**Total tokens**: ${fmtCompact(tot.tokensTotal)} `);
92
+ if (tot.cost > 0) {
93
+ out.push(`**Cost**: ${fmtCost(tot.cost)} (opencode only) `);
94
+ }
95
+ } else {
96
+ out.push(`**No session data available** — only detection status below.`);
97
+ }
98
+ out.push(``);
99
+
100
+ // Detected tools ------------------------------------------------------
101
+ out.push(`## Detected AI Tools`);
102
+ out.push(``);
103
+ out.push(`| Tool | Status | Path | Count | Tokens |`);
104
+ out.push(`|---|---|---|---:|---|`);
105
+ for (const d of detections) {
106
+ const status = d.status === 'present' ? '✅ present' : '❌ absent';
107
+ const path = d.path ? '`' + mdEscape(compactHome(d.path)) + '`' : '—';
108
+ const count = d.count ? fmtInt(d.count) : '—';
109
+ const tok = d.hasTokens
110
+ ? (d.count ? '✅' : '—')
111
+ : 'n/a';
112
+ out.push(`| ${mdEscape(d.name)} | ${status} | ${path} | ${count} | ${tok} |`);
113
+ }
114
+ out.push(``);
115
+
116
+ if (!hasData) {
117
+ if (errors && errors.length > 0) {
118
+ out.push(`## Errors`);
119
+ out.push(``);
120
+ for (const e of errors) out.push(`- ${mdEscape(e)}`);
121
+ out.push(``);
122
+ }
123
+ return out.join('\n');
124
+ }
125
+
126
+ // Overview ------------------------------------------------------------
127
+ out.push(`## Overview`);
128
+ out.push(``);
129
+ out.push(`### Per-tool Summary`);
130
+ out.push(``);
131
+ out.push(`| Tool | Sessions | Total | Avg/sess | Cost |`);
132
+ out.push(`|---|---:|---:|---:|---:|`);
133
+ for (const t of tools) {
134
+ out.push(`| ${mdEscape(t.tool)} | ${fmtInt(t.n)} | ${fmtCompact(t.tokensTotal)} | ${fmtCompact(t.avg)} | ${fmtCost(t.cost)} |`);
135
+ }
136
+ out.push(`| **TOTAL** | **${fmtInt(records.length)}** | **${fmtCompact(tot.tokensTotal)}** | **${fmtCompact(tot.avg)}** | **${fmtCost(tot.cost)}** |`);
137
+ out.push(``);
138
+
139
+ out.push(`### Token Breakdown`);
140
+ out.push(``);
141
+ out.push(`| Type | Tokens | Share |`);
142
+ out.push(`|---|---:|---:|`);
143
+ out.push(`| Input | ${fmtCompact(breakdown.input)} | ${pct(breakdown.ratios.input)} |`);
144
+ out.push(`| Output | ${fmtCompact(breakdown.output)} | ${pct(breakdown.ratios.output)} |`);
145
+ out.push(`| Cache Read | ${fmtCompact(breakdown.cacheRead)} | ${pct(breakdown.ratios.cacheRead)} |`);
146
+ out.push(`| Cache Write | ${fmtCompact(breakdown.cacheWrite)} | ${pct(breakdown.ratios.cacheWrite)} |`);
147
+ out.push(`| Reasoning | ${fmtCompact(breakdown.reasoning)} | ${pct(breakdown.ratios.reasoning)} |`);
148
+ out.push(`| **Total** | **${fmtCompact(breakdown.total)}** | **100.0%** |`);
149
+ out.push(``);
150
+
151
+ // Per Project ---------------------------------------------------------
152
+ const projects = perProject(records);
153
+ if (projects.length > 0) {
154
+ const max = projects[0].tokensTotal;
155
+ out.push(`## Per Project`);
156
+ out.push(``);
157
+ out.push(`| Tool | Project | Sessions | Input | Output | Cache | Total | Dist |`);
158
+ out.push(`|---|---|---:|---:|---:|---:|---:|---|`);
159
+ for (const p of projects) {
160
+ const cache = p.tokensCacheRead + p.tokensCacheWrite;
161
+ const proj = compactHome(p.project);
162
+ out.push(`| ${mdEscape(p.tool)} | \`${mdEscape(proj)}\` | ${fmtInt(p.n)} | ${fmtCompact(p.tokensInput)} | ${fmtCompact(p.tokensOutput)} | ${fmtCompact(cache)} | ${fmtCompact(p.tokensTotal)} | ${bar(p.tokensTotal, max, 20)} |`);
163
+ }
164
+ out.push(``);
165
+ }
166
+
167
+ // Per Month -----------------------------------------------------------
168
+ const months = perMonth(records);
169
+ if (months.length > 0) {
170
+ const max = Math.max(...months.map(m => m.tokensTotal));
171
+ out.push(`## Per Month`);
172
+ out.push(``);
173
+ out.push(`| Month | Sessions | Input | Output | Total | OC | CX | MM | Dist |`);
174
+ out.push(`|---|---:|---:|---:|---:|---:|---:|---:|---|`);
175
+ for (const m of months) {
176
+ const yyyy = m.month.slice(0, 4);
177
+ const mm = m.month.slice(5, 7);
178
+ const label = `${MONTH_NAMES[mm] || mm} ${yyyy}`;
179
+ out.push(`| ${label} | ${fmtInt(m.n)} | ${fmtCompact(m.tokensInput)} | ${fmtCompact(m.tokensOutput)} | ${fmtCompact(m.tokensTotal)} | ${m.byTool.opencode ? fmtCompact(m.byTool.opencode) : '—'} | ${m.byTool.codex ? fmtCompact(m.byTool.codex) : '—'} | ${m.byTool.mimocode ? fmtCompact(m.byTool.mimocode) : '—'} | ${bar(m.tokensTotal, max, 20)} |`);
180
+ }
181
+ out.push(``);
182
+ }
183
+
184
+ // Per Week ------------------------------------------------------------
185
+ const weeks = perWeek(records);
186
+ if (weeks.length > 0) {
187
+ const max = Math.max(...weeks.map(w => w.tokensTotal));
188
+ out.push(`## Per Week`);
189
+ out.push(``);
190
+ out.push(`| Week | Sessions | Input | Output | Total | OC | CX | MM | Dist |`);
191
+ out.push(`|---|---:|---:|---:|---:|---:|---:|---:|---|`);
192
+ for (const w of weeks) {
193
+ out.push(`| ${w.week} | ${fmtInt(w.n)} | ${fmtCompact(w.tokensInput)} | ${fmtCompact(w.tokensOutput)} | ${fmtCompact(w.tokensTotal)} | ${w.byTool.opencode ? fmtCompact(w.byTool.opencode) : '—'} | ${w.byTool.codex ? fmtCompact(w.byTool.codex) : '—'} | ${w.byTool.mimocode ? fmtCompact(w.byTool.mimocode) : '—'} | ${bar(w.tokensTotal, max, 18)} |`);
194
+ }
195
+ out.push(``);
196
+ }
197
+
198
+ // Top N ---------------------------------------------------------------
199
+ const top = topSessions(records, topN);
200
+ if (top.length > 0) {
201
+ out.push(`## Top ${top.length} Heaviest Sessions`);
202
+ out.push(``);
203
+ out.push(`| # | Total | Input | Output | Cost | Model | Project | Title |`);
204
+ out.push(`|---:|---:|---:|---:|---:|---|---|---|`);
205
+ top.forEach((r, i) => {
206
+ const proj = compactHome(r.project);
207
+ out.push(`| ${i + 1} | ${fmtCompact(r.tokensTotal)} | ${fmtCompact(r.tokensInput)} | ${fmtCompact(r.tokensOutput)} | ${r.cost ? '$' + r.cost.toFixed(4) : '—'} | ${mdEscape(shortModel(r.model))} | \`${mdEscape(proj)}\` | ${mdEscape(r.title || '—')} |`);
208
+ });
209
+ out.push(``);
210
+ }
211
+
212
+ // Notes ---------------------------------------------------------------
213
+ out.push(`## Notes`);
214
+ out.push(``);
215
+ out.push(`### Token Definitions`);
216
+ out.push(``);
217
+ out.push(`- **Input** — prompt tokens sent to the model`);
218
+ out.push(`- **Output** — completion tokens generated by the model`);
219
+ out.push(`- **Cache Read** — prompt tokens served from the provider's cache (cheap)`);
220
+ out.push(`- **Cache Write** — prompt tokens cached for future use (OpenCode only)`);
221
+ out.push(`- **Reasoning** — extended thinking / chain-of-thought tokens`);
222
+ out.push(``);
223
+
224
+ const withTokens = detections.filter(d => d.hasTokens && d.status === 'present');
225
+ if (withTokens.length > 0) {
226
+ out.push(`### Detected Tools With Token Data`);
227
+ out.push(``);
228
+ for (const d of withTokens) {
229
+ out.push(`- **${mdEscape(d.name)}** — \`${mdEscape(d.description)}\``);
230
+ }
231
+ out.push(``);
232
+ }
233
+
234
+ const noTokens = detections.filter(d => !d.hasTokens && d.status === 'present');
235
+ if (noTokens.length > 0) {
236
+ out.push(`### Detected Tools Without Token Data`);
237
+ out.push(``);
238
+ for (const d of noTokens) {
239
+ out.push(`- **${mdEscape(d.name)}** — \`${mdEscape(d.description)}\``);
240
+ }
241
+ out.push(``);
242
+ }
243
+
244
+ if (errors && errors.length > 0) {
245
+ out.push(`### Errors`);
246
+ out.push(``);
247
+ for (const e of errors.slice(0, 10)) {
248
+ out.push(`- ${mdEscape(e)}`);
249
+ }
250
+ if (errors.length > 10) {
251
+ out.push(`- … and ${errors.length - 10} more`);
252
+ }
253
+ out.push(``);
254
+ }
255
+
256
+ return out.join('\n');
257
+ }