ai-usage-analyzer 0.4.0 → 0.5.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/src/render.js CHANGED
@@ -72,9 +72,10 @@ export function fmtCompact(n) {
72
72
  return String(n);
73
73
  }
74
74
 
75
- export function fmtCost(n) {
75
+ export function fmtCost(n, status) {
76
+ if (status === "unavailable") return "n/a";
76
77
  if (!n) return "—";
77
- return "$" + Number(n).toFixed(2);
78
+ return (status === "estimated" || status === "partial" ? "~" : "") + "$" + Number(n).toFixed(2) + (status === "partial" ? "*" : "");
78
79
  }
79
80
  function fmtDuration(ms) {
80
81
  if (!ms || ms <= 0) return chalk.dim("—");
@@ -175,6 +176,7 @@ export function renderHeader({
175
176
  totalSessions,
176
177
  totalTokens,
177
178
  totalCost,
179
+ costStatus,
178
180
  dateRange,
179
181
  }) {
180
182
  const title = gradient.pastel.multiline("◆ AI TOKEN ANALYZER ◆");
@@ -183,7 +185,7 @@ export function renderHeader({
183
185
  " • " +
184
186
  chalk.bold.cyan(`${fmtCompact(totalTokens)} total tokens`) +
185
187
  (totalCost
186
- ? " • " + chalk.bold.yellow(`$${totalCost.toFixed(2)} USD`)
188
+ ? " • " + chalk.bold.yellow(`${fmtCost(totalCost, costStatus)} USD`)
187
189
  : "");
188
190
  const range =
189
191
  dateRange[0] && dateRange[1]
@@ -338,7 +340,7 @@ export function renderOverview(records, detections) {
338
340
  fmtInt(p.n),
339
341
  fmtCompact(p.tokensTotal),
340
342
  fmtCompact(p.avg),
341
- fmtCost(p.cost),
343
+ fmtCost(p.cost, p.costStatus),
342
344
  ]);
343
345
  }
344
346
  pt.push([
@@ -346,15 +348,18 @@ export function renderOverview(records, detections) {
346
348
  chalk.bold(fmtInt(records.length)),
347
349
  chalk.bold(fmtCompact(t.tokensTotal)),
348
350
  chalk.bold(fmtCompact(t.avg)),
349
- chalk.bold(fmtCost(t.cost)),
351
+ chalk.bold(fmtCost(t.cost, t.costStatus)),
350
352
  ]);
351
353
 
352
354
  const body =
353
355
  pt.toString() +
356
+ "\n\n" + `Average daily tokens: ${fmtCompact(t.avgDailyTokens)}` +
354
357
  "\n\n" +
355
358
  chalk.bold.underline("Token Breakdown") +
356
359
  "\n" +
357
- bt.toString();
360
+ bt.toString() +
361
+ "\n\n" + chalk.dim("~ estimated API cost; * partial; n/a unavailable") +
362
+ (t.unknownModels.length ? "\n" + chalk.dim(`Unpriced models: ${t.unknownModels.map(m => String(m).replace(/[\x00-\x1f\x7f-\x9f]/g, "")).join(", ")}`) : "");
358
363
  return boxen(body, {
359
364
  title: chalk.bold("Overview"),
360
365
  borderStyle: "round",
@@ -526,7 +531,7 @@ export function renderProjectSessions(records, projectName) {
526
531
  colorize(r.tool, toolColor(r.tool)),
527
532
  dur,
528
533
  fmtCompact(r.tokensTotal),
529
- r.cost ? "$" + r.cost.toFixed(4) : "—",
534
+ fmtCost(r.cost, r.costStatus),
530
535
  titleStr,
531
536
  ]);
532
537
  } else {
@@ -539,7 +544,7 @@ export function renderProjectSessions(records, projectName) {
539
544
  fmtCompact(r.tokensInput),
540
545
  fmtCompact(r.tokensOutput),
541
546
  fmtCompact(cache),
542
- r.cost ? "$" + r.cost.toFixed(4) : "—",
547
+ fmtCost(r.cost, r.costStatus),
543
548
  titleStr,
544
549
  ]);
545
550
  }
@@ -600,7 +605,7 @@ export function renderPerTool(records) {
600
605
  }
601
606
  row.push(
602
607
  fmtCompact(p.tokensTotal),
603
- fmtCost(p.cost),
608
+ fmtCost(p.cost, p.costStatus),
604
609
  fmtCompact(p.avg),
605
610
  bar(p.tokensTotal, max, barW),
606
611
  );
@@ -796,7 +801,7 @@ export function renderTopSessions(records, n = 5) {
796
801
  chalk.bold(String(i + 1)),
797
802
  chalk.bold.yellow(fmtCompact(r.tokensTotal)),
798
803
  `${fmtCompact(r.tokensInput)}/${fmtCompact(r.tokensOutput)}`,
799
- r.cost ? "$" + r.cost.toFixed(4) : "—",
804
+ fmtCost(r.cost, r.costStatus),
800
805
  truncEnd(r.title, 30) || "—",
801
806
  ]);
802
807
  });
@@ -830,7 +835,7 @@ export function renderTopSessions(records, n = 5) {
830
835
  chalk.bold.yellow(fmtCompact(r.tokensTotal)),
831
836
  fmtCompact(r.tokensInput),
832
837
  fmtCompact(r.tokensOutput),
833
- r.cost ? "$" + r.cost.toFixed(4) : "—",
838
+ fmtCost(r.cost, r.costStatus),
834
839
  shortModel(r.model),
835
840
  truncEnd(r.project, 30),
836
841
  truncEnd(r.title, 40) || "—",
@@ -850,6 +855,7 @@ export function renderTopSessions(records, n = 5) {
850
855
 
851
856
  export function renderNotes(detections, errors) {
852
857
  const lines = [];
858
+ lines.push(chalk.dim("Costs: ~ estimated API rates; * partial, excludes unpriced models; n/a unavailable."));
853
859
  lines.push(chalk.bold.underline("Token definitions"));
854
860
  lines.push(
855
861
  ` • ${chalk.cyan("Input")} — prompt tokens (what was sent to the model)`,
@@ -861,7 +867,7 @@ export function renderNotes(detections, errors) {
861
867
  ` • ${chalk.cyan("Cache Read")} — prompt tokens served from provider cache (cheap)`,
862
868
  );
863
869
  lines.push(
864
- ` • ${chalk.cyan("Cache Write")} — prompt tokens cached for future use (OpenCode only)`,
870
+ ` • ${chalk.cyan("Cache Write")} — prompt tokens cached for future use`,
865
871
  );
866
872
  lines.push(
867
873
  ` • ${chalk.yellow("Reasoning")} — extended thinking / chain-of-thought tokens`,
@@ -902,9 +908,9 @@ export function renderNotes(detections, errors) {
902
908
  lines.push("");
903
909
  lines.push(chalk.bold.underline("Env overrides"));
904
910
  lines.push(
905
- " $CLAUDE_HOME $CODEX_HOME $OPENCODE_HOME $MIMOCODE_HOME",
911
+ " $CLAUDE_HOME $CODEX_HOME $OPENCODE_HOME",
906
912
  );
907
- lines.push(" $COPILOT_HOME $ANTIGRAVITY_HOME $GEMINI_HOME");
913
+ lines.push(" $ANTIGRAVITY_HOME $GEMINI_HOME");
908
914
  lines.push(
909
915
  " $AI_USAGE_PATHS_JSON = " + chalk.italic('\'{"codex":"/custom/path"}\''),
910
916
  );
package/src/server.js ADDED
@@ -0,0 +1,68 @@
1
+ import { createServer } from 'node:http';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { spawn } from 'node:child_process';
4
+ import { detectAll } from './detectors.js';
5
+ import { loadAll } from './loaders.js';
6
+
7
+ const assets = new Map([
8
+ ['/', ['../web/index.html', 'text/html; charset=utf-8']],
9
+ ['/app.js', ['../web/app.js', 'text/javascript; charset=utf-8']],
10
+ ['/dates.js', ['../web/dates.js', 'text/javascript; charset=utf-8']],
11
+ ['/aggregate.js', ['./aggregate.js', 'text/javascript; charset=utf-8']],
12
+ ['/style.css', ['../web/style.css', 'text/css; charset=utf-8']],
13
+ ['/favicon.svg', ['../web/favicon.svg', 'image/svg+xml']],
14
+ ['/gsap.js', [import.meta.resolve('gsap/dist/gsap.min.js'), 'text/javascript; charset=utf-8']],
15
+ ['/fonts/manrope.woff2', ['../web/fonts/manrope.woff2', 'font/woff2']],
16
+ ['/icons/claude.svg', ['../web/icons/claude.svg', 'image/svg+xml']],
17
+ ['/icons/openai.svg', ['../web/icons/openai.svg', 'image/svg+xml']],
18
+ ['/icons/opencode.svg', ['../web/icons/opencode.svg', 'image/svg+xml']],
19
+ ]);
20
+
21
+ export function createDashboardServer() {
22
+ return createServer(async (req, res) => {
23
+ const host = req.headers.host || '';
24
+ if (!/^127\.0\.0\.1:\d+$/.test(host) || (req.headers.origin && req.headers.origin !== `http://${host}`) || req.headers['sec-fetch-site'] === 'cross-site') {
25
+ res.writeHead(403).end('Forbidden');
26
+ return;
27
+ }
28
+ res.setHeader('X-Content-Type-Options', 'nosniff');
29
+ res.setHeader('Cache-Control', 'no-store');
30
+ res.setHeader('Content-Security-Policy', "default-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'");
31
+ if (req.method !== 'GET') {
32
+ res.writeHead(405, { Allow: 'GET' }).end();
33
+ return;
34
+ }
35
+ try {
36
+ const path = new URL(req.url, `http://${host}`).pathname;
37
+ if (path === '/api/usage') {
38
+ const detections = detectAll();
39
+ const { records, errors } = await loadAll(detections);
40
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }).end(JSON.stringify({ detections, records, errors, generatedAt: new Date().toISOString() }));
41
+ } else if (assets.has(path)) {
42
+ const [file, type] = assets.get(path);
43
+ const content = await readFile(new URL(file, import.meta.url));
44
+ res.writeHead(200, { 'Content-Type': type }).end(content);
45
+ } else {
46
+ res.writeHead(404).end('Not found');
47
+ }
48
+ } catch {
49
+ res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: 'Could not load local usage data.' }));
50
+ }
51
+ });
52
+ }
53
+
54
+ export async function startDashboard() {
55
+ const server = createDashboardServer();
56
+ await new Promise((resolve, reject) => {
57
+ server.once('error', reject);
58
+ server.listen(0, '127.0.0.1', resolve);
59
+ });
60
+ const url = `http://127.0.0.1:${server.address().port}/`;
61
+ console.log(`AI Usage Analyzer is running at ${url}\nPress Ctrl+C to stop.`);
62
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
63
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
64
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
65
+ child.on('error', () => {});
66
+ child.unref();
67
+ return server;
68
+ }
package/src/tools.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // data, also wire a loader branch in src/loaders.js.
9
9
 
10
10
  import { statSync, readdirSync, existsSync } from 'node:fs';
11
- import { join } from 'node:path';
11
+ import { basename, dirname, join } from 'node:path';
12
12
  import { homedir, platform } from 'node:os';
13
13
  import { env } from 'node:process';
14
14
  import { createRequire } from 'node:module';
@@ -112,10 +112,10 @@ export const TOOLS = [
112
112
  barChar: '▓',
113
113
  kind: 'jsonl-rollout',
114
114
  envVar: 'CODEX_HOME',
115
- candidatePaths: () => [
116
- env.CODEX_HOME || join(HOME, '.codex', 'sessions'),
117
- ],
118
- count: countJsonlRecursive,
115
+ candidatePaths: () => env.CODEX_HOME
116
+ ? [env.CODEX_HOME]
117
+ : [join(HOME, '.codex', 'sessions'), join(HOME, '.codex', 'archived_sessions')],
118
+ count: path => countJsonlRecursive(path) + (basename(path) === 'sessions' ? countJsonlRecursive(join(dirname(path), 'archived_sessions')) : 0),
119
119
  hasTokens: true,
120
120
  description: '~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl',
121
121
  },
@@ -141,50 +141,6 @@ export const TOOLS = [
141
141
  description: '~/.local/share/opencode/opencode.db (tokens + cost)',
142
142
  },
143
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
144
  // -----------------------------------------------------------------------
189
145
  // Antigravity (VS Code variant) - mostly cache; no token data
190
146
  // -----------------------------------------------------------------------
package/src/tui.js CHANGED
@@ -5,6 +5,7 @@ import chalk from "chalk";
5
5
  import process from "node:process";
6
6
  import readline from "node:readline";
7
7
  import { perProject } from "./aggregate.js";
8
+ import { renderQuota } from "./quota.js";
8
9
  import {
9
10
  CURSOR_MARK,
10
11
  renderDetections,
@@ -25,11 +26,14 @@ function visLen(s) {
25
26
  return s.replace(ANSI_RE, "").length;
26
27
  }
27
28
 
28
- export async function runInteractive({ records, detections, errors, topN }) {
29
+ export async function runInteractive({ records, detections, errors, topN, quotaPromise, quotaController, liveTailer }) {
29
30
  if (!process.stdout.isTTY || !process.stdin.isTTY) return false;
30
31
 
31
32
  const PROJECTS_VIEW = 1; // index into VIEWS
32
33
 
34
+ let liveRate = liveTailer ? liveTailer.tick() : null;
35
+ let quotas = null;
36
+ let active = true;
33
37
  const VIEWS = [
34
38
  {
35
39
  key: "1",
@@ -53,6 +57,7 @@ export async function runInteractive({ records, detections, errors, topN }) {
53
57
  },
54
58
  { key: "8", label: "Detections", fn: () => renderDetections(detections) },
55
59
  { key: "9", label: "Notes", fn: () => renderNotes(detections, errors) },
60
+ { key: "0", label: "Quota", fn: () => renderQuota(quotas) },
56
61
  ];
57
62
 
58
63
  // Project drill-down state (only active when current === PROJECTS_VIEW).
@@ -128,6 +133,7 @@ export async function runInteractive({ records, detections, errors, topN }) {
128
133
  " ←/→ h/l switch ↑/↓ j/k scroll PgUp/PgDn g/G top/bot q quit",
129
134
  );
130
135
  }
136
+ if (liveRate !== null) hint = chalk.dim(` ${Math.round(liveRate)} tokens/min (10m) `) + hint;
131
137
  const gap = Math.max(1, w - visLen(hint) - visLen(pct));
132
138
  return chalk.bgBlack(hint + " ".repeat(gap) + pct);
133
139
  }
@@ -195,20 +201,29 @@ export async function runInteractive({ records, detections, errors, topN }) {
195
201
  process.stdout.write("\x1b[?1049h\x1b[?25l");
196
202
  draw(true);
197
203
 
198
- process.stdout.on("resize", () => {
199
- draw(true);
204
+ const liveTimer = liveTailer ? setInterval(() => { liveRate = liveTailer.tick(); if (active) draw(false); }, 5000) : null;
205
+ const onResize = () => draw(true);
206
+ process.stdout.on("resize", onResize);
207
+ if (quotaPromise) quotaPromise.then((result) => {
208
+ quotas = result;
209
+ if (active && current === 9) { loadView(); draw(false); }
200
210
  });
201
211
 
202
212
  return new Promise((resolve) => {
203
213
  function quit() {
204
214
  // Leave alternate screen — original terminal content is restored automatically.
215
+ active = false;
216
+ if (liveTimer) clearInterval(liveTimer);
217
+ quotaController?.abort();
218
+ process.stdout.removeListener("resize", onResize);
219
+ process.stdin.removeListener("keypress", onKeypress);
205
220
  process.stdout.write("\x1b[?25h\x1b[?1049l");
206
221
  process.stdin.setRawMode(false);
207
222
  process.stdin.pause();
208
- resolve();
223
+ resolve(true);
209
224
  }
210
225
 
211
- process.stdin.on("keypress", (_str, key) => {
226
+ function onKeypress(_str, key) {
212
227
  if (!key) return;
213
228
  const usable = usableRows();
214
229
  const maxScroll = Math.max(0, lines.length - usable);
@@ -270,8 +285,8 @@ export async function runInteractive({ records, detections, errors, topN }) {
270
285
  draw(false);
271
286
  return;
272
287
  }
273
- if (_str && /^[1-9]$/.test(_str)) {
274
- const idx = parseInt(_str, 10) - 1;
288
+ if (_str && /^[0-9]$/.test(_str)) {
289
+ const idx = _str === "0" ? 9 : parseInt(_str, 10) - 1;
275
290
  if (idx < VIEWS.length) {
276
291
  current = idx;
277
292
  if (current === PROJECTS_VIEW) proj.mode = "list";
@@ -305,6 +320,7 @@ export async function runInteractive({ records, detections, errors, topN }) {
305
320
  }
306
321
 
307
322
  if (moved) draw(false);
308
- });
323
+ }
324
+ process.stdin.on("keypress", onKeypress);
309
325
  });
310
326
  }