usagelens 0.2.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.2.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": {
@@ -2,8 +2,9 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import { homedir } from 'node:os';
4
4
  import { basename, join } from 'node:path';
5
- import { costOf } from './pricing.mjs';
5
+ import { costOf, setPricingOverrides } from './pricing.mjs';
6
6
  import { antigravityAccount, claudeAccount, codexAccount, fallbackAccount } from './accounts.mjs';
7
+ import { loadConfig } from './config.mjs';
7
8
 
8
9
  const HOME = homedir();
9
10
  const require = createRequire(import.meta.url);
@@ -25,7 +26,7 @@ function walk(dir, ext) {
25
26
  export function loadClaude(dir, account) {
26
27
  const root = dir ?? join(HOME, '.claude', 'projects');
27
28
  if (!existsSync(root)) return { events: [], commits: [], activity: [], status: { tool: 'claude', account, available: false, note: 'no projects dir' } };
28
- const events = [];
29
+ const events = [], toolcalls = [];
29
30
  for (const f of walk(root, '.jsonl')) {
30
31
  let text; try { text = readFileSync(f, 'utf8'); } catch { continue; }
31
32
  for (const line of text.split('\n')) {
@@ -35,6 +36,12 @@ export function loadClaude(dir, account) {
35
36
  const m = row.message, u = m?.usage;
36
37
  if (!u || !m?.model) continue;
37
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
+ }
38
45
  const t = {
39
46
  inputTokens: u.input_tokens ?? 0, outputTokens: u.output_tokens ?? 0,
40
47
  cacheReadTokens: u.cache_read_input_tokens ?? 0, cacheCreateTokens: u.cache_creation_input_tokens ?? 0,
@@ -46,7 +53,7 @@ export function loadClaude(dir, account) {
46
53
  });
47
54
  }
48
55
  }
49
- return { events, commits: [], activity: [], status: statusOf('claude', account, events) };
56
+ return { events, commits: [], activity: [], toolcalls, status: statusOf('claude', account, events) };
50
57
  }
51
58
 
52
59
  // ---------- Codex ----------
@@ -157,22 +164,17 @@ function defaultSources() {
157
164
  ];
158
165
  }
159
166
 
160
- function configuredSources() {
161
- const cfg = join(HOME, '.usagelens.json');
162
- if (!existsSync(cfg)) return null;
163
- try {
164
- const j = JSON.parse(readFileSync(cfg, 'utf8'));
165
- if (!Array.isArray(j.sources) || j.sources.length === 0) return null;
166
- return j.sources.map((s) => ({ tool: s.tool, dir: expand(s.dir ?? ''), account: s.label ?? s.account ?? null }));
167
- } catch { return null; }
168
- }
169
-
170
167
  const LOADERS = { claude: loadClaude, codex: loadCodex, cursor: loadCursor, antigravity: loadAntigravity };
171
168
 
172
169
  export function loadAll() {
173
- const sources = configuredSources() ?? defaultSources();
174
- const events = [], commits = [], activity = [], statuses = [];
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 = [];
175
176
  const accounts = new Set();
177
+ const projectPaths = new Map(); // project name -> real filesystem path (for git correlation)
176
178
  for (const s of sources) {
177
179
  const loader = LOADERS[s.tool];
178
180
  if (!loader) { statuses.push({ tool: s.tool, available: false, note: 'unknown tool' }); continue; }
@@ -180,6 +182,8 @@ export function loadAll() {
180
182
  try {
181
183
  const r = loader(s.dir, account);
182
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);
183
187
  statuses.push(r.status);
184
188
  if (r.status.available) accounts.add(account);
185
189
  } catch (e) { statuses.push({ tool: s.tool, account, available: false, note: e.message }); }
@@ -191,5 +195,5 @@ export function loadAll() {
191
195
  ]) {
192
196
  if (existsSync(join(HOME, rel))) statuses.push({ tool, available: false, note });
193
197
  }
194
- return { events, commits, activity, statuses, accounts: [...accounts].sort() };
198
+ return { events, commits, activity, toolcalls, statuses, accounts: [...accounts].sort(), projectPaths, config: cfg };
195
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 };
@@ -156,6 +158,15 @@ function activitySummary(activity) {
156
158
  };
157
159
  }
158
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
+
159
170
  export function buildPayload(ds, range, now, account) {
160
171
  const win = rangeWindow(range, now), prior = priorWindow(range, now);
161
172
  const pick = (arr) => (account && account !== 'all' ? arr.filter((x) => x.account === account) : arr);
@@ -164,7 +175,10 @@ export function buildPayload(ds, range, now, account) {
164
175
  const prev = evAll.filter((e) => inWin(e.timestamp, prior));
165
176
  const commits = coAll.filter((c) => inWin(c.timestamp, win));
166
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));
167
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 };
168
182
  return {
169
183
  summary: summarize(cur, prev),
170
184
  breakdowns: { byTool: group(cur, (e) => e.tool), byModel: group(cur, (e) => e.model), byProject: group(cur, (e) => e.project, 12) },
@@ -173,6 +187,11 @@ export function buildPayload(ds, range, now, account) {
173
187
  sessions: sessionShape(cur),
174
188
  authorship: authorship(commits),
175
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),
176
195
  tools: ds.statuses,
177
196
  accounts: ds.accounts ?? [],
178
197
  meta: { range, account: account ?? 'all', generatedAt: now.toISOString(), lastActivity: lastAll.reduce((a, t) => (!a || t > a ? t : a), undefined) },
@@ -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.');
@@ -256,4 +343,11 @@ $('#toggle').addEventListener('click', (e) => {
256
343
  [...$('#toggle').children].forEach((c) => c.classList.toggle('on', c === b));
257
344
  $('#app').innerHTML = '<div class="loading">Loading…</div>'; load();
258
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
+ });
259
353
  load();
@@ -23,7 +23,9 @@
23
23
  .heroSub{color:var(--sub);font-size:14px;margin:0}
24
24
  .heroSub b{color:var(--ink);font-weight:600}
25
25
  .controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
26
- #account{font-family:var(--mono);font-size:13px;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:7px 12px;cursor:pointer;max-width:280px}
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}
27
29
  .toggle{display:inline-flex;gap:2px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:3px}
28
30
  .toggle button{border:0;background:transparent;color:var(--sub);font-weight:600;font-size:13px;
29
31
  padding:6px 14px;border-radius:7px;cursor:pointer;font-family:var(--mono)}
@@ -65,6 +67,8 @@
65
67
  .chip b{color:var(--ink)} .chip.on b{color:var(--good)} .chip .d{margin-right:6px}
66
68
  .foot{margin-top:22px;font-size:12px;color:var(--sub);font-family:var(--mono)}
67
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}
68
72
  svg{display:block;width:100%;overflow:visible}
69
73
  .tip{fill:var(--sub);font-size:9px;font-family:var(--mono)}
70
74
  </style>
@@ -73,10 +77,13 @@
73
77
  <div class="wrap">
74
78
  <div class="row">
75
79
  <div class="eyebrow" id="eyebrow">ANALYTICS · LAST 30 DAYS</div>
76
- <div class="toggle" id="toggle">
77
- <button data-r="7d">7d</button>
78
- <button data-r="30d" class="on">30d</button>
79
- <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>
80
87
  </div>
81
88
  </div>
82
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,14 +27,39 @@ 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
58
  process.stdout.write('usagelens · scanning Claude, Codex, Cursor, Antigravity …\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 + ${dataset.activity.length} activity in ${Date.now() - t0}ms\n`);
36
- process.stdout.write(`usagelens · accounts: ${dataset.accounts.join(', ') || 'none'}\n`);
37
- for (const s of dataset.statuses) process.stdout.write(` ${s.available ? '✓' : '·'} ${s.tool}${s.count != null ? ` (${s.count})` : ''}${s.note ? ' — ' + s.note : ''}\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
38
63
 
39
64
  const server = createServer((req, res) => {
40
65
  const url = new URL(req.url ?? '/', 'http://localhost');
@@ -42,10 +67,32 @@ const server = createServer((req, res) => {
42
67
  const rq = url.searchParams.get('range');
43
68
  const range = ['7d', '30d', '90d'].includes(rq) ? rq : '30d';
44
69
  const account = url.searchParams.get('account') || 'all';
45
- const body = JSON.stringify(buildPayload(dataset, range, new Date(), account));
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); }
46
73
  res.writeHead(200, { 'content-type': 'application/json' }).end(body);
47
74
  return;
48
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
+ }
49
96
  const name = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\/+/, '').replace(/\.\./g, '');
50
97
  const file = join(PUBLIC, name);
51
98
  if (existsSync(file) && statSync(file).isFile()) {
@@ -56,7 +103,8 @@ const server = createServer((req, res) => {
56
103
  }
57
104
  });
58
105
 
59
- server.listen(port, () => {
106
+ // Bind to loopback only — the dashboard exposes your usage/projects/cost.
107
+ server.listen(port, '127.0.0.1', () => {
60
108
  const link = `http://localhost:${port}`;
61
109
  process.stdout.write(`\nusagelens · dashboard → ${link}\n`);
62
110
  if (open) {