usagelens 0.1.0 → 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/README.md CHANGED
@@ -12,20 +12,44 @@ That's it — it scans your local data, starts a server on <http://localhost:431
12
12
 
13
13
  - **Tokens, est. cost, sessions, cache hit-rate, streak** — with deltas vs the prior period
14
14
  - **Daily activity** bar chart and a **when-you-work** hour × weekday heatmap (hover for details)
15
- - **Tool breakdown** (Claude / Codex / Cursor) and **model mix** with per-model estimated cost
16
- - **Top projects** — which repos consume the most AI
17
- - **Session shape** (quick / standard / deep / marathon) with averages
18
- - **Cache effectiveness** trend and estimated savings
19
- - **Code authorship** (from Cursor) AI-authored vs human lines over time
15
+ - **Tool breakdown** (Claude / Codex / Cursor / Antigravity) and **model mix** with per-model estimated cost
16
+ - **Cost per shipped code** — AI spend mapped to real git commits & lines (`$/commit`, `$/1K lines`, per project)
17
+ - **Cost efficiency & model routing** — premium (Opus) share, output/input leverage, and estimated savings if routine turns were routed to a cheaper tier
18
+ - **Tool usage** how often Bash / Edit / Read / etc. are called
19
+ - **Top projects**, **session shape**, **cache effectiveness**, and **code authorship** (Cursor)
20
+ - **Budgets** — month-to-date spend vs a configured monthly budget, with straight-line projection
21
+ - **Live refresh** — re-scan your data without restarting; **multi-account** aware
20
22
 
21
23
  ## Options
22
24
 
23
25
  ```bash
24
- usagelens # start on :4319 and open the browser
25
- usagelens --port 8080 # custom port
26
- usagelens --no-open # don't auto-open the browser
26
+ usagelens # start on :4319 (loopback only) and open the browser
27
+ usagelens --port 8080 # custom port
28
+ usagelens --no-open # don't auto-open the browser
29
+ usagelens report # print a markdown usage report to stdout
30
+ usagelens report --json # machine-readable report (pipe to jq, CI, Slack)
31
+ usagelens report --range 7d # 7d | 30d | 90d
27
32
  ```
28
33
 
34
+ ## Config (optional) — `~/.usagelens.json`
35
+
36
+ ```json
37
+ {
38
+ "sources": [
39
+ { "tool": "claude", "dir": "~/.claude", "label": "work@company.com" },
40
+ { "tool": "claude", "dir": "~/.claude-personal", "label": "me@personal.com" }
41
+ ],
42
+ "pricing": { "opus": { "input": 12, "output": 60 } },
43
+ "budget": { "monthlyUsd": 500, "monthlyTokens": 2000000000 },
44
+ "git": true
45
+ }
46
+ ```
47
+
48
+ - **sources** — point at multiple config dirs to track multiple accounts (each labeled).
49
+ - **pricing** — override the estimated per-model $/Mtoken rates.
50
+ - **budget** — enables the budget panel + projection.
51
+ - **git** — set `false` to skip the git correlation (cost-per-shipped-code).
52
+
29
53
  ## Data sources
30
54
 
31
55
  Read-only, from your machine:
@@ -39,7 +63,7 @@ Tools detected without usable local usage data (Gemini, GitHub Copilot, Ollama)
39
63
  ## Notes
40
64
 
41
65
  - **Cost is estimated** — list price × tokens ("API-equivalent value"), not a bill. If you're on a subscription, treat it as a proxy for usage.
42
- - Nothing is written to your tool directories, and nothing leaves your machine.
66
+ - Nothing is written to your tool directories, and nothing leaves your machine. The server binds to **loopback (127.0.0.1) only**.
43
67
  - Requires **Node ≥ 22.5** (uses the built-in `node:sqlite`).
44
68
 
45
69
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usagelens",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Local dashboard for your AI coding usage across Claude Code, Codex CLI, and Cursor. Reads your machine's local data and serves an interactive dashboard on localhost. Read-only, offline, zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,5 +31,13 @@
31
31
  "cli"
32
32
  ],
33
33
  "author": "Aayush Jain",
34
- "license": "MIT"
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/aayushGofynd/usagelens.git"
38
+ },
39
+ "homepage": "https://github.com/aayushGofynd/usagelens#readme",
40
+ "bugs": {
41
+ "url": "https://github.com/aayushGofynd/usagelens/issues"
42
+ }
35
43
  }
@@ -0,0 +1,68 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, join } from 'node:path';
4
+
5
+ const HOME = homedir();
6
+
7
+ function readJson(path) {
8
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
9
+ }
10
+
11
+ // Decode the payload of a JWT without verifying (local, read-only, best-effort).
12
+ function jwtPayload(token) {
13
+ try {
14
+ const seg = String(token).split('.')[1];
15
+ const json = Buffer.from(seg.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
16
+ return JSON.parse(json);
17
+ } catch { return null; }
18
+ }
19
+
20
+ // Claude: oauthAccount lives in a .claude.json near the config/projects dir.
21
+ export function claudeAccount(root) {
22
+ const candidates = [
23
+ join(root, '.claude.json'),
24
+ join(dirname(root), '.claude.json'), // <root>/projects -> <root>/.claude.json
25
+ root.replace(/\/?$/, '') + '.json', // ~/.claude-work -> ~/.claude-work.json
26
+ join(HOME, '.claude.json'),
27
+ ];
28
+ for (const c of candidates) {
29
+ const j = readJson(c);
30
+ const oa = j?.oauthAccount;
31
+ if (oa?.emailAddress) return oa.emailAddress;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ // Codex: email from the id_token JWT, else account_id, from auth.json.
37
+ export function codexAccount(root) {
38
+ for (const c of [join(root, 'auth.json'), join(HOME, '.codex', 'auth.json')]) {
39
+ const j = readJson(c);
40
+ if (!j) continue;
41
+ const p = jwtPayload(j.tokens?.id_token);
42
+ const email = p?.email || p?.['https://api.openai.com/profile']?.email;
43
+ if (email) return email;
44
+ const acct = j.tokens?.account_id;
45
+ if (acct) return `openai:${String(acct).slice(0, 8)}`;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ import { createRequire } from 'node:module';
51
+ const require = createRequire(import.meta.url);
52
+
53
+ // Antigravity (Gemini): display name from the VS Code state DB auth status.
54
+ export function antigravityAccount() {
55
+ const db = join(HOME, 'Library', 'Application Support', 'Antigravity', 'User', 'globalStorage', 'state.vscdb');
56
+ if (!existsSync(db)) return null;
57
+ try {
58
+ const { DatabaseSync } = require('node:sqlite');
59
+ const d = new DatabaseSync(db, { readOnly: true });
60
+ const row = d.prepare("SELECT value FROM ItemTable WHERE key='antigravityAuthStatus'").get();
61
+ d.close();
62
+ const v = row && JSON.parse(row.value);
63
+ return v?.name || v?.email || null;
64
+ } catch { return null; }
65
+ }
66
+
67
+ // Fallback label when no identity can be derived.
68
+ export function fallbackAccount(dir) { return basename(dir) || 'local'; }
@@ -1,137 +1,199 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
2
3
  import { homedir } from 'node:os';
3
4
  import { basename, join } from 'node:path';
4
- import { costOf } from './pricing.mjs';
5
+ import { costOf, setPricingOverrides } from './pricing.mjs';
6
+ import { antigravityAccount, claudeAccount, codexAccount, fallbackAccount } from './accounts.mjs';
7
+ import { loadConfig } from './config.mjs';
5
8
 
6
9
  const HOME = homedir();
10
+ const require = createRequire(import.meta.url);
11
+ const expand = (p) => (p.startsWith('~') ? join(HOME, p.slice(1)) : p);
7
12
 
8
- function walk(dir) {
13
+ function walk(dir, ext) {
9
14
  if (!existsSync(dir)) return [];
10
15
  const out = [];
11
16
  for (const name of readdirSync(dir)) {
12
17
  let p = join(dir, name), st;
13
18
  try { st = statSync(p); } catch { continue; }
14
- if (st.isDirectory()) out.push(...walk(p));
15
- else if (name.endsWith('.jsonl')) out.push(p);
19
+ if (st.isDirectory()) out.push(...walk(p, ext));
20
+ else if (!ext || name.endsWith(ext)) out.push(p);
16
21
  }
17
22
  return out;
18
23
  }
19
24
 
20
25
  // ---------- Claude ----------
21
- export function loadClaude() {
22
- const root = join(HOME, '.claude', 'projects');
23
- if (!existsSync(root)) return { events: [], commits: [], status: { tool: 'claude', available: false, note: 'no ~/.claude/projects' } };
24
- const events = [];
25
- for (const f of walk(root)) {
26
- let text;
27
- try { text = readFileSync(f, 'utf8'); } catch { continue; }
26
+ export function loadClaude(dir, account) {
27
+ const root = dir ?? join(HOME, '.claude', 'projects');
28
+ if (!existsSync(root)) return { events: [], commits: [], activity: [], status: { tool: 'claude', account, available: false, note: 'no projects dir' } };
29
+ const events = [], toolcalls = [];
30
+ for (const f of walk(root, '.jsonl')) {
31
+ let text; try { text = readFileSync(f, 'utf8'); } catch { continue; }
28
32
  for (const line of text.split('\n')) {
29
33
  if (!line.trim()) continue;
30
- let row;
31
- try { row = JSON.parse(line); } catch { continue; }
34
+ let row; try { row = JSON.parse(line); } catch { continue; }
32
35
  if (row.type !== 'assistant') continue;
33
36
  const m = row.message, u = m?.usage;
34
37
  if (!u || !m?.model) continue;
35
38
  const cwd = row.cwd ?? 'unknown';
39
+ // tool_use blocks in the assistant message content
40
+ if (Array.isArray(m.content)) {
41
+ for (const b of m.content) {
42
+ if (b?.type === 'tool_use' && b.name) toolcalls.push({ tool: 'claude', account, timestamp: row.timestamp, name: b.name });
43
+ }
44
+ }
36
45
  const t = {
37
- inputTokens: u.input_tokens ?? 0,
38
- outputTokens: u.output_tokens ?? 0,
39
- cacheReadTokens: u.cache_read_input_tokens ?? 0,
40
- cacheCreateTokens: u.cache_creation_input_tokens ?? 0,
46
+ inputTokens: u.input_tokens ?? 0, outputTokens: u.output_tokens ?? 0,
47
+ cacheReadTokens: u.cache_read_input_tokens ?? 0, cacheCreateTokens: u.cache_creation_input_tokens ?? 0,
41
48
  };
42
49
  events.push({
43
- tool: 'claude', model: m.model, project: basename(cwd), projectPath: cwd,
50
+ tool: 'claude', account, model: m.model, project: basename(cwd), projectPath: cwd,
44
51
  gitBranch: row.gitBranch || undefined, timestamp: row.timestamp,
45
52
  sessionId: row.sessionId ?? basename(f, '.jsonl'), ...t, costUsd: costOf(m.model, t),
46
53
  });
47
54
  }
48
55
  }
49
- const last = events.reduce((a, e) => (!a || e.timestamp > a ? e.timestamp : a), undefined);
50
- return { events, commits: [], status: { tool: 'claude', available: true, lastActivity: last, count: events.length } };
56
+ return { events, commits: [], activity: [], toolcalls, status: statusOf('claude', account, events) };
51
57
  }
52
58
 
53
59
  // ---------- Codex ----------
54
- export function loadCodex() {
55
- const base = join(HOME, '.codex');
56
- const files = ['sessions', 'archived_sessions'].flatMap((r) => walk(join(base, r)));
57
- if (files.length === 0) return { events: [], commits: [], status: { tool: 'codex', available: false, note: 'no ~/.codex sessions' } };
60
+ export function loadCodex(dir, account) {
61
+ const base = dir ?? join(HOME, '.codex');
62
+ const files = ['sessions', 'archived_sessions'].flatMap((r) => walk(join(base, r), '.jsonl'));
63
+ if (files.length === 0) return { events: [], commits: [], activity: [], status: { tool: 'codex', account, available: false, note: 'no sessions' } };
58
64
  const events = [];
59
65
  for (const f of files) {
60
- let text;
61
- try { text = readFileSync(f, 'utf8'); } catch { continue; }
66
+ let text; try { text = readFileSync(f, 'utf8'); } catch { continue; }
62
67
  let model = 'gpt-5', cwd = 'unknown', sessionId = basename(f, '.jsonl');
63
68
  for (const line of text.split('\n')) {
64
69
  if (!line.trim()) continue;
65
- let row;
66
- try { row = JSON.parse(line); } catch { continue; }
70
+ let row; try { row = JSON.parse(line); } catch { continue; }
67
71
  const p = row.payload ?? row;
68
- // Codex sessions nest the real event type inside event_msg/response_item payloads.
69
72
  const kind = (row.type === 'event_msg' || row.type === 'response_item') ? (p.type ?? row.type) : row.type;
70
73
  if (kind === 'session_meta') { cwd = p.cwd ?? cwd; sessionId = p.id ?? sessionId; }
71
74
  else if (kind === 'turn_context') { if (p.model) model = p.model; if (p.cwd) cwd = p.cwd; }
72
75
  else if (kind === 'token_count') {
73
- const last = p.info?.last_token_usage;
74
- if (!last) continue;
76
+ const last = p.info?.last_token_usage; if (!last) continue;
75
77
  const cached = last.cached_input_tokens ?? 0;
76
78
  const t = {
77
- inputTokens: Math.max(0, (last.input_tokens ?? 0) - cached),
78
- outputTokens: last.output_tokens ?? 0,
79
+ inputTokens: Math.max(0, (last.input_tokens ?? 0) - cached), outputTokens: last.output_tokens ?? 0,
79
80
  cacheReadTokens: cached, cacheCreateTokens: 0,
80
81
  };
81
82
  if (t.inputTokens + t.outputTokens + t.cacheReadTokens === 0) continue;
82
83
  events.push({
83
- tool: 'codex', model, project: basename(cwd), projectPath: cwd,
84
+ tool: 'codex', account, model, project: basename(cwd), projectPath: cwd,
84
85
  timestamp: row.timestamp, sessionId, ...t, costUsd: costOf(model, t),
85
86
  });
86
87
  }
87
88
  }
88
89
  }
89
- const last = events.reduce((a, e) => (!a || e.timestamp > a ? e.timestamp : a), undefined);
90
- return { events, commits: [], status: { tool: 'codex', available: true, lastActivity: last, count: events.length } };
90
+ return { events, commits: [], activity: [], status: statusOf('codex', account, events) };
91
91
  }
92
92
 
93
93
  // ---------- Cursor ----------
94
- export function loadCursor() {
95
- const path = join(HOME, '.cursor', 'ai-tracking', 'ai-code-tracking.db');
96
- if (!existsSync(path)) return { events: [], commits: [], status: { tool: 'cursor', available: false, note: 'no cursor ai-tracking db' } };
94
+ export function loadCursor(dir, account) {
95
+ const path = join(dir ?? join(HOME, '.cursor'), 'ai-tracking', 'ai-code-tracking.db');
96
+ if (!existsSync(path)) return { events: [], commits: [], activity: [], status: { tool: 'cursor', account, available: false, note: 'no ai-tracking db' } };
97
97
  const commits = [];
98
98
  try {
99
99
  const { DatabaseSync } = require('node:sqlite');
100
100
  const db = new DatabaseSync(path, { readOnly: true });
101
- const rows = db.prepare(
102
- 'SELECT commitHash, branchName, scoredAt, linesAdded, v1AiPercentage, v2AiPercentage FROM scored_commits'
103
- ).all();
101
+ const rows = db.prepare('SELECT commitHash, branchName, scoredAt, linesAdded, v1AiPercentage, v2AiPercentage FROM scored_commits').all();
104
102
  for (const r of rows) {
105
103
  const total = r.linesAdded ?? 0;
106
104
  const aiPct = Number(r.v2AiPercentage ?? r.v1AiPercentage ?? 0) || 0;
107
105
  const aiLines = Math.round((total * aiPct) / 100);
108
106
  commits.push({
109
- tool: 'cursor', timestamp: new Date(Number(r.scoredAt)).toISOString(),
107
+ tool: 'cursor', account, timestamp: new Date(Number(r.scoredAt)).toISOString(),
110
108
  commitHash: r.commitHash, branchName: r.branchName ?? 'unknown',
111
109
  totalLines: total, aiLines, humanLines: Math.max(0, total - aiLines), aiPct,
112
110
  });
113
111
  }
114
112
  db.close();
115
113
  } catch (e) {
116
- return { events: [], commits: [], status: { tool: 'cursor', available: false, note: 'cursor db unreadable: ' + e.message } };
114
+ return { events: [], commits: [], activity: [], status: { tool: 'cursor', account, available: false, note: 'db unreadable: ' + e.message } };
117
115
  }
118
116
  const last = commits.reduce((a, c) => (!a || c.timestamp > a ? c.timestamp : a), undefined);
119
- return { events: [], commits, status: { tool: 'cursor', available: true, lastActivity: last, count: commits.length } };
117
+ return { events: [], commits, activity: [], status: { tool: 'cursor', account, available: true, lastActivity: last, count: commits.length, kind: 'authorship' } };
120
118
  }
121
119
 
122
- // require shim for ESM (node:sqlite import)
123
- import { createRequire } from 'node:module';
124
- const require = createRequire(import.meta.url);
120
+ // ---------- Antigravity (Gemini) activity only, no tokens ----------
121
+ export function loadAntigravity(dir, account) {
122
+ const base = dir ?? join(HOME, '.gemini', 'antigravity');
123
+ if (!existsSync(base)) return { events: [], commits: [], activity: [], status: { tool: 'antigravity', account, available: false, note: 'not installed' } };
124
+ const activity = [];
125
+ // conversations = sessions
126
+ for (const f of walk(join(base, 'conversations'), '.pb')) {
127
+ let ts; try { ts = new Date(statSync(f).mtimeMs).toISOString(); } catch { continue; }
128
+ activity.push({ tool: 'antigravity', account, kind: 'session', project: 'antigravity', timestamp: ts });
129
+ }
130
+ // code_tracker = AI-touched files, project = workspace dir (minus trailing hash)
131
+ for (const root of ['active', 'history']) {
132
+ const rd = join(base, 'code_tracker', root);
133
+ if (!existsSync(rd)) continue;
134
+ for (const ws of readdirSync(rd)) {
135
+ const wsDir = join(rd, ws);
136
+ let st; try { st = statSync(wsDir); } catch { continue; }
137
+ if (!st.isDirectory()) continue;
138
+ const project = ws.replace(/_[0-9a-f]{40}$/, '') || 'antigravity';
139
+ for (const f of walk(wsDir)) {
140
+ const m = basename(f).match(/_(\d{14})-/);
141
+ let ts;
142
+ if (m) { const d = m[1]; ts = `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}T${d.slice(8, 10)}:${d.slice(10, 12)}:${d.slice(12, 14)}`; }
143
+ else { try { ts = new Date(statSync(f).mtimeMs).toISOString(); } catch { continue; } }
144
+ activity.push({ tool: 'antigravity', account, kind: 'file', project, timestamp: ts });
145
+ }
146
+ }
147
+ }
148
+ const last = activity.reduce((a, e) => (!a || e.timestamp > a ? e.timestamp : a), undefined);
149
+ return { events: [], commits: [], activity, status: { tool: 'antigravity', account, available: activity.length > 0, lastActivity: last, count: activity.length, kind: 'activity' } };
150
+ }
151
+
152
+ function statusOf(tool, account, events) {
153
+ const last = events.reduce((a, e) => (!a || e.timestamp > a ? e.timestamp : a), undefined);
154
+ return { tool, account, available: events.length > 0, lastActivity: last, count: events.length, kind: 'tokens' };
155
+ }
156
+
157
+ // ---------- source composition ----------
158
+ function defaultSources() {
159
+ return [
160
+ { tool: 'claude', dir: join(HOME, '.claude', 'projects'), account: claudeAccount(join(HOME, '.claude', 'projects')) },
161
+ { tool: 'codex', dir: join(HOME, '.codex'), account: codexAccount(join(HOME, '.codex')) },
162
+ { tool: 'cursor', dir: join(HOME, '.cursor'), account: 'cursor (local)' },
163
+ { tool: 'antigravity', dir: join(HOME, '.gemini', 'antigravity'), account: antigravityAccount() },
164
+ ];
165
+ }
166
+
167
+ const LOADERS = { claude: loadClaude, codex: loadCodex, cursor: loadCursor, antigravity: loadAntigravity };
125
168
 
126
169
  export function loadAll() {
127
- const events = [], commits = [], statuses = [];
128
- for (const fn of [loadClaude, loadCodex, loadCursor]) {
129
- try { const r = fn(); events.push(...r.events); commits.push(...r.commits); statuses.push(r.status); }
130
- catch (e) { statuses.push({ tool: fn.name.replace('load', '').toLowerCase(), available: false, note: e.message }); }
170
+ const cfg = loadConfig();
171
+ setPricingOverrides(cfg.pricing);
172
+ const sources = (cfg.sources
173
+ ? cfg.sources.map((s) => ({ tool: s.tool, dir: expand(s.dir ?? ''), account: s.label ?? s.account ?? null }))
174
+ : null) ?? defaultSources();
175
+ const events = [], commits = [], activity = [], toolcalls = [], statuses = [];
176
+ const accounts = new Set();
177
+ const projectPaths = new Map(); // project name -> real filesystem path (for git correlation)
178
+ for (const s of sources) {
179
+ const loader = LOADERS[s.tool];
180
+ if (!loader) { statuses.push({ tool: s.tool, available: false, note: 'unknown tool' }); continue; }
181
+ const account = s.account || fallbackAccount(s.dir);
182
+ try {
183
+ const r = loader(s.dir, account);
184
+ events.push(...r.events); commits.push(...r.commits); activity.push(...(r.activity ?? []));
185
+ if (r.toolcalls) toolcalls.push(...r.toolcalls);
186
+ for (const e of r.events) if (e.projectPath && e.projectPath !== 'unknown') projectPaths.set(e.project, e.projectPath);
187
+ statuses.push(r.status);
188
+ if (r.status.available) accounts.add(account);
189
+ } catch (e) { statuses.push({ tool: s.tool, account, available: false, note: e.message }); }
131
190
  }
132
- // detect-only tools with no parseable usage
133
- for (const [tool, rel] of [['gemini', '.gemini'], ['copilot', '.copilot'], ['ollama', '.ollama']]) {
134
- if (existsSync(join(HOME, rel))) statuses.push({ tool, available: false, note: 'detected, no usage metrics exposed' });
191
+ // tools present on disk but exposing no usable local usage
192
+ for (const [tool, rel, note] of [
193
+ ['copilot', '.copilot', 'no local usage (editor/cloud only)'],
194
+ ['ollama', '.ollama', 'no logged inference calls (server pings only)'],
195
+ ]) {
196
+ if (existsSync(join(HOME, rel))) statuses.push({ tool, available: false, note });
135
197
  }
136
- return { events, commits, statuses };
198
+ return { events, commits, activity, toolcalls, statuses, accounts: [...accounts].sort(), projectPaths, config: cfg };
137
199
  }
@@ -1,4 +1,6 @@
1
1
  import { savingsOf } from './pricing.mjs';
2
+ import { budgetStatus, efficiency, modelRouting, toolUsage } from './analytics.mjs';
3
+ import { gitCorrelation } from './git.mjs';
2
4
 
3
5
  const MS_DAY = 86_400_000;
4
6
  const DAYS = { '7d': 7, '30d': 30, '90d': 90 };
@@ -141,11 +143,42 @@ function authorship(commits) {
141
143
  };
142
144
  }
143
145
 
144
- export function buildPayload(ds, range, now) {
146
+ function activitySummary(activity) {
147
+ const byProject = new Map();
148
+ const byDay = new Set();
149
+ let sessions = 0, files = 0;
150
+ for (const a of activity) {
151
+ if (a.kind === 'session') sessions += 1; else files += 1;
152
+ byProject.set(a.project, (byProject.get(a.project) ?? 0) + 1);
153
+ byDay.add(localDayKey(a.timestamp));
154
+ }
155
+ return {
156
+ total: activity.length, sessions, files, activeDays: byDay.size,
157
+ byProject: [...byProject.entries()].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count).slice(0, 8),
158
+ };
159
+ }
160
+
161
+ // Computed on demand (shells out to git across repos) — see /api/git.
162
+ export async function gitReport(ds, range, now, account) {
163
+ const win = rangeWindow(range, now);
164
+ const evAll = account && account !== 'all' ? ds.events.filter((e) => e.account === account) : ds.events;
165
+ const cur = evAll.filter((e) => inWin(e.timestamp, win));
166
+ const cfg = ds.config ?? { git: true };
167
+ return gitCorrelation(cur, ds.projectPaths ?? new Map(), win, cfg.git !== false);
168
+ }
169
+
170
+ export function buildPayload(ds, range, now, account) {
145
171
  const win = rangeWindow(range, now), prior = priorWindow(range, now);
146
- const cur = ds.events.filter((e) => inWin(e.timestamp, win));
147
- const prev = ds.events.filter((e) => inWin(e.timestamp, prior));
148
- const commits = ds.commits.filter((c) => inWin(c.timestamp, win));
172
+ const pick = (arr) => (account && account !== 'all' ? arr.filter((x) => x.account === account) : arr);
173
+ const evAll = pick(ds.events), coAll = pick(ds.commits), acAll = pick(ds.activity ?? []);
174
+ const cur = evAll.filter((e) => inWin(e.timestamp, win));
175
+ const prev = evAll.filter((e) => inWin(e.timestamp, prior));
176
+ const commits = coAll.filter((c) => inWin(c.timestamp, win));
177
+ const activity = acAll.filter((a) => inWin(a.timestamp, win));
178
+ const toolcalls = (account && account !== 'all' ? (ds.toolcalls ?? []).filter((t) => t.account === account) : (ds.toolcalls ?? []))
179
+ .filter((t) => inWin(t.timestamp, win));
180
+ const lastAll = [...cur.map((e) => e.timestamp), ...commits.map((c) => c.timestamp), ...activity.map((a) => a.timestamp)];
181
+ const cfg = ds.config ?? { git: true, budget: null };
149
182
  return {
150
183
  summary: summarize(cur, prev),
151
184
  breakdowns: { byTool: group(cur, (e) => e.tool), byModel: group(cur, (e) => e.model), byProject: group(cur, (e) => e.project, 12) },
@@ -153,7 +186,14 @@ export function buildPayload(ds, range, now) {
153
186
  heatmap: heatmap(cur),
154
187
  sessions: sessionShape(cur),
155
188
  authorship: authorship(commits),
189
+ activity: activitySummary(activity),
190
+ efficiency: efficiency(cur),
191
+ routing: modelRouting(cur),
192
+ toolUsage: toolUsage(toolcalls),
193
+ gitEnabled: cfg.git !== false, // git correlation is computed on demand via /api/git
194
+ budget: budgetStatus(account && account !== 'all' ? evAll : ds.events, cfg.budget, now),
156
195
  tools: ds.statuses,
157
- meta: { range, generatedAt: now.toISOString(), lastActivity: cur.reduce((a, e) => (!a || e.timestamp > a ? e.timestamp : a), undefined) },
196
+ accounts: ds.accounts ?? [],
197
+ meta: { range, account: account ?? 'all', generatedAt: now.toISOString(), lastActivity: lastAll.reduce((a, t) => (!a || t > a ? t : a), undefined) },
158
198
  };
159
199
  }
@@ -0,0 +1,73 @@
1
+ import { priceFor } from './pricing.mjs';
2
+
3
+ const tokensOf = (e) => e.inputTokens + e.outputTokens + e.cacheReadTokens + e.cacheCreateTokens;
4
+ const costAt = (p, e) =>
5
+ (e.inputTokens * p.input + e.outputTokens * p.output + e.cacheCreateTokens * p.cacheWrite + e.cacheReadTokens * p.cacheRead) / 1e6;
6
+
7
+ // Leverage & waste signals from token events.
8
+ export function efficiency(events) {
9
+ const input = events.reduce((a, e) => a + e.inputTokens, 0);
10
+ const output = events.reduce((a, e) => a + e.outputTokens, 0);
11
+ const cost = events.reduce((a, e) => a + e.costUsd, 0);
12
+ // "thrash": a turn with large uncached input but tiny output (context churn / re-reading)
13
+ const thrash = events.filter((e) => e.inputTokens > 20_000 && e.outputTokens < 300);
14
+ return {
15
+ outputPerInput: input ? output / input : 0,
16
+ costPerMOutput: output ? cost / (output / 1e6) : 0,
17
+ avgTokensPerMsg: events.length ? events.reduce((a, e) => a + tokensOf(e), 0) / events.length : 0,
18
+ thrashCount: thrash.length,
19
+ thrashCostUsd: thrash.reduce((a, e) => a + e.costUsd, 0),
20
+ };
21
+ }
22
+
23
+ // How much of spend is on premium models, and estimated savings if "routine" turns
24
+ // (short outputs) were routed to a cheaper tier (Sonnet-equivalent pricing).
25
+ export function modelRouting(events) {
26
+ const sonnet = priceFor('sonnet');
27
+ const premium = /opus/i;
28
+ let premiumCost = 0, totalCost = 0, routineCount = 0, savings = 0;
29
+ for (const e of events) {
30
+ totalCost += e.costUsd;
31
+ if (premium.test(e.model)) {
32
+ premiumCost += e.costUsd;
33
+ if (e.outputTokens < 800) { // routine / trivial turn
34
+ routineCount += 1;
35
+ savings += Math.max(0, e.costUsd - costAt(sonnet, e));
36
+ }
37
+ }
38
+ }
39
+ return {
40
+ premiumSharePct: totalCost ? (premiumCost / totalCost) * 100 : 0,
41
+ routineCount,
42
+ estRoutingSavingsUsd: savings,
43
+ };
44
+ }
45
+
46
+ // Tool-call frequency (from transcript tool_use blocks).
47
+ export function toolUsage(toolcalls) {
48
+ const map = new Map();
49
+ for (const t of toolcalls) map.set(t.name, (map.get(t.name) ?? 0) + 1);
50
+ const top = [...map.entries()].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count);
51
+ return { total: toolcalls.length, top: top.slice(0, 10) };
52
+ }
53
+
54
+ // Month-to-date budget tracking + straight-line projection.
55
+ export function budgetStatus(events, budget, now) {
56
+ if (!budget) return null;
57
+ const y = now.getFullYear(), m = now.getMonth();
58
+ const mtd = events.filter((e) => { const d = new Date(e.timestamp); return d.getFullYear() === y && d.getMonth() === m; });
59
+ const spendUsd = mtd.reduce((a, e) => a + e.costUsd, 0);
60
+ const spendTokens = mtd.reduce((a, e) => a + tokensOf(e), 0);
61
+ const dayOfMonth = now.getDate();
62
+ const daysInMonth = new Date(y, m + 1, 0).getDate();
63
+ const project = (v) => (dayOfMonth ? (v / dayOfMonth) * daysInMonth : v);
64
+ return {
65
+ monthlyUsd: budget.monthlyUsd ?? null,
66
+ monthlyTokens: budget.monthlyTokens ?? null,
67
+ spendUsd, spendTokens,
68
+ projectedUsd: project(spendUsd), projectedTokens: project(spendTokens),
69
+ usdPct: budget.monthlyUsd ? (spendUsd / budget.monthlyUsd) * 100 : null,
70
+ tokensPct: budget.monthlyTokens ? (spendTokens / budget.monthlyTokens) * 100 : null,
71
+ dayOfMonth, daysInMonth,
72
+ };
73
+ }
@@ -0,0 +1,32 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ const HOME = homedir();
6
+
7
+ // ~/.usagelens.json — all optional:
8
+ // {
9
+ // "sources": [ { "tool": "claude", "dir": "~/.claude", "label": "work@x.com" } ],
10
+ // "pricing": { "opus": { "input": 15, "output": 75, "cacheWrite": 18.75, "cacheRead": 1.5 } },
11
+ // "budget": { "monthlyUsd": 500, "monthlyTokens": 2000000000 },
12
+ // "git": true,
13
+ // "planUsd": 200
14
+ // }
15
+ let cached;
16
+ export function loadConfig() {
17
+ if (cached) return cached;
18
+ const path = join(HOME, '.usagelens.json');
19
+ let raw = {};
20
+ if (existsSync(path)) {
21
+ try { raw = JSON.parse(readFileSync(path, 'utf8')); } catch { raw = {}; }
22
+ }
23
+ cached = {
24
+ sources: Array.isArray(raw.sources) ? raw.sources : null,
25
+ pricing: raw.pricing && typeof raw.pricing === 'object' ? raw.pricing : {},
26
+ budget: raw.budget && typeof raw.budget === 'object' ? raw.budget : null,
27
+ git: raw.git !== false, // default on
28
+ planUsd: typeof raw.planUsd === 'number' ? raw.planUsd : null,
29
+ configPath: existsSync(path) ? path : null,
30
+ };
31
+ return cached;
32
+ }
@@ -0,0 +1,72 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+
6
+ const execFileAsync = promisify(execFile);
7
+ const tokensOf = (e) => e.inputTokens + e.outputTokens + e.cacheReadTokens + e.cacheCreateTokens;
8
+ const iso = (d) => d.toISOString();
9
+
10
+ function isRepo(dir) {
11
+ try { return existsSync(join(dir, '.git')); } catch { return false; }
12
+ }
13
+
14
+ // Commits + lines changed in a repo within [start,end], authored by anyone.
15
+ async function repoStats(dir, start, end) {
16
+ try {
17
+ const { stdout: out } = await execFileAsync('git', [
18
+ '-C', dir, 'log', `--since=${iso(start)}`, `--until=${iso(end)}`,
19
+ '--no-merges', '--numstat', '--pretty=format:%x01%H',
20
+ ], { encoding: 'utf8', timeout: 6000, maxBuffer: 1 << 24 });
21
+ let commits = 0, added = 0, deleted = 0;
22
+ for (const line of out.split('\n')) {
23
+ if (line.startsWith('\x01')) { commits += 1; continue; }
24
+ const m = line.split('\t');
25
+ if (m.length === 3) { added += Number(m[0]) || 0; deleted += Number(m[1]) || 0; }
26
+ }
27
+ return { commits, linesChanged: added + deleted };
28
+ } catch { return null; }
29
+ }
30
+
31
+ // Correlate AI token spend per project with real git activity in the window.
32
+ // Async + parallel across repos (each repo's `git log` runs concurrently).
33
+ export async function gitCorrelation(events, projectPaths, window, enabled) {
34
+ if (!enabled) return { available: false, note: 'disabled in config', rows: [], totals: null };
35
+ const byProject = new Map();
36
+ for (const e of events) {
37
+ const cur = byProject.get(e.project) ?? { costUsd: 0, tokens: 0 };
38
+ cur.costUsd += e.costUsd; cur.tokens += tokensOf(e); byProject.set(e.project, cur);
39
+ }
40
+ // top projects by cost, so we bound how many git calls we make
41
+ const top = [...byProject.entries()].sort((a, b) => b[1].costUsd - a[1].costUsd).slice(0, 15);
42
+ const candidates = top
43
+ .map(([project, agg]) => ({ project, agg, dir: projectPaths.get?.(project) ?? projectPaths[project] }))
44
+ .filter((c) => c.dir && isRepo(c.dir));
45
+ const stats = await Promise.all(candidates.map((c) => repoStats(c.dir, window.start, window.end)));
46
+ const rows = [];
47
+ let anyRepo = false;
48
+ candidates.forEach((c, i) => {
49
+ const st = stats[i];
50
+ if (!st) return;
51
+ anyRepo = true;
52
+ rows.push({
53
+ project: c.project, costUsd: c.agg.costUsd, tokens: c.agg.tokens,
54
+ commits: st.commits, linesChanged: st.linesChanged,
55
+ usdPerCommit: st.commits ? c.agg.costUsd / st.commits : null,
56
+ usdPerKLine: st.linesChanged ? c.agg.costUsd / (st.linesChanged / 1000) : null,
57
+ });
58
+ });
59
+ rows.sort((a, b) => b.costUsd - a.costUsd);
60
+ const totals = rows.reduce((t, r) => ({
61
+ costUsd: t.costUsd + r.costUsd, commits: t.commits + r.commits, linesChanged: t.linesChanged + r.linesChanged,
62
+ }), { costUsd: 0, commits: 0, linesChanged: 0 });
63
+ return {
64
+ available: anyRepo, note: anyRepo ? null : 'no git repos found for active projects',
65
+ rows,
66
+ totals: anyRepo ? {
67
+ ...totals,
68
+ usdPerCommit: totals.commits ? totals.costUsd / totals.commits : null,
69
+ usdPerKLine: totals.linesChanged ? totals.costUsd / (totals.linesChanged / 1000) : null,
70
+ } : null,
71
+ };
72
+ }
@@ -8,8 +8,16 @@ const TABLE = [
8
8
  ];
9
9
  const DEFAULT = { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 };
10
10
 
11
+ // Optional per-key overrides from ~/.usagelens.json, e.g. { "opus": { "input": 12 } }
12
+ let OVERRIDES = {};
13
+ export function setPricingOverrides(o) { OVERRIDES = o && typeof o === 'object' ? o : {}; }
14
+
11
15
  export function priceFor(model) {
12
- return (TABLE.find((r) => r.m.test(model || ''))?.p) ?? DEFAULT;
16
+ const base = (TABLE.find((r) => r.m.test(model || ''))?.p) ?? DEFAULT;
17
+ for (const key in OVERRIDES) {
18
+ if (new RegExp(key, 'i').test(model || '')) return { ...base, ...OVERRIDES[key] };
19
+ }
20
+ return base;
13
21
  }
14
22
 
15
23
  export function costOf(model, t) {
@@ -0,0 +1,44 @@
1
+ const tok = (n) => n >= 1e9 ? (n / 1e9).toFixed(2) + 'B' : n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : String(Math.round(n));
2
+ const usd = (n) => n >= 1000 ? '$' + (n / 1000).toFixed(1) + 'K' : '$' + n.toFixed(2);
3
+ const pct = (n) => (n >= 0 ? '+' : '') + n.toFixed(1) + '%';
4
+
5
+ export function reportText(p) {
6
+ const s = p.summary, L = [];
7
+ L.push(`# usagelens · last ${p.meta.range}`);
8
+ L.push('');
9
+ L.push(`You wrote **${tok(s.totalTokens)} tokens** across **${s.sessions} sessions** (${s.messages} messages, ${s.activeDays} active days, streak ${s.currentStreak}d).`);
10
+ L.push('');
11
+ L.push(`- Est. cost: **${usd(s.costUsd)}** (${pct(s.deltas.costPct)} vs prior)`);
12
+ L.push(`- Tokens: ${tok(s.totalTokens)} (${pct(s.deltas.tokensPct)} vs prior)`);
13
+ L.push(`- Cache hit: ${(s.cacheHitRate * 100).toFixed(0)}% · saved ${usd(s.cacheSavingsUsd)}`);
14
+ L.push('');
15
+ L.push('## Tools');
16
+ for (const t of p.breakdowns.byTool) L.push(`- ${t.key}: ${tok(t.tokens)} · ${usd(t.costUsd)} · ${t.sharePct.toFixed(0)}%`);
17
+ L.push('');
18
+ L.push('## Models');
19
+ for (const m of p.breakdowns.byModel.slice(0, 6)) L.push(`- ${m.key}: ${m.sharePct.toFixed(1)}% · ${usd(m.costUsd)}`);
20
+ L.push('');
21
+ L.push('## Top projects');
22
+ for (const pr of p.breakdowns.byProject.slice(0, 8)) L.push(`- ${pr.key}: ${tok(pr.tokens)} · ${usd(pr.costUsd)}`);
23
+ L.push('');
24
+ L.push('## Efficiency');
25
+ L.push(`- Output/input leverage: ${p.efficiency.outputPerInput.toFixed(2)}×`);
26
+ L.push(`- Premium (Opus) share: ${p.routing.premiumSharePct.toFixed(0)}%`);
27
+ L.push(`- Est. routing savings: ${usd(p.routing.estRoutingSavingsUsd)} (${p.routing.routineCount} routine turns → Sonnet)`);
28
+ L.push('');
29
+ if (p.git.available) {
30
+ L.push('## Cost per shipped code (git)');
31
+ const g = p.git.totals;
32
+ L.push(`- ${usd(g.costUsd)} across ${g.commits} commits, ${tok(g.linesChanged)} lines`);
33
+ L.push(`- ${g.usdPerCommit != null ? usd(g.usdPerCommit) : '—'} / commit · ${g.usdPerKLine != null ? usd(g.usdPerKLine) : '—'} / 1K lines`);
34
+ for (const r of p.git.rows.slice(0, 6)) L.push(` - ${r.project}: ${usd(r.costUsd)} · ${r.commits}c · ${r.usdPerCommit != null ? usd(r.usdPerCommit) + '/c' : '—'}`);
35
+ L.push('');
36
+ }
37
+ if (p.toolUsage.total) {
38
+ L.push('## Tool usage');
39
+ for (const t of p.toolUsage.top.slice(0, 8)) L.push(`- ${t.key}: ${t.count}`);
40
+ L.push('');
41
+ }
42
+ L.push(`_Generated ${p.meta.generatedAt} · cost is estimated API-equivalent, not a bill._`);
43
+ return L.join('\n') + '\n';
44
+ }
package/src/public/app.js CHANGED
@@ -50,6 +50,18 @@ function barRow(label, right, pctWidth, color) {
50
50
  ]);
51
51
  }
52
52
 
53
+ function budgetRow(label, value, ofText, pct, proj) {
54
+ const p = pct ?? 0;
55
+ const color = p >= 100 ? 'var(--warn)' : p >= 80 ? 'var(--c2)' : 'var(--good)';
56
+ return el('div', { class: 'barRow' }, [
57
+ el('div', { class: 'top' }, [
58
+ el('span', {}, `${label} ${value} `),
59
+ el('span', { class: 'r' }, `${ofText} · ${p.toFixed(0)}% · ${proj}`),
60
+ ]),
61
+ el('div', { class: 'track' }, [el('div', { class: 'fill', style: `width:${Math.min(100, Math.max(1, p))}%;background:${color}` })]),
62
+ ]);
63
+ }
64
+
53
65
  // SVG bar chart (daily tokens)
54
66
  function barChart(data, h = 170) {
55
67
  const w = 1000, pad = 4, max = Math.max(1, ...data.map((d) => d.tokens));
@@ -133,6 +145,50 @@ function panel(titleText, meta, body) {
133
145
  return el('div', { class: 'card' }, [head, body]);
134
146
  }
135
147
 
148
+ // ---- git cost-per-shipped-code: on-demand, cached per range ----
149
+ const gitState = {}; // range -> computed git result
150
+ function renderGitBody(g) {
151
+ if (!g.available) return el('div', { class: 'csub' }, g.note || 'no git repos found for active projects');
152
+ const t = g.totals;
153
+ return el('div', {}, [
154
+ el('div', { class: 'statPair', style: 'margin-bottom:12px' }, [
155
+ el('div', {}, [el('div', { class: 'k' }, '$ / COMMIT'), el('div', { class: 'v' }, t.usdPerCommit != null ? fmtUsd(t.usdPerCommit) : '—')]),
156
+ el('div', {}, [el('div', { class: 'k' }, '$ / 1K LINES'), el('div', { class: 'v' }, t.usdPerKLine != null ? fmtUsd(t.usdPerKLine) : '—')]),
157
+ el('div', {}, [el('div', { class: 'k' }, 'COMMITS'), el('div', { class: 'v' }, fmtInt(t.commits))]),
158
+ el('div', {}, [el('div', { class: 'k' }, 'LINES SHIPPED'), el('div', { class: 'v' }, fmtTok(t.linesChanged))]),
159
+ ]),
160
+ ...g.rows.map((r) => el('div', { class: 'barRow' }, [
161
+ el('div', { class: 'top' }, [
162
+ el('span', {}, r.project),
163
+ el('span', { class: 'r' }, `${fmtUsd(r.costUsd)} · ${fmtInt(r.commits)}c · ${r.usdPerCommit != null ? fmtUsd(r.usdPerCommit) + '/c' : '—'}`),
164
+ ]),
165
+ el('div', { class: 'track' }, [el('div', { class: 'fill', style: `width:${(r.costUsd / (g.rows[0].costUsd || 1)) * 100}%;background:var(--c1)` })]),
166
+ ])),
167
+ ]);
168
+ }
169
+ function gitPanel(d) {
170
+ const body = el('div', {});
171
+ const fillButton = () => {
172
+ body.innerHTML = '';
173
+ if (!d.gitEnabled) { body.append(el('div', { class: 'csub' }, 'git correlation disabled in config')); return; }
174
+ const btn = el('button', { class: 'bigbtn' }, '⚡ Compute cost-per-shipped-code');
175
+ const note = el('div', { class: 'csub', style: 'margin-top:10px' }, 'Maps AI spend to real git commits & lines in your repos. Runs `git log` locally — takes a few seconds.');
176
+ btn.addEventListener('click', async () => {
177
+ btn.disabled = true; btn.textContent = 'Computing… (scanning repos)';
178
+ try {
179
+ const g = await (await fetch('/api/git?range=' + range)).json();
180
+ gitState[range] = g; body.innerHTML = ''; body.append(renderGitBody(g));
181
+ } catch (e) {
182
+ btn.disabled = false; btn.textContent = '⚡ Compute cost-per-shipped-code';
183
+ }
184
+ });
185
+ body.append(btn, note);
186
+ };
187
+ if (gitState[range]) body.append(renderGitBody(gitState[range]));
188
+ else fillButton();
189
+ return panel('COST PER SHIPPED CODE · GIT × AI SPEND', 'AI $ mapped to real commits & lines · on-demand', body);
190
+ }
191
+
136
192
  function render(d) {
137
193
  const app = $('#app'); app.innerHTML = '';
138
194
  const s = d.summary, rng = d.meta.range;
@@ -208,6 +264,37 @@ function render(d) {
208
264
  ]);
209
265
  app.append(el('div', { class: 'grid two' }, [panel('SESSION SHAPE', 'by token size', shBody), panel('CACHE EFFECTIVENESS', 'daily hit rate', cacheBody)]));
210
266
 
267
+ // budget (only if configured)
268
+ if (d.budget && (d.budget.monthlyUsd || d.budget.monthlyTokens)) {
269
+ const b = d.budget;
270
+ const rows = [];
271
+ if (b.monthlyUsd) rows.push(budgetRow('SPEND (MTD)', fmtUsd(b.spendUsd), `of ${fmtUsd(b.monthlyUsd)}`, b.usdPct, `proj ${fmtUsd(b.projectedUsd)}`));
272
+ if (b.monthlyTokens) rows.push(budgetRow('TOKENS (MTD)', fmtTok(b.spendTokens), `of ${fmtTok(b.monthlyTokens)}`, b.tokensPct, `proj ${fmtTok(b.projectedTokens)}`));
273
+ app.append(el('div', { class: 'grid' }, [panel('BUDGET', `day ${b.dayOfMonth}/${b.daysInMonth} this month`, el('div', {}, rows))]));
274
+ }
275
+
276
+ // cost per shipped code (git) — on-demand (shells out to git; ~seconds)
277
+ app.append(el('div', { class: 'grid' }, [gitPanel(d)]));
278
+
279
+ // efficiency + routing, and tool usage
280
+ const eff = d.efficiency, rt = d.routing;
281
+ const effBody = el('div', {}, [
282
+ el('div', { style: 'font-size:30px;font-weight:700;color:var(--good)' }, fmtUsd(rt.estRoutingSavingsUsd)),
283
+ el('div', { class: 'csub', style: 'margin-bottom:12px' }, `est. saved if ${fmtInt(rt.routineCount)} routine Opus turns routed to Sonnet`),
284
+ el('div', { class: 'statPair' }, [
285
+ el('div', {}, [el('div', { class: 'k' }, 'PREMIUM SHARE'), el('div', { class: 'v' }, rt.premiumSharePct.toFixed(0) + '%')]),
286
+ el('div', {}, [el('div', { class: 'k' }, 'OUTPUT/INPUT'), el('div', { class: 'v' }, eff.outputPerInput.toFixed(2) + '×')]),
287
+ el('div', {}, [el('div', { class: 'k' }, '$ / 1M OUT'), el('div', { class: 'v' }, fmtUsd(eff.costPerMOutput))]),
288
+ ]),
289
+ el('div', { class: 'csub', style: 'margin-top:10px' }, `${fmtInt(eff.thrashCount)} thrash turns (big input, tiny output) · ${fmtUsd(eff.thrashCostUsd)}`),
290
+ ]);
291
+ const tu = d.toolUsage;
292
+ const maxTu = tu.top[0]?.count || 1;
293
+ const tuBody = tu.total === 0
294
+ ? el('div', { class: 'csub' }, 'no tool-call data in range')
295
+ : el('div', {}, tu.top.map((t) => barRow(t.key, fmtInt(t.count), (t.count / maxTu) * 100, 'var(--c4)')));
296
+ app.append(el('div', { class: 'grid two' }, [panel('COST EFFICIENCY · MODEL ROUTING', 'where spend could shrink', effBody), panel('TOOL USAGE', `${fmtInt(tu.total)} calls`, tuBody)]));
297
+
211
298
  // authorship
212
299
  let authBody;
213
300
  if (d.authorship.totalCommits === 0) authBody = el('div', { class: 'csub' }, 'No Cursor commit data in this range.');
@@ -217,9 +304,28 @@ function render(d) {
217
304
  ]);
218
305
  app.append(el('div', { class: 'grid' }, [panel('CODE AUTHORSHIP · CURSOR', 'weekly AI vs human lines', authBody)]));
219
306
 
307
+ // activity (Antigravity / Gemini) — count-only signal, no tokens
308
+ if (d.activity && d.activity.total > 0) {
309
+ const maxA = d.activity.byProject[0]?.count || 1;
310
+ const actBody = el('div', {}, [
311
+ el('div', { class: 'statPair', style: 'margin-bottom:10px' }, [
312
+ el('div', {}, [el('div', { class: 'k' }, 'SESSIONS'), el('div', { class: 'v' }, fmtInt(d.activity.sessions))]),
313
+ el('div', {}, [el('div', { class: 'k' }, 'AI-TOUCHED FILES'), el('div', { class: 'v' }, fmtInt(d.activity.files))]),
314
+ el('div', {}, [el('div', { class: 'k' }, 'ACTIVE DAYS'), el('div', { class: 'v' }, fmtInt(d.activity.activeDays))]),
315
+ ]),
316
+ ...d.activity.byProject.map((p) => barRow(p.key, `${fmtInt(p.count)}`, (p.count / maxA) * 100, 'var(--c5)')),
317
+ ]);
318
+ app.append(el('div', { class: 'grid' }, [panel('ACTIVITY · ANTIGRAVITY (GEMINI)', 'sessions & AI-touched files · no token data', actBody)]));
319
+ }
320
+
220
321
  // tool chips
221
- const chips = el('div', { class: 'tools' }, d.tools.map((t) =>
222
- el('div', { class: 'chip ' + (t.available ? 'on' : ''), html: `<span class="d" style="background:${t.available ? 'var(--good)' : 'var(--line)'};width:8px;height:8px;border-radius:50%;display:inline-block"></span><b>${esc(t.tool)}</b> ${t.available ? (t.count != null ? fmtInt(t.count) + ' records' : 'active') : esc(t.note || 'no data')}` })));
322
+ const kindLabel = { tokens: 'tokens', authorship: 'code lines', activity: 'activity' };
323
+ const chips = el('div', { class: 'tools' }, d.tools.map((t) => {
324
+ const detail = t.available
325
+ ? `${t.count != null ? fmtInt(t.count) + ' ' + (kindLabel[t.kind] || 'records') : 'active'}${t.account ? ' · ' + esc(t.account) : ''}`
326
+ : esc(t.note || 'no data');
327
+ return el('div', { class: 'chip ' + (t.available ? 'on' : ''), html: `<span class="d" style="background:${t.available ? 'var(--good)' : 'var(--line)'};width:8px;height:8px;border-radius:50%;display:inline-block"></span><b>${esc(t.tool)}</b> ${detail}` });
328
+ }));
223
329
  app.append(el('div', {}, [el('div', { class: 'clabel', style: 'margin-top:8px' }, 'TOOLS DETECTED'), chips]));
224
330
  app.append(el('div', { class: 'foot' }, `Generated ${new Date(d.meta.generatedAt).toLocaleString()} · read-only from ~/.claude, ~/.codex, ~/.cursor · cost is estimated API-equivalent, not a bill`));
225
331
  }
@@ -227,7 +333,7 @@ function render(d) {
227
333
  let range = '30d';
228
334
  async function load() {
229
335
  try {
230
- const r = await fetch('/api/dashboard?range=' + range);
336
+ const r = await fetch(`/api/dashboard?range=${range}&account=all`);
231
337
  render(await r.json());
232
338
  } catch (e) { $('#app').innerHTML = '<div class="loading">Failed: ' + e.message + '</div>'; }
233
339
  }
@@ -237,4 +343,11 @@ $('#toggle').addEventListener('click', (e) => {
237
343
  [...$('#toggle').children].forEach((c) => c.classList.toggle('on', c === b));
238
344
  $('#app').innerHTML = '<div class="loading">Loading…</div>'; load();
239
345
  });
346
+ $('#refresh').addEventListener('click', async () => {
347
+ const btn = $('#refresh'); btn.disabled = true; btn.textContent = '⟳ Scanning…';
348
+ for (const k in gitState) delete gitState[k]; // git cache is stale after a re-scan
349
+ try { await fetch('/api/refresh'); } catch {}
350
+ await load();
351
+ btn.disabled = false; btn.textContent = '⟳ Refresh';
352
+ });
240
353
  load();
@@ -22,6 +22,10 @@
22
22
  h1.hero b{color:var(--accent);font-weight:600;font-variant-numeric:tabular-nums}
23
23
  .heroSub{color:var(--sub);font-size:14px;margin:0}
24
24
  .heroSub b{color:var(--ink);font-weight:600}
25
+ .controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
26
+ #refresh{font-family:var(--mono);font-size:13px;color:var(--sub);background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:7px 12px;cursor:pointer}
27
+ #refresh:hover{color:var(--accent);border-color:var(--accent)}
28
+ #refresh:disabled{opacity:.5;cursor:wait}
25
29
  .toggle{display:inline-flex;gap:2px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:3px}
26
30
  .toggle button{border:0;background:transparent;color:var(--sub);font-weight:600;font-size:13px;
27
31
  padding:6px 14px;border-radius:7px;cursor:pointer;font-family:var(--mono)}
@@ -63,6 +67,8 @@
63
67
  .chip b{color:var(--ink)} .chip.on b{color:var(--good)} .chip .d{margin-right:6px}
64
68
  .foot{margin-top:22px;font-size:12px;color:var(--sub);font-family:var(--mono)}
65
69
  .loading{padding:60px;text-align:center;color:var(--sub)}
70
+ .bigbtn{font-family:var(--mono);font-size:14px;font-weight:600;color:#fff;background:var(--accent);border:0;border-radius:10px;padding:12px 20px;cursor:pointer}
71
+ .bigbtn:hover{filter:brightness(1.08)} .bigbtn:disabled{opacity:.6;cursor:wait}
66
72
  svg{display:block;width:100%;overflow:visible}
67
73
  .tip{fill:var(--sub);font-size:9px;font-family:var(--mono)}
68
74
  </style>
@@ -71,10 +77,13 @@
71
77
  <div class="wrap">
72
78
  <div class="row">
73
79
  <div class="eyebrow" id="eyebrow">ANALYTICS · LAST 30 DAYS</div>
74
- <div class="toggle" id="toggle">
75
- <button data-r="7d">7d</button>
76
- <button data-r="30d" class="on">30d</button>
77
- <button data-r="90d">90d</button>
80
+ <div class="controls">
81
+ <button id="refresh" title="Re-scan local data">⟳ Refresh</button>
82
+ <div class="toggle" id="toggle">
83
+ <button data-r="7d">7d</button>
84
+ <button data-r="30d" class="on">30d</button>
85
+ <button data-r="90d">90d</button>
86
+ </div>
78
87
  </div>
79
88
  </div>
80
89
  <div id="app"><div class="loading">Reading your local AI data…</div></div>
package/src/server.mjs CHANGED
@@ -13,7 +13,7 @@ import { platform } from 'node:os';
13
13
  import { extname, join } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { loadAll } from './lib/adapters.mjs';
16
- import { buildPayload } from './lib/aggregate.mjs';
16
+ import { buildPayload, gitReport } from './lib/aggregate.mjs';
17
17
 
18
18
  const PUBLIC = join(fileURLToPath(new URL('.', import.meta.url)), 'public');
19
19
  const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml' };
@@ -27,23 +27,72 @@ function parseArgs(argv) {
27
27
  return o;
28
28
  }
29
29
 
30
+ function scan(quiet) {
31
+ const t0 = Date.now();
32
+ const ds = loadAll();
33
+ if (!quiet) {
34
+ process.stdout.write(`usagelens · loaded ${ds.events.length} usage events + ${ds.commits.length} commits + ${ds.activity.length} activity in ${Date.now() - t0}ms\n`);
35
+ process.stdout.write(`usagelens · accounts: ${ds.accounts.join(', ') || 'none'}\n`);
36
+ for (const s of ds.statuses) process.stdout.write(` ${s.available ? '✓' : '·'} ${s.tool}${s.count != null ? ` (${s.count})` : ''}${s.note ? ' — ' + s.note : ''}\n`);
37
+ }
38
+ return ds;
39
+ }
40
+
41
+ // ---- headless report mode: `usagelens report [--json] [--range 30d] ----
42
+ if (process.argv[2] === 'report') {
43
+ const args = process.argv.slice(3);
44
+ const json = args.includes('--json');
45
+ const ri = args.indexOf('--range');
46
+ const range = ri >= 0 && ['7d', '30d', '90d'].includes(args[ri + 1]) ? args[ri + 1] : '30d';
47
+ const { reportText } = await import('./lib/report.mjs');
48
+ const ds = scan(true);
49
+ const now = new Date();
50
+ const payload = buildPayload(ds, range, now, 'all');
51
+ payload.git = await gitReport(ds, range, now, 'all'); // report includes git (CLI can afford it)
52
+ process.stdout.write(json ? JSON.stringify(payload, null, 2) + '\n' : reportText(payload));
53
+ process.exit(0);
54
+ }
55
+
30
56
  const { port, open } = parseArgs(process.argv.slice(2));
31
57
 
32
- process.stdout.write('usagelens · scanning ~/.claude, ~/.codex, ~/.cursor …\n');
33
- const t0 = Date.now();
34
- const dataset = loadAll();
35
- process.stdout.write(`usagelens · loaded ${dataset.events.length} usage events + ${dataset.commits.length} commits in ${Date.now() - t0}ms\n`);
36
- for (const s of dataset.statuses) process.stdout.write(` ${s.available ? '✓' : '·'} ${s.tool}${s.count != null ? ` (${s.count})` : ''}${s.note ? ' ' + s.note : ''}\n`);
58
+ process.stdout.write('usagelens · scanning Claude, Codex, Cursor, Antigravity …\n');
59
+ let dataset = scan(false);
60
+
61
+ const gitCache = new Map(); // key `range|account` -> JSON string; cleared on refresh
62
+ const dashCache = new Map(); // same; snapshot valid until next refresh
37
63
 
38
64
  const server = createServer((req, res) => {
39
65
  const url = new URL(req.url ?? '/', 'http://localhost');
40
66
  if (url.pathname === '/api/dashboard') {
41
67
  const rq = url.searchParams.get('range');
42
68
  const range = ['7d', '30d', '90d'].includes(rq) ? rq : '30d';
43
- const body = JSON.stringify(buildPayload(dataset, range, new Date()));
69
+ const account = url.searchParams.get('account') || 'all';
70
+ const key = `${range}|${account}`;
71
+ let body = dashCache.get(key);
72
+ if (!body) { body = JSON.stringify(buildPayload(dataset, range, new Date(), account)); dashCache.set(key, body); }
44
73
  res.writeHead(200, { 'content-type': 'application/json' }).end(body);
45
74
  return;
46
75
  }
76
+ if (url.pathname === '/api/git') {
77
+ const rq = url.searchParams.get('range');
78
+ const range = ['7d', '30d', '90d'].includes(rq) ? rq : '30d';
79
+ const account = url.searchParams.get('account') || 'all';
80
+ const key = `${range}|${account}`;
81
+ if (gitCache.has(key)) { res.writeHead(200, { 'content-type': 'application/json' }).end(gitCache.get(key)); return; }
82
+ gitReport(dataset, range, new Date(), account).then((g) => {
83
+ const body = JSON.stringify(g);
84
+ gitCache.set(key, body);
85
+ res.writeHead(200, { 'content-type': 'application/json' }).end(body);
86
+ }).catch((e) => { res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ available: false, note: 'git error: ' + e.message, rows: [] })); });
87
+ return;
88
+ }
89
+ if (url.pathname === '/api/refresh') {
90
+ dataset = scan(true);
91
+ gitCache.clear();
92
+ dashCache.clear();
93
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, events: dataset.events.length }));
94
+ return;
95
+ }
47
96
  const name = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\/+/, '').replace(/\.\./g, '');
48
97
  const file = join(PUBLIC, name);
49
98
  if (existsSync(file) && statSync(file).isFile()) {
@@ -54,7 +103,8 @@ const server = createServer((req, res) => {
54
103
  }
55
104
  });
56
105
 
57
- server.listen(port, () => {
106
+ // Bind to loopback only — the dashboard exposes your usage/projects/cost.
107
+ server.listen(port, '127.0.0.1', () => {
58
108
  const link = `http://localhost:${port}`;
59
109
  process.stdout.write(`\nusagelens · dashboard → ${link}\n`);
60
110
  if (open) {