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 +33 -9
- package/package.json +10 -2
- package/src/lib/accounts.mjs +68 -0
- package/src/lib/adapters.mjs +118 -56
- package/src/lib/aggregate.mjs +45 -5
- 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 +116 -3
- package/src/public/index.html +13 -4
- package/src/server.mjs +58 -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": {
|
|
@@ -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'; }
|
package/src/lib/adapters.mjs
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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: '
|
|
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
|
-
//
|
|
123
|
-
|
|
124
|
-
const
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
//
|
|
133
|
-
for (const [tool, rel
|
|
134
|
-
|
|
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
|
}
|
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 };
|
|
@@ -141,11 +143,42 @@ function authorship(commits) {
|
|
|
141
143
|
};
|
|
142
144
|
}
|
|
143
145
|
|
|
144
|
-
|
|
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
|
|
147
|
-
const
|
|
148
|
-
const
|
|
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
|
-
|
|
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
|
+
}
|
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.');
|
|
@@ -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
|
|
222
|
-
|
|
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(
|
|
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();
|
package/src/public/index.html
CHANGED
|
@@ -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="
|
|
75
|
-
<button
|
|
76
|
-
<
|
|
77
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|