ai-usage-analyzer 0.2.1 → 0.3.0
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/bin/ai-usage.js +22 -3
- package/package.json +1 -1
- package/src/aggregate.js +32 -17
- package/src/detectors.js +11 -257
- package/src/loaders.js +112 -1
- package/src/markdown.js +73 -13
- package/src/render.js +154 -98
- package/src/tools.js +259 -0
package/bin/ai-usage.js
CHANGED
|
@@ -7,7 +7,8 @@ import { loadAll, dateRange } from '../src/loaders.js';
|
|
|
7
7
|
import { overall } from '../src/aggregate.js';
|
|
8
8
|
import {
|
|
9
9
|
renderHeader, renderDetections, renderOverview,
|
|
10
|
-
renderPerProject,
|
|
10
|
+
renderPerProject, renderPerTool, renderPerToolPerMonth,
|
|
11
|
+
renderPerMonth, renderPerWeek,
|
|
11
12
|
renderTopSessions, renderNotes,
|
|
12
13
|
} from '../src/render.js';
|
|
13
14
|
import { renderMarkdown } from '../src/markdown.js';
|
|
@@ -23,6 +24,12 @@ const topN = (() => {
|
|
|
23
24
|
const v = parseInt(args[i + 1], 10);
|
|
24
25
|
return Number.isFinite(v) && v > 0 ? v : 5;
|
|
25
26
|
})();
|
|
27
|
+
const yearFilter = (() => {
|
|
28
|
+
const i = args.indexOf('--year');
|
|
29
|
+
if (i < 0) return null;
|
|
30
|
+
const v = parseInt(args[i + 1], 10);
|
|
31
|
+
return Number.isFinite(v) && v > 1970 && v < 3000 ? v : null;
|
|
32
|
+
})();
|
|
26
33
|
|
|
27
34
|
if (showHelp) {
|
|
28
35
|
console.log(`
|
|
@@ -36,9 +43,11 @@ Options:
|
|
|
36
43
|
--json Output machine-readable JSON instead of TUI
|
|
37
44
|
--markdown, --md Output as a Markdown report (GitHub-flavored tables)
|
|
38
45
|
--top N Show top N heaviest sessions (default: 5)
|
|
46
|
+
--year YYYY Filter records to a single year (e.g. --year 2026)
|
|
39
47
|
|
|
40
48
|
Examples:
|
|
41
49
|
ai-usage # default TUI
|
|
50
|
+
ai-usage --year 2026 # TUI, only 2026 sessions
|
|
42
51
|
ai-usage --json | jq .summary # pipe to jq
|
|
43
52
|
ai-usage --md > report.md # save as markdown
|
|
44
53
|
ai-usage --top 20 # show top 20 sessions
|
|
@@ -49,7 +58,7 @@ Environment overrides (per-tool data path):
|
|
|
49
58
|
AI_USAGE_PATHS_JSON='{"codex":"/custom/path",...}'
|
|
50
59
|
|
|
51
60
|
Supported tools:
|
|
52
|
-
• Claude Code — ~/.claude/projects (
|
|
61
|
+
• Claude Code — ~/.claude/projects (tokens from per-message usage)
|
|
53
62
|
• Codex — ~/.codex/sessions (tokens from token_count events)
|
|
54
63
|
• OpenCode — ~/.local/share/opencode/opencode.db (tokens + cost)
|
|
55
64
|
• MimoCode — ~/.local/share/mimocode/mimocode.db (tokens + cost)
|
|
@@ -68,7 +77,14 @@ if (jsonOut && mdOut) {
|
|
|
68
77
|
async function main() {
|
|
69
78
|
const t0 = Date.now();
|
|
70
79
|
const detections = detectAll();
|
|
71
|
-
const { records, errors } = await loadAll(detections);
|
|
80
|
+
const { records: allRecords, errors } = await loadAll(detections);
|
|
81
|
+
|
|
82
|
+
// Apply --year filter before any aggregation so dateRange and totals
|
|
83
|
+
// reflect the filtered set.
|
|
84
|
+
const records = yearFilter !== null
|
|
85
|
+
? allRecords.filter(r => r.month && r.month.startsWith(`${yearFilter}-`))
|
|
86
|
+
: allRecords;
|
|
87
|
+
|
|
72
88
|
const range = dateRange(records);
|
|
73
89
|
const tot = overall(records);
|
|
74
90
|
|
|
@@ -78,6 +94,7 @@ async function main() {
|
|
|
78
94
|
summary: tot,
|
|
79
95
|
dateRange: range,
|
|
80
96
|
sessions: records,
|
|
97
|
+
filter: yearFilter !== null ? { year: yearFilter } : null,
|
|
81
98
|
errors,
|
|
82
99
|
generatedAt: new Date().toISOString(),
|
|
83
100
|
durationMs: Date.now() - t0,
|
|
@@ -109,6 +126,8 @@ async function main() {
|
|
|
109
126
|
sections.push(
|
|
110
127
|
renderOverview(records, detections),
|
|
111
128
|
renderPerProject(records),
|
|
129
|
+
renderPerTool(records),
|
|
130
|
+
renderPerToolPerMonth(records),
|
|
112
131
|
renderPerMonth(records),
|
|
113
132
|
renderPerWeek(records),
|
|
114
133
|
renderTopSessions(records, topN),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-usage-analyzer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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/aggregate.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const MONTH_NAMES = {
|
|
4
4
|
'01': 'Jan', '02': 'Feb', '03': 'Mar', '04': 'Apr',
|
|
5
|
-
'05': '
|
|
6
|
-
'09': 'Sep', '10': '
|
|
5
|
+
'05': 'May', '06': 'Jun', '07': 'Jul', '08': 'Aug',
|
|
6
|
+
'09': 'Sep', '10': 'Oct', '11': 'Nov', '12': 'Dec',
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
function sum(arr, key) {
|
|
@@ -38,13 +38,23 @@ function summarize(records) {
|
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function toolBreakdown(records) {
|
|
42
|
+
const byTool = {};
|
|
43
|
+
for (const r of records) {
|
|
44
|
+
byTool[r.tool] = (byTool[r.tool] || 0) + r.tokensTotal;
|
|
45
|
+
}
|
|
46
|
+
return byTool;
|
|
47
|
+
}
|
|
48
|
+
|
|
41
49
|
export function perProject(records) {
|
|
42
|
-
|
|
50
|
+
// One row per project — tool mix is shown via the stacked bar / byTool,
|
|
51
|
+
// not as a separate column. Detailed per-tool-per-project breakdown
|
|
52
|
+
// is no longer surfaced here; the "Per Tool per Month" section is the
|
|
53
|
+
// place to see per-tool data over time.
|
|
54
|
+
const m = groupBy(records, r => r.project);
|
|
43
55
|
const out = [];
|
|
44
|
-
for (const [
|
|
45
|
-
|
|
46
|
-
const s = summarize(arr);
|
|
47
|
-
out.push({ tool, project, ...s });
|
|
56
|
+
for (const [project, arr] of m) {
|
|
57
|
+
out.push({ project, ...summarize(arr), byTool: toolBreakdown(arr) });
|
|
48
58
|
}
|
|
49
59
|
return out.sort((a, b) => b.tokensTotal - a.tokensTotal);
|
|
50
60
|
}
|
|
@@ -53,11 +63,7 @@ export function perMonth(records) {
|
|
|
53
63
|
const m = groupBy(records, r => r.month);
|
|
54
64
|
const out = [];
|
|
55
65
|
for (const [month, arr] of m) {
|
|
56
|
-
|
|
57
|
-
for (const r of arr) {
|
|
58
|
-
byTool[r.tool] = (byTool[r.tool] || 0) + r.tokensTotal;
|
|
59
|
-
}
|
|
60
|
-
out.push({ month, ...summarize(arr), byTool });
|
|
66
|
+
out.push({ month, ...summarize(arr), byTool: toolBreakdown(arr) });
|
|
61
67
|
}
|
|
62
68
|
return out.sort((a, b) => a.month.localeCompare(b.month));
|
|
63
69
|
}
|
|
@@ -66,11 +72,7 @@ export function perWeek(records) {
|
|
|
66
72
|
const m = groupBy(records, r => r.week);
|
|
67
73
|
const out = [];
|
|
68
74
|
for (const [week, arr] of m) {
|
|
69
|
-
|
|
70
|
-
for (const r of arr) {
|
|
71
|
-
byTool[r.tool] = (byTool[r.tool] || 0) + r.tokensTotal;
|
|
72
|
-
}
|
|
73
|
-
out.push({ week, ...summarize(arr), byTool });
|
|
75
|
+
out.push({ week, ...summarize(arr), byTool: toolBreakdown(arr) });
|
|
74
76
|
}
|
|
75
77
|
return out.sort((a, b) => a.week.localeCompare(b.week));
|
|
76
78
|
}
|
|
@@ -84,6 +86,19 @@ export function perTool(records) {
|
|
|
84
86
|
return out.sort((a, b) => b.tokensTotal - a.tokensTotal);
|
|
85
87
|
}
|
|
86
88
|
|
|
89
|
+
export function perToolPerMonth(records) {
|
|
90
|
+
// Cross-tab: one row per (tool, month). Lets you see how a single tool's
|
|
91
|
+
// usage is distributed across months — and avoids the hardcoded OC/CX/MM
|
|
92
|
+
// column problem in the per-month table.
|
|
93
|
+
const m = groupBy(records, r => `${r.tool}\u0001${r.month}`);
|
|
94
|
+
const out = [];
|
|
95
|
+
for (const [k, arr] of m) {
|
|
96
|
+
const [tool, month] = k.split('\u0001');
|
|
97
|
+
out.push({ tool, month, ...summarize(arr) });
|
|
98
|
+
}
|
|
99
|
+
return out.sort((a, b) => b.tokensTotal - a.tokensTotal);
|
|
100
|
+
}
|
|
101
|
+
|
|
87
102
|
export function overall(records) {
|
|
88
103
|
return summarize(records);
|
|
89
104
|
}
|
package/src/detectors.js
CHANGED
|
@@ -1,33 +1,14 @@
|
|
|
1
1
|
// Auto-detect AI coding agent data directories.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// 3. Probe well-known locations per platform (mac/linux) relative to $HOME
|
|
7
|
-
// 4. Return a status for each tool: 'present' | 'absent' | 'disabled'
|
|
8
|
-
//
|
|
9
|
-
// Tools that share the opencode SQLite schema are auto-registered.
|
|
10
|
-
|
|
11
|
-
import { existsSync, statSync, readdirSync } from 'node:fs';
|
|
12
|
-
import { join, isAbsolute } from 'node:path';
|
|
13
|
-
import { homedir, platform } from 'node:os';
|
|
14
|
-
import { env, exitCode } from 'node:process';
|
|
15
|
-
import { createRequire } from 'node:module';
|
|
16
|
-
const require = createRequire(import.meta.url);
|
|
17
|
-
|
|
18
|
-
const HOME = homedir();
|
|
19
|
-
const OS = platform(); // 'darwin' | 'linux' | 'win32'
|
|
3
|
+
// All tool configuration (paths, env vars, kinds, display metadata) lives
|
|
4
|
+
// in src/tools.js. This file only orchestrates: probe paths, apply user
|
|
5
|
+
// overrides, and project each tool's metadata into the detection result.
|
|
20
6
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
? join(process.env.XDG_DATA_HOME, '..') // XDG_DATA_HOME/../ = ~/.local/share
|
|
26
|
-
: join(HOME, '.local', 'share');
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { isAbsolute } from 'node:path';
|
|
9
|
+
import { env } from 'node:process';
|
|
10
|
+
import { TOOLS, TOOL_ORDER } from './tools.js';
|
|
27
11
|
|
|
28
|
-
const CONFIG_HOME = process.env.XDG_CONFIG_HOME || join(HOME, '.config');
|
|
29
|
-
|
|
30
|
-
// Probe = array of candidate paths; first one that exists wins.
|
|
31
12
|
function firstExisting(paths) {
|
|
32
13
|
for (const p of paths) {
|
|
33
14
|
if (p && existsSync(p)) return p;
|
|
@@ -35,236 +16,17 @@ function firstExisting(paths) {
|
|
|
35
16
|
return null;
|
|
36
17
|
}
|
|
37
18
|
|
|
38
|
-
function
|
|
39
|
-
try { return
|
|
40
|
-
}
|
|
41
|
-
function isDir(p) {
|
|
42
|
-
try { return statSync(p).isDirectory(); } catch { return false; }
|
|
19
|
+
function safeParseJSON(s) {
|
|
20
|
+
try { return JSON.parse(s); } catch { return {}; }
|
|
43
21
|
}
|
|
44
22
|
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
// Detector definitions. Each entry returns:
|
|
47
|
-
// { key, name, kind, status, path, count, details }
|
|
48
|
-
// ---------------------------------------------------------------------------
|
|
49
|
-
|
|
50
|
-
const DETECTORS = [
|
|
51
|
-
// -----------------------------------------------------------------------
|
|
52
|
-
// Claude Code
|
|
53
|
-
// -----------------------------------------------------------------------
|
|
54
|
-
{
|
|
55
|
-
key: 'claude',
|
|
56
|
-
name: 'Claude Code',
|
|
57
|
-
kind: 'jsonl',
|
|
58
|
-
envVar: 'CLAUDE_HOME',
|
|
59
|
-
candidatePaths: () => [
|
|
60
|
-
env.CLAUDE_HOME
|
|
61
|
-
? join(env.CLAUDE_HOME, 'projects')
|
|
62
|
-
: join(HOME, '.claude', 'projects'),
|
|
63
|
-
],
|
|
64
|
-
count: (p) => {
|
|
65
|
-
if (!p) return 0;
|
|
66
|
-
let n = 0;
|
|
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);
|
|
77
|
-
return n;
|
|
78
|
-
},
|
|
79
|
-
hasTokens: false, // transcripts only contain text, no token counts
|
|
80
|
-
description: '~/.claude/projects/*/<UUID>.jsonl (no token data stored locally)',
|
|
81
|
-
},
|
|
82
|
-
|
|
83
|
-
// -----------------------------------------------------------------------
|
|
84
|
-
// Codex
|
|
85
|
-
// -----------------------------------------------------------------------
|
|
86
|
-
{
|
|
87
|
-
key: 'codex',
|
|
88
|
-
name: 'Codex',
|
|
89
|
-
kind: 'jsonl-rollout',
|
|
90
|
-
envVar: 'CODEX_HOME',
|
|
91
|
-
candidatePaths: () => [
|
|
92
|
-
env.CODEX_HOME || join(HOME, '.codex', 'sessions'),
|
|
93
|
-
],
|
|
94
|
-
count: (p) => {
|
|
95
|
-
if (!p) return 0;
|
|
96
|
-
let n = 0;
|
|
97
|
-
function walk(dir) {
|
|
98
|
-
try {
|
|
99
|
-
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
100
|
-
const full = join(dir, e.name);
|
|
101
|
-
if (e.isDirectory()) walk(full);
|
|
102
|
-
else if (e.isFile() && e.name.startsWith('rollout-') && e.name.endsWith('.jsonl')) n++;
|
|
103
|
-
}
|
|
104
|
-
} catch {}
|
|
105
|
-
}
|
|
106
|
-
walk(p);
|
|
107
|
-
return n;
|
|
108
|
-
},
|
|
109
|
-
hasTokens: true,
|
|
110
|
-
description: '~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl',
|
|
111
|
-
},
|
|
112
|
-
|
|
113
|
-
// -----------------------------------------------------------------------
|
|
114
|
-
// OpenCode
|
|
115
|
-
// -----------------------------------------------------------------------
|
|
116
|
-
{
|
|
117
|
-
key: 'opencode',
|
|
118
|
-
name: 'OpenCode',
|
|
119
|
-
kind: 'sqlite',
|
|
120
|
-
envVar: 'OPENCODE_HOME',
|
|
121
|
-
candidatePaths: () => [
|
|
122
|
-
env.OPENCODE_HOME
|
|
123
|
-
? join(env.OPENCODE_HOME, 'opencode.db')
|
|
124
|
-
: join(HOME, '.local', 'share', 'opencode', 'opencode.db'),
|
|
125
|
-
],
|
|
126
|
-
count: (p) => {
|
|
127
|
-
if (!p) return 0;
|
|
128
|
-
try {
|
|
129
|
-
const { DatabaseSync } = require('node:sqlite');
|
|
130
|
-
const db = new DatabaseSync(p, { readOnly: true });
|
|
131
|
-
return db.prepare('SELECT COUNT(*) AS c FROM session').get().c;
|
|
132
|
-
} catch { return 0; }
|
|
133
|
-
},
|
|
134
|
-
hasTokens: true,
|
|
135
|
-
description: '~/.local/share/opencode/opencode.db (tokens + cost)',
|
|
136
|
-
},
|
|
137
|
-
|
|
138
|
-
// -----------------------------------------------------------------------
|
|
139
|
-
// MimoCode (same schema as OpenCode)
|
|
140
|
-
// -----------------------------------------------------------------------
|
|
141
|
-
{
|
|
142
|
-
key: 'mimocode',
|
|
143
|
-
name: 'MimoCode',
|
|
144
|
-
kind: 'sqlite',
|
|
145
|
-
envVar: 'MIMOCODE_HOME',
|
|
146
|
-
candidatePaths: () => [
|
|
147
|
-
env.MIMOCODE_HOME
|
|
148
|
-
? join(env.MIMOCODE_HOME, 'mimocode.db')
|
|
149
|
-
: join(HOME, '.local', 'share', 'mimocode', 'mimocode.db'),
|
|
150
|
-
],
|
|
151
|
-
count: (p) => {
|
|
152
|
-
if (!p) return 0;
|
|
153
|
-
try {
|
|
154
|
-
const { DatabaseSync } = require('node:sqlite');
|
|
155
|
-
const db = new DatabaseSync(p, { readOnly: true });
|
|
156
|
-
return db.prepare('SELECT COUNT(*) AS c FROM session').get().c;
|
|
157
|
-
} catch { return 0; }
|
|
158
|
-
},
|
|
159
|
-
hasTokens: true,
|
|
160
|
-
description: '~/.local/share/mimocode/mimocode.db (tokens + cost)',
|
|
161
|
-
},
|
|
162
|
-
|
|
163
|
-
// -----------------------------------------------------------------------
|
|
164
|
-
// GitHub Copilot CLI
|
|
165
|
-
// -----------------------------------------------------------------------
|
|
166
|
-
{
|
|
167
|
-
key: 'copilot',
|
|
168
|
-
name: 'GitHub Copilot',
|
|
169
|
-
kind: 'jsonl-events',
|
|
170
|
-
envVar: 'COPILOT_HOME',
|
|
171
|
-
candidatePaths: () => {
|
|
172
|
-
const base = env.COPILOT_HOME || join(HOME, '.copilot');
|
|
173
|
-
return [
|
|
174
|
-
join(base, 'session-state'),
|
|
175
|
-
base,
|
|
176
|
-
];
|
|
177
|
-
},
|
|
178
|
-
count: (p) => {
|
|
179
|
-
if (!p) return 0;
|
|
180
|
-
let n = 0;
|
|
181
|
-
function walk(dir) {
|
|
182
|
-
try {
|
|
183
|
-
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
184
|
-
const full = join(dir, e.name);
|
|
185
|
-
if (e.isDirectory()) walk(full);
|
|
186
|
-
else if (e.isFile() && e.name.endsWith('.jsonl')) n++;
|
|
187
|
-
}
|
|
188
|
-
} catch {}
|
|
189
|
-
}
|
|
190
|
-
walk(p);
|
|
191
|
-
return n;
|
|
192
|
-
},
|
|
193
|
-
hasTokens: false,
|
|
194
|
-
description: '~/.copilot/session-state/*/events.jsonl (no token data)',
|
|
195
|
-
},
|
|
196
|
-
|
|
197
|
-
// -----------------------------------------------------------------------
|
|
198
|
-
// Antigravity (VS Code variant) - mostly cache; no token data
|
|
199
|
-
// -----------------------------------------------------------------------
|
|
200
|
-
{
|
|
201
|
-
key: 'antigravity',
|
|
202
|
-
name: 'Antigravity',
|
|
203
|
-
kind: 'dir',
|
|
204
|
-
envVar: 'ANTIGRAVITY_HOME',
|
|
205
|
-
candidatePaths: () => {
|
|
206
|
-
const base = env.ANTIGRAVITY_HOME || join(APP_SUPPORT, 'Antigravity');
|
|
207
|
-
return [
|
|
208
|
-
base,
|
|
209
|
-
join(HOME, '.antigravity'),
|
|
210
|
-
];
|
|
211
|
-
},
|
|
212
|
-
count: (p) => {
|
|
213
|
-
if (!p) return 0;
|
|
214
|
-
let n = 0;
|
|
215
|
-
try {
|
|
216
|
-
for (const e of readdirSync(p)) {
|
|
217
|
-
const full = join(p, e);
|
|
218
|
-
if (statSync(full).isDirectory()) n++;
|
|
219
|
-
}
|
|
220
|
-
} catch {}
|
|
221
|
-
return n;
|
|
222
|
-
},
|
|
223
|
-
hasTokens: false,
|
|
224
|
-
description: '~/Library/Application Support/Antigravity (no token data)',
|
|
225
|
-
},
|
|
226
|
-
|
|
227
|
-
// -----------------------------------------------------------------------
|
|
228
|
-
// Gemini CLI
|
|
229
|
-
// -----------------------------------------------------------------------
|
|
230
|
-
{
|
|
231
|
-
key: 'gemini',
|
|
232
|
-
name: 'Gemini CLI',
|
|
233
|
-
kind: 'protobuf',
|
|
234
|
-
envVar: 'GEMINI_HOME',
|
|
235
|
-
candidatePaths: () => {
|
|
236
|
-
const base = env.GEMINI_HOME || join(HOME, '.gemini');
|
|
237
|
-
return [
|
|
238
|
-
join(base, 'antigravity', 'conversations'),
|
|
239
|
-
join(base, 'conversations'),
|
|
240
|
-
];
|
|
241
|
-
},
|
|
242
|
-
count: (p) => {
|
|
243
|
-
if (!p) return 0;
|
|
244
|
-
let n = 0;
|
|
245
|
-
try {
|
|
246
|
-
for (const f of readdirSync(p)) {
|
|
247
|
-
if (f.endsWith('.pb')) n++;
|
|
248
|
-
}
|
|
249
|
-
} catch {}
|
|
250
|
-
return n;
|
|
251
|
-
},
|
|
252
|
-
hasTokens: false,
|
|
253
|
-
description: '~/.gemini/antigravity/conversations/*.pb (binary, no token data)',
|
|
254
|
-
},
|
|
255
|
-
];
|
|
256
|
-
|
|
257
|
-
// ---------------------------------------------------------------------------
|
|
258
|
-
// Public: run all detectors
|
|
259
|
-
// ---------------------------------------------------------------------------
|
|
260
|
-
|
|
261
23
|
export function detectAll(opts = {}) {
|
|
262
24
|
const override = opts.override || (env.AI_USAGE_PATHS_JSON
|
|
263
25
|
? safeParseJSON(env.AI_USAGE_PATHS_JSON)
|
|
264
26
|
: {});
|
|
265
27
|
|
|
266
28
|
const results = [];
|
|
267
|
-
for (const def of
|
|
29
|
+
for (const def of TOOLS) {
|
|
268
30
|
let candidatePaths = def.candidatePaths();
|
|
269
31
|
|
|
270
32
|
// Apply override if user supplied one
|
|
@@ -292,12 +54,4 @@ export function detectAll(opts = {}) {
|
|
|
292
54
|
return results;
|
|
293
55
|
}
|
|
294
56
|
|
|
295
|
-
|
|
296
|
-
try { return JSON.parse(s); } catch { return {}; }
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// ---------------------------------------------------------------------------
|
|
300
|
-
// Public: just keys in order (for stable UI columns)
|
|
301
|
-
// ---------------------------------------------------------------------------
|
|
302
|
-
|
|
303
|
-
export const TOOL_ORDER = DETECTORS.map(d => d.key);
|
|
57
|
+
export { TOOL_ORDER };
|
package/src/loaders.js
CHANGED
|
@@ -207,6 +207,113 @@ function walkJsonl(root, prefix) {
|
|
|
207
207
|
return out;
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
// Walks Claude's projects/ tree. Top-level .jsonl files are sessions; files
|
|
211
|
+
// under any `subagents/` subdir are deliberately skipped (their usage is
|
|
212
|
+
// already represented in the parent session's assistant messages).
|
|
213
|
+
function walkClaudeSessions(root) {
|
|
214
|
+
const out = [];
|
|
215
|
+
function walk(dir) {
|
|
216
|
+
let entries;
|
|
217
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
218
|
+
for (const e of entries) {
|
|
219
|
+
const full = join(dir, e.name);
|
|
220
|
+
if (e.isDirectory()) {
|
|
221
|
+
if (e.name === 'subagents') continue;
|
|
222
|
+
walk(full);
|
|
223
|
+
} else if (e.isFile() && e.name.endsWith('.jsonl')) {
|
|
224
|
+
out.push(full);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
walk(root);
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Claude Code JSONL loader
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
//
|
|
236
|
+
// Each top-level file under projects/<encoded-cwd>/<UUID>.jsonl is one session.
|
|
237
|
+
// Per assistant line, `message.usage` carries the per-turn token counts; we
|
|
238
|
+
// dedupe by `message.id` (Claude Code emits streaming duplicates) and sum
|
|
239
|
+
// across unique messages. cwd / model / timestamp come from the line itself.
|
|
240
|
+
|
|
241
|
+
async function loadClaudeJsonl(rootDir) {
|
|
242
|
+
const records = [];
|
|
243
|
+
const errors = [];
|
|
244
|
+
const files = walkClaudeSessions(rootDir);
|
|
245
|
+
|
|
246
|
+
for (const f of files) {
|
|
247
|
+
try {
|
|
248
|
+
const rec = await parseClaudeSession(f);
|
|
249
|
+
if (rec) records.push(rec);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
errors.push(`${f}: ${e.message}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { records, errors };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function parseClaudeSession(path) {
|
|
258
|
+
const sessionId = path.split('/').pop().replace('.jsonl', '');
|
|
259
|
+
const seenIds = new Set();
|
|
260
|
+
let cwd = '';
|
|
261
|
+
let model = '';
|
|
262
|
+
let firstTs = null;
|
|
263
|
+
|
|
264
|
+
let tokensInput = 0;
|
|
265
|
+
let tokensOutput = 0;
|
|
266
|
+
let tokensCacheRead = 0;
|
|
267
|
+
let tokensCacheWrite = 0;
|
|
268
|
+
|
|
269
|
+
const stream = createReadStream(path, { encoding: 'utf8' });
|
|
270
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
271
|
+
for await (const line of rl) {
|
|
272
|
+
if (!line) continue;
|
|
273
|
+
let ev;
|
|
274
|
+
try { ev = JSON.parse(line); } catch { continue; }
|
|
275
|
+
if (ev.type !== 'assistant') continue;
|
|
276
|
+
const msg = ev.message || {};
|
|
277
|
+
const usage = msg.usage;
|
|
278
|
+
if (!usage) continue;
|
|
279
|
+
const id = msg.id;
|
|
280
|
+
if (id) {
|
|
281
|
+
if (seenIds.has(id)) continue;
|
|
282
|
+
seenIds.add(id);
|
|
283
|
+
}
|
|
284
|
+
if (!cwd && typeof ev.cwd === 'string') cwd = ev.cwd;
|
|
285
|
+
if (!model && typeof msg.model === 'string') model = msg.model;
|
|
286
|
+
const ts = parseIsoMs(ev.timestamp);
|
|
287
|
+
if (firstTs === null && ts !== null) firstTs = ts;
|
|
288
|
+
tokensInput += usage.input_tokens || 0;
|
|
289
|
+
tokensOutput += usage.output_tokens || 0;
|
|
290
|
+
tokensCacheRead += usage.cache_read_input_tokens || 0;
|
|
291
|
+
tokensCacheWrite += usage.cache_creation_input_tokens || 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const tot = tokensInput + tokensOutput + tokensCacheRead + tokensCacheWrite;
|
|
295
|
+
if (tot === 0) return null;
|
|
296
|
+
const ts = firstTs ?? Date.now();
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
tool: 'claude',
|
|
300
|
+
sessionId,
|
|
301
|
+
project: compactHome(cwd),
|
|
302
|
+
title: '',
|
|
303
|
+
week: isoWeekKey(ts),
|
|
304
|
+
month: monthKey(ts),
|
|
305
|
+
ts,
|
|
306
|
+
tokensInput,
|
|
307
|
+
tokensOutput,
|
|
308
|
+
tokensCacheRead,
|
|
309
|
+
tokensCacheWrite,
|
|
310
|
+
tokensReasoning: 0,
|
|
311
|
+
tokensTotal: tot,
|
|
312
|
+
cost: 0,
|
|
313
|
+
model,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
210
317
|
// ---------------------------------------------------------------------------
|
|
211
318
|
// Public: load all session records for given detectors
|
|
212
319
|
// ---------------------------------------------------------------------------
|
|
@@ -226,8 +333,12 @@ export async function loadAll(detections) {
|
|
|
226
333
|
const r = await loadCodexRollouts(d.path);
|
|
227
334
|
all.push(...r.records);
|
|
228
335
|
if (r.errors) errors.push(...r.errors);
|
|
336
|
+
} else if (d.key === 'claude') {
|
|
337
|
+
const r = await loadClaudeJsonl(d.path);
|
|
338
|
+
all.push(...r.records);
|
|
339
|
+
if (r.errors) errors.push(...r.errors);
|
|
229
340
|
}
|
|
230
|
-
// For other tools (
|
|
341
|
+
// For other tools (copilot, antigravity, gemini), we only have
|
|
231
342
|
// presence info — no token data to load.
|
|
232
343
|
}
|
|
233
344
|
return { records: all, errors };
|
package/src/markdown.js
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// copies cleanly into issues, PRs, Notion, etc.
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
-
perProject, perMonth, perWeek, perTool, overall, topSessions, tokenBreakdown,
|
|
6
|
+
perProject, perMonth, perWeek, perTool, perToolPerMonth, overall, topSessions, tokenBreakdown,
|
|
7
7
|
MONTH_NAMES,
|
|
8
8
|
} from './aggregate.js';
|
|
9
|
+
import { getToolBarChar, TOOLS } from './tools.js';
|
|
9
10
|
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
11
12
|
// Number formatting helpers (re-implemented locally to avoid TUI deps)
|
|
@@ -41,6 +42,29 @@ function bar(value, max, width) {
|
|
|
41
42
|
return '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
// Markdown has no color, so each tool's stacked-bar segment is shown with
|
|
46
|
+
// a distinct block character. The character for each tool is defined in
|
|
47
|
+
// src/tools.js alongside its color.
|
|
48
|
+
function stackedBar(byTool, width) {
|
|
49
|
+
const total = Object.values(byTool).reduce((a, b) => a + b, 0);
|
|
50
|
+
if (total <= 0 || width <= 0) return ' '.repeat(Math.max(0, width));
|
|
51
|
+
const entries = Object.entries(byTool)
|
|
52
|
+
.filter(([, v]) => v > 0)
|
|
53
|
+
.sort((a, b) => b[1] - a[1]);
|
|
54
|
+
let out = '';
|
|
55
|
+
let x = 0;
|
|
56
|
+
for (let i = 0; i < entries.length; i++) {
|
|
57
|
+
const [tool, value] = entries[i];
|
|
58
|
+
const segW = i === entries.length - 1
|
|
59
|
+
? width - x
|
|
60
|
+
: Math.round((value / total) * width);
|
|
61
|
+
if (segW <= 0) continue;
|
|
62
|
+
out += getToolBarChar(tool).repeat(segW);
|
|
63
|
+
x += segW;
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
44
68
|
function pct(x) { return (x * 100).toFixed(1) + '%'; }
|
|
45
69
|
|
|
46
70
|
function mdEscape(s) {
|
|
@@ -151,15 +175,53 @@ export function renderMarkdown({
|
|
|
151
175
|
// Per Project ---------------------------------------------------------
|
|
152
176
|
const projects = perProject(records);
|
|
153
177
|
if (projects.length > 0) {
|
|
154
|
-
const max = projects[0].tokensTotal;
|
|
155
178
|
out.push(`## Per Project`);
|
|
156
179
|
out.push(``);
|
|
157
|
-
out.push(`|
|
|
158
|
-
out.push(
|
|
180
|
+
out.push(`| Project | Sessions | Input | Output | Cache | Total | Dist |`);
|
|
181
|
+
out.push(`|---|---:|---:|---:|---:|---:|---|`);
|
|
159
182
|
for (const p of projects) {
|
|
160
183
|
const cache = p.tokensCacheRead + p.tokensCacheWrite;
|
|
161
184
|
const proj = compactHome(p.project);
|
|
162
|
-
out.push(`|
|
|
185
|
+
out.push(`| \`${mdEscape(proj)}\` | ${fmtInt(p.n)} | ${fmtCompact(p.tokensInput)} | ${fmtCompact(p.tokensOutput)} | ${fmtCompact(cache)} | ${fmtCompact(p.tokensTotal)} | ${stackedBar(p.byTool, 20)} |`);
|
|
186
|
+
}
|
|
187
|
+
out.push(``);
|
|
188
|
+
const legend = TOOLS
|
|
189
|
+
.filter(t => t.hasTokens)
|
|
190
|
+
.map(t => `\`${t.barChar}\` ${t.label}`)
|
|
191
|
+
.join(' · ');
|
|
192
|
+
out.push(`_Bar legend: ${legend} · \`·\` other_`);
|
|
193
|
+
out.push(``);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Per Tool ------------------------------------------------------------
|
|
197
|
+
const toolRows = perTool(records);
|
|
198
|
+
if (toolRows.length > 0) {
|
|
199
|
+
const max = toolRows[0].tokensTotal;
|
|
200
|
+
out.push(`## Per Tool`);
|
|
201
|
+
out.push(``);
|
|
202
|
+
out.push(`| Tool | Sessions | Input | Output | Cache | Total | Cost | Avg/sess | Dist |`);
|
|
203
|
+
out.push(`|---|---:|---:|---:|---:|---:|---:|---:|---|`);
|
|
204
|
+
for (const p of toolRows) {
|
|
205
|
+
const cache = p.tokensCacheRead + p.tokensCacheWrite;
|
|
206
|
+
out.push(`| ${mdEscape(p.tool)} | ${fmtInt(p.n)} | ${fmtCompact(p.tokensInput)} | ${fmtCompact(p.tokensOutput)} | ${fmtCompact(cache)} | ${fmtCompact(p.tokensTotal)} | ${fmtCost(p.cost)} | ${fmtCompact(p.avg)} | ${bar(p.tokensTotal, max, 20)} |`);
|
|
207
|
+
}
|
|
208
|
+
out.push(``);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Per Tool per Month ---------------------------------------------------
|
|
212
|
+
const tpmRows = perToolPerMonth(records);
|
|
213
|
+
if (tpmRows.length > 0) {
|
|
214
|
+
const max = tpmRows[0].tokensTotal;
|
|
215
|
+
out.push(`## Per Tool per Month`);
|
|
216
|
+
out.push(``);
|
|
217
|
+
out.push(`| Tool | Month | Sessions | Input | Output | Cache | Total | Dist |`);
|
|
218
|
+
out.push(`|---|---|---:|---:|---:|---:|---:|---|`);
|
|
219
|
+
for (const p of tpmRows) {
|
|
220
|
+
const cache = p.tokensCacheRead + p.tokensCacheWrite;
|
|
221
|
+
const yyyy = p.month.slice(0, 4);
|
|
222
|
+
const mm = p.month.slice(5, 7);
|
|
223
|
+
const label = `${MONTH_NAMES[mm] || mm} ${yyyy}`;
|
|
224
|
+
out.push(`| ${mdEscape(p.tool)} | ${label} | ${fmtInt(p.n)} | ${fmtCompact(p.tokensInput)} | ${fmtCompact(p.tokensOutput)} | ${fmtCompact(cache)} | ${fmtCompact(p.tokensTotal)} | ${bar(p.tokensTotal, max, 18)} |`);
|
|
163
225
|
}
|
|
164
226
|
out.push(``);
|
|
165
227
|
}
|
|
@@ -167,16 +229,15 @@ export function renderMarkdown({
|
|
|
167
229
|
// Per Month -----------------------------------------------------------
|
|
168
230
|
const months = perMonth(records);
|
|
169
231
|
if (months.length > 0) {
|
|
170
|
-
const max = Math.max(...months.map(m => m.tokensTotal));
|
|
171
232
|
out.push(`## Per Month`);
|
|
172
233
|
out.push(``);
|
|
173
|
-
out.push(`| Month | Sessions | Input | Output | Total |
|
|
174
|
-
out.push(
|
|
234
|
+
out.push(`| Month | Sessions | Input | Output | Total | Dist |`);
|
|
235
|
+
out.push(`|---|---:|---:|---:|---:|---|`);
|
|
175
236
|
for (const m of months) {
|
|
176
237
|
const yyyy = m.month.slice(0, 4);
|
|
177
238
|
const mm = m.month.slice(5, 7);
|
|
178
239
|
const label = `${MONTH_NAMES[mm] || mm} ${yyyy}`;
|
|
179
|
-
out.push(`| ${label} | ${fmtInt(m.n)} | ${fmtCompact(m.tokensInput)} | ${fmtCompact(m.tokensOutput)} | ${fmtCompact(m.tokensTotal)} | ${
|
|
240
|
+
out.push(`| ${label} | ${fmtInt(m.n)} | ${fmtCompact(m.tokensInput)} | ${fmtCompact(m.tokensOutput)} | ${fmtCompact(m.tokensTotal)} | ${stackedBar(m.byTool, 20)} |`);
|
|
180
241
|
}
|
|
181
242
|
out.push(``);
|
|
182
243
|
}
|
|
@@ -184,13 +245,12 @@ export function renderMarkdown({
|
|
|
184
245
|
// Per Week ------------------------------------------------------------
|
|
185
246
|
const weeks = perWeek(records);
|
|
186
247
|
if (weeks.length > 0) {
|
|
187
|
-
const max = Math.max(...weeks.map(w => w.tokensTotal));
|
|
188
248
|
out.push(`## Per Week`);
|
|
189
249
|
out.push(``);
|
|
190
|
-
out.push(`| Week | Sessions | Input | Output | Total |
|
|
191
|
-
out.push(
|
|
250
|
+
out.push(`| Week | Sessions | Input | Output | Total | Dist |`);
|
|
251
|
+
out.push(`|---|---:|---:|---:|---:|---|`);
|
|
192
252
|
for (const w of weeks) {
|
|
193
|
-
out.push(`| ${w.week} | ${fmtInt(w.n)} | ${fmtCompact(w.tokensInput)} | ${fmtCompact(w.tokensOutput)} | ${fmtCompact(w.tokensTotal)} | ${
|
|
253
|
+
out.push(`| ${w.week} | ${fmtInt(w.n)} | ${fmtCompact(w.tokensInput)} | ${fmtCompact(w.tokensOutput)} | ${fmtCompact(w.tokensTotal)} | ${stackedBar(w.byTool, 18)} |`);
|
|
194
254
|
}
|
|
195
255
|
out.push(``);
|
|
196
256
|
}
|
package/src/render.js
CHANGED
|
@@ -6,9 +6,10 @@ import boxen from 'boxen';
|
|
|
6
6
|
import gradient from 'gradient-string';
|
|
7
7
|
import process from 'node:process';
|
|
8
8
|
import {
|
|
9
|
-
perProject, perMonth, perWeek, perTool, overall, topSessions, tokenBreakdown,
|
|
9
|
+
perProject, perMonth, perWeek, perTool, perToolPerMonth, overall, topSessions, tokenBreakdown,
|
|
10
10
|
MONTH_NAMES,
|
|
11
11
|
} from './aggregate.js';
|
|
12
|
+
import { getToolColor } from './tools.js';
|
|
12
13
|
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
14
15
|
// Terminal width detection
|
|
@@ -63,34 +64,45 @@ export function fmtCost(n) {
|
|
|
63
64
|
// Color helpers
|
|
64
65
|
// ---------------------------------------------------------------------------
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
copilot: 'green',
|
|
72
|
-
antigravity: 'red',
|
|
73
|
-
gemini: 'gray',
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
export function toolColor(t) { return TOOL_COLORS[t] || 'white'; }
|
|
77
|
-
export function colorize(t, c) { return chalk.hex(toHex(c))(t); }
|
|
78
|
-
|
|
79
|
-
function toHex(name) {
|
|
80
|
-
const m = {
|
|
81
|
-
red: '#ff5555', green: '#50fa7b', yellow: '#f1fa8c',
|
|
82
|
-
blue: '#8be9fd', magenta: '#ff79c6', cyan: '#8be9fd',
|
|
83
|
-
white: '#f8f8f2', gray: '#6272a4',
|
|
84
|
-
};
|
|
85
|
-
return m[name] || '#ffffff';
|
|
86
|
-
}
|
|
67
|
+
export function toolColor(t) { return getToolColor(t); }
|
|
68
|
+
export function colorize(t, c) { return chalk.hex(c)(t); }
|
|
69
|
+
|
|
70
|
+
// Cool-to-warm gradient: small bars feel subtle, large bars pop.
|
|
71
|
+
const BAR_GRADIENT = gradient(['#3b82f6', '#14b8a6', '#f1fa8c', '#ff9e64']);
|
|
87
72
|
|
|
88
|
-
function bar(value, max, width
|
|
73
|
+
function bar(value, max, width) {
|
|
89
74
|
if (max <= 0) return ' '.repeat(width);
|
|
90
75
|
const pct = Math.max(0, Math.min(1, value / max));
|
|
91
76
|
const filled = Math.round(pct * width);
|
|
92
77
|
const empty = width - filled;
|
|
93
|
-
|
|
78
|
+
if (filled === 0) return chalk.gray('░'.repeat(width));
|
|
79
|
+
return BAR_GRADIENT('█'.repeat(filled)) + chalk.gray('░'.repeat(empty));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Stacked bar — one segment per tool, widths proportional to that tool's
|
|
83
|
+
// share of the row's total. Used wherever a row spans multiple tools
|
|
84
|
+
// (per-project / per-month / per-week). Single-tool rows are just one
|
|
85
|
+
// colored segment; no change in feel.
|
|
86
|
+
function stackedBar(byTool, width) {
|
|
87
|
+
const total = Object.values(byTool).reduce((a, b) => a + b, 0);
|
|
88
|
+
if (total <= 0 || width <= 0) return ' '.repeat(Math.max(0, width));
|
|
89
|
+
const entries = Object.entries(byTool)
|
|
90
|
+
.filter(([, v]) => v > 0)
|
|
91
|
+
.sort((a, b) => b[1] - a[1]);
|
|
92
|
+
let out = '';
|
|
93
|
+
let x = 0;
|
|
94
|
+
for (let i = 0; i < entries.length; i++) {
|
|
95
|
+
const [tool, value] = entries[i];
|
|
96
|
+
// Last segment fills the remaining width so rounding doesn't leave
|
|
97
|
+
// a visible gap or overshoot.
|
|
98
|
+
const segW = i === entries.length - 1
|
|
99
|
+
? width - x
|
|
100
|
+
: Math.round((value / total) * width);
|
|
101
|
+
if (segW <= 0) continue;
|
|
102
|
+
out += chalk.hex(toolColor(tool))('█'.repeat(segW));
|
|
103
|
+
x += segW;
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
94
106
|
}
|
|
95
107
|
|
|
96
108
|
function shortModel(m) {
|
|
@@ -250,23 +262,60 @@ function pct(x) { return (x * 100).toFixed(1) + '%'; }
|
|
|
250
262
|
export function renderPerProject(records) {
|
|
251
263
|
const items = perProject(records);
|
|
252
264
|
if (items.length === 0) return '';
|
|
253
|
-
const max = items[0].tokensTotal;
|
|
254
265
|
const barW = NARROW ? 10 : 18;
|
|
255
266
|
|
|
256
267
|
// Narrow mode: drop In/Out/Cache columns, just show n + Total + Dist
|
|
257
268
|
const head = NARROW
|
|
258
|
-
? [chalk.bold('
|
|
269
|
+
? [chalk.bold('Project'), chalk.bold('n'),
|
|
259
270
|
chalk.bold('Total'), chalk.bold('Dist')]
|
|
260
|
-
: [chalk.bold('
|
|
271
|
+
: [chalk.bold('Project'), chalk.bold('n'),
|
|
261
272
|
chalk.bold('In'), chalk.bold('Out'),
|
|
262
273
|
chalk.bold('Cache'), chalk.bold('Total'), chalk.bold('Dist')];
|
|
263
274
|
const t = new Table({ head, style: { head: [], border: [] } });
|
|
264
275
|
|
|
276
|
+
for (const p of items) {
|
|
277
|
+
const cache = p.tokensCacheRead + p.tokensCacheWrite;
|
|
278
|
+
const row = [truncEnd(p.project, NARROW ? 28 : 40), fmtInt(p.n)];
|
|
279
|
+
if (!NARROW) {
|
|
280
|
+
row.push(
|
|
281
|
+
fmtCompact(p.tokensInput),
|
|
282
|
+
fmtCompact(p.tokensOutput),
|
|
283
|
+
fmtCompact(cache),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
row.push(fmtCompact(p.tokensTotal), stackedBar(p.byTool, barW));
|
|
287
|
+
t.push(row);
|
|
288
|
+
}
|
|
289
|
+
return boxen(t.toString(), {
|
|
290
|
+
title: chalk.bold('Per Project'),
|
|
291
|
+
borderStyle: 'round',
|
|
292
|
+
borderColor: 'cyan',
|
|
293
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Per Tool (detailed — complements the brief summary in Overview)
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
export function renderPerTool(records) {
|
|
302
|
+
const items = perTool(records);
|
|
303
|
+
if (items.length === 0) return '';
|
|
304
|
+
const max = items[0].tokensTotal;
|
|
305
|
+
const barW = NARROW ? 10 : 18;
|
|
306
|
+
|
|
307
|
+
const head = NARROW
|
|
308
|
+
? [chalk.bold('Tool'), chalk.bold('n'),
|
|
309
|
+
chalk.bold('Total'), chalk.bold('Cost'), chalk.bold('Avg/sess'), chalk.bold('Dist')]
|
|
310
|
+
: [chalk.bold('Tool'), chalk.bold('n'),
|
|
311
|
+
chalk.bold('Input'), chalk.bold('Output'), chalk.bold('Cache'),
|
|
312
|
+
chalk.bold('Total'), chalk.bold('Cost'), chalk.bold('Avg/sess'), chalk.bold('Dist')];
|
|
313
|
+
const t = new Table({ head, style: { head: [], border: [] } });
|
|
314
|
+
|
|
265
315
|
for (const p of items) {
|
|
266
316
|
const cache = p.tokensCacheRead + p.tokensCacheWrite;
|
|
267
317
|
const row = [
|
|
268
318
|
colorize(p.tool, toolColor(p.tool)),
|
|
269
|
-
truncEnd(p.project, NARROW ? 26 : 38),
|
|
270
319
|
fmtInt(p.n),
|
|
271
320
|
];
|
|
272
321
|
if (!NARROW) {
|
|
@@ -278,12 +327,63 @@ export function renderPerProject(records) {
|
|
|
278
327
|
}
|
|
279
328
|
row.push(
|
|
280
329
|
fmtCompact(p.tokensTotal),
|
|
281
|
-
|
|
330
|
+
fmtCost(p.cost),
|
|
331
|
+
fmtCompact(p.avg),
|
|
332
|
+
bar(p.tokensTotal, max, barW),
|
|
282
333
|
);
|
|
283
334
|
t.push(row);
|
|
284
335
|
}
|
|
285
336
|
return boxen(t.toString(), {
|
|
286
|
-
title: chalk.bold('Per
|
|
337
|
+
title: chalk.bold('Per Tool'),
|
|
338
|
+
borderStyle: 'round',
|
|
339
|
+
borderColor: 'magenta',
|
|
340
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// Per Tool per Month (cross-tab — one row per (tool, month))
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
export function renderPerToolPerMonth(records) {
|
|
349
|
+
const items = perToolPerMonth(records);
|
|
350
|
+
if (items.length === 0) return '';
|
|
351
|
+
const max = items[0].tokensTotal;
|
|
352
|
+
const barW = NARROW ? 8 : 16;
|
|
353
|
+
|
|
354
|
+
const head = NARROW
|
|
355
|
+
? [chalk.bold('Tool'), chalk.bold('Month'), chalk.bold('n'),
|
|
356
|
+
chalk.bold('Total'), chalk.bold('Dist')]
|
|
357
|
+
: [chalk.bold('Tool'), chalk.bold('Month'), chalk.bold('n'),
|
|
358
|
+
chalk.bold('Input'), chalk.bold('Output'), chalk.bold('Cache'),
|
|
359
|
+
chalk.bold('Total'), chalk.bold('Dist')];
|
|
360
|
+
const t = new Table({ head, style: { head: [], border: [] } });
|
|
361
|
+
|
|
362
|
+
for (const p of items) {
|
|
363
|
+
const cache = p.tokensCacheRead + p.tokensCacheWrite;
|
|
364
|
+
const yyyy = p.month.slice(0, 4);
|
|
365
|
+
const mm = p.month.slice(5, 7);
|
|
366
|
+
const monthLabel = `${MONTH_NAMES[mm] || mm} ${yyyy}`;
|
|
367
|
+
const row = [
|
|
368
|
+
colorize(p.tool, toolColor(p.tool)),
|
|
369
|
+
chalk.bold(monthLabel),
|
|
370
|
+
fmtInt(p.n),
|
|
371
|
+
];
|
|
372
|
+
if (!NARROW) {
|
|
373
|
+
row.push(
|
|
374
|
+
fmtCompact(p.tokensInput),
|
|
375
|
+
fmtCompact(p.tokensOutput),
|
|
376
|
+
fmtCompact(cache),
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
row.push(
|
|
380
|
+
fmtCompact(p.tokensTotal),
|
|
381
|
+
bar(p.tokensTotal, max, barW),
|
|
382
|
+
);
|
|
383
|
+
t.push(row);
|
|
384
|
+
}
|
|
385
|
+
return boxen(t.toString(), {
|
|
386
|
+
title: chalk.bold('Per Tool per Month'),
|
|
287
387
|
borderStyle: 'round',
|
|
288
388
|
borderColor: 'cyan',
|
|
289
389
|
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
@@ -297,64 +397,42 @@ export function renderPerProject(records) {
|
|
|
297
397
|
export function renderPerMonth(records) {
|
|
298
398
|
const items = perMonth(records);
|
|
299
399
|
if (items.length === 0) return '';
|
|
300
|
-
const max = Math.max(...items.map(p => p.tokensTotal));
|
|
301
400
|
const barW = NARROW ? 10 : 16;
|
|
302
|
-
const t1 = perTool(records);
|
|
303
401
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
: [chalk.bold('Month'), chalk.bold('n'),
|
|
310
|
-
chalk.bold('Input'), chalk.bold('Output'),
|
|
311
|
-
chalk.bold('Total'), chalk.bold('OC'),
|
|
312
|
-
chalk.bold('CX'), chalk.bold('MM'), chalk.bold('Dist')];
|
|
402
|
+
const head = [
|
|
403
|
+
chalk.bold('Month'), chalk.bold('n'),
|
|
404
|
+
chalk.bold('Input'), chalk.bold('Output'),
|
|
405
|
+
chalk.bold('Total'), chalk.bold('Dist'),
|
|
406
|
+
];
|
|
313
407
|
const t = new Table({ head, style: { head: [], border: [] } });
|
|
314
408
|
|
|
315
409
|
for (const p of items) {
|
|
316
410
|
const yyyy = p.month.slice(0, 4);
|
|
317
411
|
const mm = p.month.slice(5, 7);
|
|
318
412
|
const label = `${MONTH_NAMES[mm] || mm} ${yyyy}`;
|
|
319
|
-
|
|
413
|
+
t.push([
|
|
320
414
|
chalk.bold(label),
|
|
321
415
|
fmtInt(p.n),
|
|
322
416
|
fmtCompact(p.tokensInput),
|
|
323
417
|
fmtCompact(p.tokensOutput),
|
|
324
418
|
fmtCompact(p.tokensTotal),
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
row.push(
|
|
328
|
-
p.byTool.opencode ? fmtCompact(p.byTool.opencode) : chalk.dim('—'),
|
|
329
|
-
p.byTool.codex ? fmtCompact(p.byTool.codex) : chalk.dim('—'),
|
|
330
|
-
p.byTool.mimocode ? fmtCompact(p.byTool.mimocode) : chalk.dim('—'),
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
row.push(bar(p.tokensTotal, max, barW, 'green'));
|
|
334
|
-
t.push(row);
|
|
419
|
+
stackedBar(p.byTool, barW),
|
|
420
|
+
]);
|
|
335
421
|
}
|
|
336
422
|
|
|
337
423
|
// TOTAL row
|
|
338
424
|
const tot = overall(records);
|
|
339
|
-
|
|
425
|
+
t.push([
|
|
340
426
|
chalk.bgGray.white.bold(' TOTAL '),
|
|
341
427
|
chalk.bgGray.white.bold(fmtInt(records.length)),
|
|
342
428
|
chalk.bgGray.white.bold(fmtCompact(tot.tokensInput)),
|
|
343
429
|
chalk.bgGray.white.bold(fmtCompact(tot.tokensOutput)),
|
|
344
430
|
chalk.bgGray.white.bold(fmtCompact(tot.tokensTotal)),
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
totalRow.push(
|
|
348
|
-
chalk.bgGray.white.bold(fmtCompact(t1.find(p => p.tool === 'opencode')?.tokensTotal || 0)),
|
|
349
|
-
chalk.bgGray.white.bold(fmtCompact(t1.find(p => p.tool === 'codex')?.tokensTotal || 0)),
|
|
350
|
-
chalk.bgGray.white.bold(fmtCompact(t1.find(p => p.tool === 'mimocode')?.tokensTotal || 0)),
|
|
351
|
-
);
|
|
352
|
-
}
|
|
353
|
-
totalRow.push('');
|
|
354
|
-
t.push(totalRow);
|
|
431
|
+
'',
|
|
432
|
+
]);
|
|
355
433
|
|
|
356
434
|
return boxen(t.toString(), {
|
|
357
|
-
title: chalk.bold('Per
|
|
435
|
+
title: chalk.bold('Per Month'),
|
|
358
436
|
borderStyle: 'round',
|
|
359
437
|
borderColor: 'green',
|
|
360
438
|
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
@@ -368,60 +446,38 @@ export function renderPerMonth(records) {
|
|
|
368
446
|
export function renderPerWeek(records) {
|
|
369
447
|
const items = perWeek(records);
|
|
370
448
|
if (items.length === 0) return '';
|
|
371
|
-
const max = Math.max(...items.map(p => p.tokensTotal));
|
|
372
449
|
const barW = NARROW ? 10 : 14;
|
|
373
|
-
const t1 = perTool(records);
|
|
374
450
|
|
|
375
|
-
const head =
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
chalk.bold('Input'), chalk.bold('Output'),
|
|
381
|
-
chalk.bold('Total'), chalk.bold('OC'),
|
|
382
|
-
chalk.bold('CX'), chalk.bold('MM'), chalk.bold('Dist')];
|
|
451
|
+
const head = [
|
|
452
|
+
chalk.bold('ISO Week'), chalk.bold('n'),
|
|
453
|
+
chalk.bold('Input'), chalk.bold('Output'),
|
|
454
|
+
chalk.bold('Total'), chalk.bold('Dist'),
|
|
455
|
+
];
|
|
383
456
|
const t = new Table({ head, style: { head: [], border: [] } });
|
|
384
457
|
|
|
385
458
|
for (const p of items) {
|
|
386
|
-
|
|
387
|
-
const row = [
|
|
459
|
+
t.push([
|
|
388
460
|
chalk.bold(p.week),
|
|
389
461
|
fmtInt(p.n),
|
|
390
462
|
fmtCompact(p.tokensInput),
|
|
391
463
|
fmtCompact(p.tokensOutput),
|
|
392
464
|
fmtCompact(p.tokensTotal),
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
row.push(
|
|
396
|
-
p.byTool.opencode ? fmtCompact(p.byTool.opencode) : chalk.dim('—'),
|
|
397
|
-
p.byTool.codex ? fmtCompact(p.byTool.codex) : chalk.dim('—'),
|
|
398
|
-
p.byTool.mimocode ? fmtCompact(p.byTool.mimocode) : chalk.dim('—'),
|
|
399
|
-
);
|
|
400
|
-
}
|
|
401
|
-
row.push(bar(p.tokensTotal, max, barW, toolColor(dominantTool)));
|
|
402
|
-
t.push(row);
|
|
465
|
+
stackedBar(p.byTool, barW),
|
|
466
|
+
]);
|
|
403
467
|
}
|
|
404
468
|
// TOTAL row
|
|
405
469
|
const tot = overall(records);
|
|
406
|
-
|
|
470
|
+
t.push([
|
|
407
471
|
chalk.bgGray.white.bold(' TOTAL '),
|
|
408
472
|
chalk.bgGray.white.bold(fmtInt(records.length)),
|
|
409
473
|
chalk.bgGray.white.bold(fmtCompact(tot.tokensInput)),
|
|
410
474
|
chalk.bgGray.white.bold(fmtCompact(tot.tokensOutput)),
|
|
411
475
|
chalk.bgGray.white.bold(fmtCompact(tot.tokensTotal)),
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
totalRow.push(
|
|
415
|
-
chalk.bgGray.white.bold(fmtCompact(t1.find(p => p.tool === 'opencode')?.tokensTotal || 0)),
|
|
416
|
-
chalk.bgGray.white.bold(fmtCompact(t1.find(p => p.tool === 'codex')?.tokensTotal || 0)),
|
|
417
|
-
chalk.bgGray.white.bold(fmtCompact(t1.find(p => p.tool === 'mimocode')?.tokensTotal || 0)),
|
|
418
|
-
);
|
|
419
|
-
}
|
|
420
|
-
totalRow.push('');
|
|
421
|
-
t.push(totalRow);
|
|
476
|
+
'',
|
|
477
|
+
]);
|
|
422
478
|
|
|
423
479
|
return boxen(t.toString(), {
|
|
424
|
-
title: chalk.bold('Per
|
|
480
|
+
title: chalk.bold('Per Week'),
|
|
425
481
|
borderStyle: 'round',
|
|
426
482
|
borderColor: 'magenta',
|
|
427
483
|
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Single source of truth for every AI coding tool this analyzer supports.
|
|
2
|
+
//
|
|
3
|
+
// Each tool entry describes both:
|
|
4
|
+
// - How to find it on disk (kind, envVar, candidatePaths, count)
|
|
5
|
+
// - How to render it in the report (name, label, color, barChar)
|
|
6
|
+
//
|
|
7
|
+
// To add a new tool, append one entry to TOOLS. If the tool exposes token
|
|
8
|
+
// data, also wire a loader branch in src/loaders.js.
|
|
9
|
+
|
|
10
|
+
import { statSync, readdirSync, existsSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { homedir, platform } from 'node:os';
|
|
13
|
+
import { env } from 'node:process';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
|
|
17
|
+
const HOME = homedir();
|
|
18
|
+
const OS = platform(); // 'darwin' | 'linux' | 'win32'
|
|
19
|
+
|
|
20
|
+
const APP_SUPPORT = OS === 'darwin'
|
|
21
|
+
? join(HOME, 'Library', 'Application Support')
|
|
22
|
+
: env.XDG_DATA_HOME
|
|
23
|
+
? join(env.XDG_DATA_HOME, '..') // XDG_DATA_HOME/../ = ~/.local/share
|
|
24
|
+
: join(HOME, '.local', 'share');
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Reusable count helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
function countJsonlRecursive(dir) {
|
|
31
|
+
if (!dir) return 0;
|
|
32
|
+
let n = 0;
|
|
33
|
+
function walk(d) {
|
|
34
|
+
try {
|
|
35
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
36
|
+
const full = join(d, e.name);
|
|
37
|
+
if (e.isDirectory()) walk(full);
|
|
38
|
+
else if (e.isFile() && e.name.endsWith('.jsonl')) n++;
|
|
39
|
+
}
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
walk(dir);
|
|
43
|
+
return n;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function countSqliteRows(dbPath) {
|
|
47
|
+
if (!dbPath) return 0;
|
|
48
|
+
try {
|
|
49
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
50
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
51
|
+
return db.prepare('SELECT COUNT(*) AS c FROM session').get().c;
|
|
52
|
+
} catch { return 0; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function countSubdirs(dir) {
|
|
56
|
+
if (!dir) return 0;
|
|
57
|
+
let n = 0;
|
|
58
|
+
try {
|
|
59
|
+
for (const e of readdirSync(dir)) {
|
|
60
|
+
const full = join(dir, e);
|
|
61
|
+
if (statSync(full).isDirectory()) n++;
|
|
62
|
+
}
|
|
63
|
+
} catch {}
|
|
64
|
+
return n;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function countProtobuf(dir) {
|
|
68
|
+
if (!dir) return 0;
|
|
69
|
+
let n = 0;
|
|
70
|
+
try {
|
|
71
|
+
for (const f of readdirSync(dir)) {
|
|
72
|
+
if (f.endsWith('.pb')) n++;
|
|
73
|
+
}
|
|
74
|
+
} catch {}
|
|
75
|
+
return n;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Tool definitions
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
export const TOOLS = [
|
|
83
|
+
// -----------------------------------------------------------------------
|
|
84
|
+
// Claude Code
|
|
85
|
+
// -----------------------------------------------------------------------
|
|
86
|
+
{
|
|
87
|
+
key: 'claude',
|
|
88
|
+
name: 'Claude Code',
|
|
89
|
+
label: 'claude',
|
|
90
|
+
color: '#ff9e64', // orange
|
|
91
|
+
barChar: '█',
|
|
92
|
+
kind: 'jsonl',
|
|
93
|
+
envVar: 'CLAUDE_HOME',
|
|
94
|
+
candidatePaths: () => [
|
|
95
|
+
env.CLAUDE_HOME
|
|
96
|
+
? join(env.CLAUDE_HOME, 'projects')
|
|
97
|
+
: join(HOME, '.claude', 'projects'),
|
|
98
|
+
],
|
|
99
|
+
count: countJsonlRecursive,
|
|
100
|
+
hasTokens: true,
|
|
101
|
+
description: '~/.claude/projects/*/<UUID>.jsonl (per-message usage in assistant lines)',
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
// -----------------------------------------------------------------------
|
|
105
|
+
// Codex
|
|
106
|
+
// -----------------------------------------------------------------------
|
|
107
|
+
{
|
|
108
|
+
key: 'codex',
|
|
109
|
+
name: 'Codex',
|
|
110
|
+
label: 'codex',
|
|
111
|
+
color: '#87ceeb', // sky blue
|
|
112
|
+
barChar: '▓',
|
|
113
|
+
kind: 'jsonl-rollout',
|
|
114
|
+
envVar: 'CODEX_HOME',
|
|
115
|
+
candidatePaths: () => [
|
|
116
|
+
env.CODEX_HOME || join(HOME, '.codex', 'sessions'),
|
|
117
|
+
],
|
|
118
|
+
count: countJsonlRecursive,
|
|
119
|
+
hasTokens: true,
|
|
120
|
+
description: '~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl',
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
// -----------------------------------------------------------------------
|
|
124
|
+
// OpenCode
|
|
125
|
+
// -----------------------------------------------------------------------
|
|
126
|
+
{
|
|
127
|
+
key: 'opencode',
|
|
128
|
+
name: 'OpenCode',
|
|
129
|
+
label: 'opencode',
|
|
130
|
+
color: '#f8f8f2', // white
|
|
131
|
+
barChar: '▒',
|
|
132
|
+
kind: 'sqlite',
|
|
133
|
+
envVar: 'OPENCODE_HOME',
|
|
134
|
+
candidatePaths: () => [
|
|
135
|
+
env.OPENCODE_HOME
|
|
136
|
+
? join(env.OPENCODE_HOME, 'opencode.db')
|
|
137
|
+
: join(HOME, '.local', 'share', 'opencode', 'opencode.db'),
|
|
138
|
+
],
|
|
139
|
+
count: countSqliteRows,
|
|
140
|
+
hasTokens: true,
|
|
141
|
+
description: '~/.local/share/opencode/opencode.db (tokens + cost)',
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
// -----------------------------------------------------------------------
|
|
145
|
+
// MimoCode (same schema as OpenCode)
|
|
146
|
+
// -----------------------------------------------------------------------
|
|
147
|
+
{
|
|
148
|
+
key: 'mimocode',
|
|
149
|
+
name: 'MimoCode',
|
|
150
|
+
label: 'mimocode',
|
|
151
|
+
color: '#f1fa8c', // yellow
|
|
152
|
+
barChar: '░',
|
|
153
|
+
kind: 'sqlite',
|
|
154
|
+
envVar: 'MIMOCODE_HOME',
|
|
155
|
+
candidatePaths: () => [
|
|
156
|
+
env.MIMOCODE_HOME
|
|
157
|
+
? join(env.MIMOCODE_HOME, 'mimocode.db')
|
|
158
|
+
: join(HOME, '.local', 'share', 'mimocode', 'mimocode.db'),
|
|
159
|
+
],
|
|
160
|
+
count: countSqliteRows,
|
|
161
|
+
hasTokens: true,
|
|
162
|
+
description: '~/.local/share/mimocode/mimocode.db (tokens + cost)',
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
// -----------------------------------------------------------------------
|
|
166
|
+
// GitHub Copilot CLI (presence only)
|
|
167
|
+
// -----------------------------------------------------------------------
|
|
168
|
+
{
|
|
169
|
+
key: 'copilot',
|
|
170
|
+
name: 'GitHub Copilot',
|
|
171
|
+
label: 'copilot',
|
|
172
|
+
color: '#3b82f6', // deep blue
|
|
173
|
+
barChar: '·',
|
|
174
|
+
kind: 'jsonl-events',
|
|
175
|
+
envVar: 'COPILOT_HOME',
|
|
176
|
+
candidatePaths: () => {
|
|
177
|
+
const base = env.COPILOT_HOME || join(HOME, '.copilot');
|
|
178
|
+
return [
|
|
179
|
+
join(base, 'session-state'),
|
|
180
|
+
base,
|
|
181
|
+
];
|
|
182
|
+
},
|
|
183
|
+
count: countJsonlRecursive,
|
|
184
|
+
hasTokens: false,
|
|
185
|
+
description: '~/.copilot/session-state/*/events.jsonl (no token data)',
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
// -----------------------------------------------------------------------
|
|
189
|
+
// Antigravity (VS Code variant) - mostly cache; no token data
|
|
190
|
+
// -----------------------------------------------------------------------
|
|
191
|
+
{
|
|
192
|
+
key: 'antigravity',
|
|
193
|
+
name: 'Antigravity',
|
|
194
|
+
label: 'antigravity',
|
|
195
|
+
color: '#ff5555', // red
|
|
196
|
+
barChar: '·',
|
|
197
|
+
kind: 'dir',
|
|
198
|
+
envVar: 'ANTIGRAVITY_HOME',
|
|
199
|
+
candidatePaths: () => [
|
|
200
|
+
env.ANTIGRAVITY_HOME || APP_SUPPORT + '/Antigravity',
|
|
201
|
+
join(HOME, '.antigravity'),
|
|
202
|
+
],
|
|
203
|
+
count: countSubdirs,
|
|
204
|
+
hasTokens: false,
|
|
205
|
+
description: '~/Library/Application Support/Antigravity (no token data)',
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
// -----------------------------------------------------------------------
|
|
209
|
+
// Gemini CLI
|
|
210
|
+
// -----------------------------------------------------------------------
|
|
211
|
+
{
|
|
212
|
+
key: 'gemini',
|
|
213
|
+
name: 'Gemini CLI',
|
|
214
|
+
label: 'gemini',
|
|
215
|
+
color: '#14b8a6', // teal
|
|
216
|
+
barChar: '·',
|
|
217
|
+
kind: 'protobuf',
|
|
218
|
+
envVar: 'GEMINI_HOME',
|
|
219
|
+
candidatePaths: () => {
|
|
220
|
+
const base = env.GEMINI_HOME || join(HOME, '.gemini');
|
|
221
|
+
return [
|
|
222
|
+
join(base, 'antigravity', 'conversations'),
|
|
223
|
+
join(base, 'conversations'),
|
|
224
|
+
];
|
|
225
|
+
},
|
|
226
|
+
count: countProtobuf,
|
|
227
|
+
hasTokens: false,
|
|
228
|
+
description: '~/.gemini/antigravity/conversations/*.pb (binary, no token data)',
|
|
229
|
+
},
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Lookup helpers — consumers should use these instead of indexing TOOLS
|
|
234
|
+
// directly so the null-handling stays in one place.
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
const TOOL_BY_KEY = new Map(TOOLS.map(t => [t.key, t]));
|
|
238
|
+
const FALLBACK_COLOR = '#ffffff';
|
|
239
|
+
const FALLBACK_BAR_CHAR = '·';
|
|
240
|
+
|
|
241
|
+
export function getTool(key) {
|
|
242
|
+
return TOOL_BY_KEY.get(key) || null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function getToolColor(key) {
|
|
246
|
+
return TOOL_BY_KEY.get(key)?.color ?? FALLBACK_COLOR;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function getToolBarChar(key) {
|
|
250
|
+
return TOOL_BY_KEY.get(key)?.barChar ?? FALLBACK_BAR_CHAR;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function getToolLabel(key) {
|
|
254
|
+
return TOOL_BY_KEY.get(key)?.label ?? key;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Stable UI column order — derived from TOOLS so adding a tool only
|
|
258
|
+
// requires one edit in this file.
|
|
259
|
+
export const TOOL_ORDER = TOOLS.map(t => t.key);
|