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 +33 -9
- package/package.json +1 -1
- package/src/lib/adapters.mjs +20 -16
- package/src/lib/aggregate.mjs +19 -0
- package/src/lib/analytics.mjs +73 -0
- package/src/lib/config.mjs +32 -0
- package/src/lib/git.mjs +72 -0
- package/src/lib/pricing.mjs +9 -1
- package/src/lib/report.mjs +44 -0
- package/src/public/app.js +94 -0
- package/src/public/index.html +12 -5
- package/src/server.mjs +56 -8
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
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
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
|
|
25
|
-
usagelens --port 8080
|
|
26
|
-
usagelens --no-open
|
|
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.
|
|
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": {
|
package/src/lib/adapters.mjs
CHANGED
|
@@ -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
|
|
174
|
-
|
|
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
|
}
|
package/src/lib/aggregate.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/git.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/lib/pricing.mjs
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/src/public/index.html
CHANGED
|
@@ -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
|
-
#
|
|
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="
|
|
77
|
-
<button
|
|
78
|
-
<
|
|
79
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|