usagelens 0.1.0 → 0.2.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/package.json +10 -2
- package/src/lib/accounts.mjs +68 -0
- package/src/lib/adapters.mjs +112 -54
- package/src/lib/aggregate.mjs +26 -5
- package/src/public/app.js +22 -3
- package/src/public/index.html +2 -0
- package/src/server.mjs +5 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usagelens",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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,195 @@
|
|
|
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
5
|
import { costOf } from './pricing.mjs';
|
|
6
|
+
import { antigravityAccount, claudeAccount, codexAccount, fallbackAccount } from './accounts.mjs';
|
|
5
7
|
|
|
6
8
|
const HOME = homedir();
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const expand = (p) => (p.startsWith('~') ? join(HOME, p.slice(1)) : p);
|
|
7
11
|
|
|
8
|
-
function walk(dir) {
|
|
12
|
+
function walk(dir, ext) {
|
|
9
13
|
if (!existsSync(dir)) return [];
|
|
10
14
|
const out = [];
|
|
11
15
|
for (const name of readdirSync(dir)) {
|
|
12
16
|
let p = join(dir, name), st;
|
|
13
17
|
try { st = statSync(p); } catch { continue; }
|
|
14
|
-
if (st.isDirectory()) out.push(...walk(p));
|
|
15
|
-
else if (name.endsWith(
|
|
18
|
+
if (st.isDirectory()) out.push(...walk(p, ext));
|
|
19
|
+
else if (!ext || name.endsWith(ext)) out.push(p);
|
|
16
20
|
}
|
|
17
21
|
return out;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
// ---------- 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
|
|
25
|
+
export function loadClaude(dir, account) {
|
|
26
|
+
const root = dir ?? join(HOME, '.claude', 'projects');
|
|
27
|
+
if (!existsSync(root)) return { events: [], commits: [], activity: [], status: { tool: 'claude', account, available: false, note: 'no projects dir' } };
|
|
24
28
|
const events = [];
|
|
25
|
-
for (const f of walk(root)) {
|
|
26
|
-
let text;
|
|
27
|
-
try { text = readFileSync(f, 'utf8'); } catch { continue; }
|
|
29
|
+
for (const f of walk(root, '.jsonl')) {
|
|
30
|
+
let text; try { text = readFileSync(f, 'utf8'); } catch { continue; }
|
|
28
31
|
for (const line of text.split('\n')) {
|
|
29
32
|
if (!line.trim()) continue;
|
|
30
|
-
let row;
|
|
31
|
-
try { row = JSON.parse(line); } catch { continue; }
|
|
33
|
+
let row; try { row = JSON.parse(line); } catch { continue; }
|
|
32
34
|
if (row.type !== 'assistant') continue;
|
|
33
35
|
const m = row.message, u = m?.usage;
|
|
34
36
|
if (!u || !m?.model) continue;
|
|
35
37
|
const cwd = row.cwd ?? 'unknown';
|
|
36
38
|
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,
|
|
39
|
+
inputTokens: u.input_tokens ?? 0, outputTokens: u.output_tokens ?? 0,
|
|
40
|
+
cacheReadTokens: u.cache_read_input_tokens ?? 0, cacheCreateTokens: u.cache_creation_input_tokens ?? 0,
|
|
41
41
|
};
|
|
42
42
|
events.push({
|
|
43
|
-
tool: 'claude', model: m.model, project: basename(cwd), projectPath: cwd,
|
|
43
|
+
tool: 'claude', account, model: m.model, project: basename(cwd), projectPath: cwd,
|
|
44
44
|
gitBranch: row.gitBranch || undefined, timestamp: row.timestamp,
|
|
45
45
|
sessionId: row.sessionId ?? basename(f, '.jsonl'), ...t, costUsd: costOf(m.model, t),
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
-
|
|
50
|
-
return { events, commits: [], status: { tool: 'claude', available: true, lastActivity: last, count: events.length } };
|
|
49
|
+
return { events, commits: [], activity: [], status: statusOf('claude', account, events) };
|
|
51
50
|
}
|
|
52
51
|
|
|
53
52
|
// ---------- 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
|
|
53
|
+
export function loadCodex(dir, account) {
|
|
54
|
+
const base = dir ?? join(HOME, '.codex');
|
|
55
|
+
const files = ['sessions', 'archived_sessions'].flatMap((r) => walk(join(base, r), '.jsonl'));
|
|
56
|
+
if (files.length === 0) return { events: [], commits: [], activity: [], status: { tool: 'codex', account, available: false, note: 'no sessions' } };
|
|
58
57
|
const events = [];
|
|
59
58
|
for (const f of files) {
|
|
60
|
-
let text;
|
|
61
|
-
try { text = readFileSync(f, 'utf8'); } catch { continue; }
|
|
59
|
+
let text; try { text = readFileSync(f, 'utf8'); } catch { continue; }
|
|
62
60
|
let model = 'gpt-5', cwd = 'unknown', sessionId = basename(f, '.jsonl');
|
|
63
61
|
for (const line of text.split('\n')) {
|
|
64
62
|
if (!line.trim()) continue;
|
|
65
|
-
let row;
|
|
66
|
-
try { row = JSON.parse(line); } catch { continue; }
|
|
63
|
+
let row; try { row = JSON.parse(line); } catch { continue; }
|
|
67
64
|
const p = row.payload ?? row;
|
|
68
|
-
// Codex sessions nest the real event type inside event_msg/response_item payloads.
|
|
69
65
|
const kind = (row.type === 'event_msg' || row.type === 'response_item') ? (p.type ?? row.type) : row.type;
|
|
70
66
|
if (kind === 'session_meta') { cwd = p.cwd ?? cwd; sessionId = p.id ?? sessionId; }
|
|
71
67
|
else if (kind === 'turn_context') { if (p.model) model = p.model; if (p.cwd) cwd = p.cwd; }
|
|
72
68
|
else if (kind === 'token_count') {
|
|
73
|
-
const last = p.info?.last_token_usage;
|
|
74
|
-
if (!last) continue;
|
|
69
|
+
const last = p.info?.last_token_usage; if (!last) continue;
|
|
75
70
|
const cached = last.cached_input_tokens ?? 0;
|
|
76
71
|
const t = {
|
|
77
|
-
inputTokens: Math.max(0, (last.input_tokens ?? 0) - cached),
|
|
78
|
-
outputTokens: last.output_tokens ?? 0,
|
|
72
|
+
inputTokens: Math.max(0, (last.input_tokens ?? 0) - cached), outputTokens: last.output_tokens ?? 0,
|
|
79
73
|
cacheReadTokens: cached, cacheCreateTokens: 0,
|
|
80
74
|
};
|
|
81
75
|
if (t.inputTokens + t.outputTokens + t.cacheReadTokens === 0) continue;
|
|
82
76
|
events.push({
|
|
83
|
-
tool: 'codex', model, project: basename(cwd), projectPath: cwd,
|
|
77
|
+
tool: 'codex', account, model, project: basename(cwd), projectPath: cwd,
|
|
84
78
|
timestamp: row.timestamp, sessionId, ...t, costUsd: costOf(model, t),
|
|
85
79
|
});
|
|
86
80
|
}
|
|
87
81
|
}
|
|
88
82
|
}
|
|
89
|
-
|
|
90
|
-
return { events, commits: [], status: { tool: 'codex', available: true, lastActivity: last, count: events.length } };
|
|
83
|
+
return { events, commits: [], activity: [], status: statusOf('codex', account, events) };
|
|
91
84
|
}
|
|
92
85
|
|
|
93
86
|
// ---------- 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
|
|
87
|
+
export function loadCursor(dir, account) {
|
|
88
|
+
const path = join(dir ?? join(HOME, '.cursor'), 'ai-tracking', 'ai-code-tracking.db');
|
|
89
|
+
if (!existsSync(path)) return { events: [], commits: [], activity: [], status: { tool: 'cursor', account, available: false, note: 'no ai-tracking db' } };
|
|
97
90
|
const commits = [];
|
|
98
91
|
try {
|
|
99
92
|
const { DatabaseSync } = require('node:sqlite');
|
|
100
93
|
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();
|
|
94
|
+
const rows = db.prepare('SELECT commitHash, branchName, scoredAt, linesAdded, v1AiPercentage, v2AiPercentage FROM scored_commits').all();
|
|
104
95
|
for (const r of rows) {
|
|
105
96
|
const total = r.linesAdded ?? 0;
|
|
106
97
|
const aiPct = Number(r.v2AiPercentage ?? r.v1AiPercentage ?? 0) || 0;
|
|
107
98
|
const aiLines = Math.round((total * aiPct) / 100);
|
|
108
99
|
commits.push({
|
|
109
|
-
tool: 'cursor', timestamp: new Date(Number(r.scoredAt)).toISOString(),
|
|
100
|
+
tool: 'cursor', account, timestamp: new Date(Number(r.scoredAt)).toISOString(),
|
|
110
101
|
commitHash: r.commitHash, branchName: r.branchName ?? 'unknown',
|
|
111
102
|
totalLines: total, aiLines, humanLines: Math.max(0, total - aiLines), aiPct,
|
|
112
103
|
});
|
|
113
104
|
}
|
|
114
105
|
db.close();
|
|
115
106
|
} catch (e) {
|
|
116
|
-
return { events: [], commits: [], status: { tool: 'cursor', available: false, note: '
|
|
107
|
+
return { events: [], commits: [], activity: [], status: { tool: 'cursor', account, available: false, note: 'db unreadable: ' + e.message } };
|
|
117
108
|
}
|
|
118
109
|
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 } };
|
|
110
|
+
return { events: [], commits, activity: [], status: { tool: 'cursor', account, available: true, lastActivity: last, count: commits.length, kind: 'authorship' } };
|
|
120
111
|
}
|
|
121
112
|
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
const
|
|
113
|
+
// ---------- Antigravity (Gemini) — activity only, no tokens ----------
|
|
114
|
+
export function loadAntigravity(dir, account) {
|
|
115
|
+
const base = dir ?? join(HOME, '.gemini', 'antigravity');
|
|
116
|
+
if (!existsSync(base)) return { events: [], commits: [], activity: [], status: { tool: 'antigravity', account, available: false, note: 'not installed' } };
|
|
117
|
+
const activity = [];
|
|
118
|
+
// conversations = sessions
|
|
119
|
+
for (const f of walk(join(base, 'conversations'), '.pb')) {
|
|
120
|
+
let ts; try { ts = new Date(statSync(f).mtimeMs).toISOString(); } catch { continue; }
|
|
121
|
+
activity.push({ tool: 'antigravity', account, kind: 'session', project: 'antigravity', timestamp: ts });
|
|
122
|
+
}
|
|
123
|
+
// code_tracker = AI-touched files, project = workspace dir (minus trailing hash)
|
|
124
|
+
for (const root of ['active', 'history']) {
|
|
125
|
+
const rd = join(base, 'code_tracker', root);
|
|
126
|
+
if (!existsSync(rd)) continue;
|
|
127
|
+
for (const ws of readdirSync(rd)) {
|
|
128
|
+
const wsDir = join(rd, ws);
|
|
129
|
+
let st; try { st = statSync(wsDir); } catch { continue; }
|
|
130
|
+
if (!st.isDirectory()) continue;
|
|
131
|
+
const project = ws.replace(/_[0-9a-f]{40}$/, '') || 'antigravity';
|
|
132
|
+
for (const f of walk(wsDir)) {
|
|
133
|
+
const m = basename(f).match(/_(\d{14})-/);
|
|
134
|
+
let ts;
|
|
135
|
+
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)}`; }
|
|
136
|
+
else { try { ts = new Date(statSync(f).mtimeMs).toISOString(); } catch { continue; } }
|
|
137
|
+
activity.push({ tool: 'antigravity', account, kind: 'file', project, timestamp: ts });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const last = activity.reduce((a, e) => (!a || e.timestamp > a ? e.timestamp : a), undefined);
|
|
142
|
+
return { events: [], commits: [], activity, status: { tool: 'antigravity', account, available: activity.length > 0, lastActivity: last, count: activity.length, kind: 'activity' } };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function statusOf(tool, account, events) {
|
|
146
|
+
const last = events.reduce((a, e) => (!a || e.timestamp > a ? e.timestamp : a), undefined);
|
|
147
|
+
return { tool, account, available: events.length > 0, lastActivity: last, count: events.length, kind: 'tokens' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------- source composition ----------
|
|
151
|
+
function defaultSources() {
|
|
152
|
+
return [
|
|
153
|
+
{ tool: 'claude', dir: join(HOME, '.claude', 'projects'), account: claudeAccount(join(HOME, '.claude', 'projects')) },
|
|
154
|
+
{ tool: 'codex', dir: join(HOME, '.codex'), account: codexAccount(join(HOME, '.codex')) },
|
|
155
|
+
{ tool: 'cursor', dir: join(HOME, '.cursor'), account: 'cursor (local)' },
|
|
156
|
+
{ tool: 'antigravity', dir: join(HOME, '.gemini', 'antigravity'), account: antigravityAccount() },
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
|
|
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
|
+
const LOADERS = { claude: loadClaude, codex: loadCodex, cursor: loadCursor, antigravity: loadAntigravity };
|
|
125
171
|
|
|
126
172
|
export function loadAll() {
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
173
|
+
const sources = configuredSources() ?? defaultSources();
|
|
174
|
+
const events = [], commits = [], activity = [], statuses = [];
|
|
175
|
+
const accounts = new Set();
|
|
176
|
+
for (const s of sources) {
|
|
177
|
+
const loader = LOADERS[s.tool];
|
|
178
|
+
if (!loader) { statuses.push({ tool: s.tool, available: false, note: 'unknown tool' }); continue; }
|
|
179
|
+
const account = s.account || fallbackAccount(s.dir);
|
|
180
|
+
try {
|
|
181
|
+
const r = loader(s.dir, account);
|
|
182
|
+
events.push(...r.events); commits.push(...r.commits); activity.push(...(r.activity ?? []));
|
|
183
|
+
statuses.push(r.status);
|
|
184
|
+
if (r.status.available) accounts.add(account);
|
|
185
|
+
} catch (e) { statuses.push({ tool: s.tool, account, available: false, note: e.message }); }
|
|
131
186
|
}
|
|
132
|
-
//
|
|
133
|
-
for (const [tool, rel
|
|
134
|
-
|
|
187
|
+
// tools present on disk but exposing no usable local usage
|
|
188
|
+
for (const [tool, rel, note] of [
|
|
189
|
+
['copilot', '.copilot', 'no local usage (editor/cloud only)'],
|
|
190
|
+
['ollama', '.ollama', 'no logged inference calls (server pings only)'],
|
|
191
|
+
]) {
|
|
192
|
+
if (existsSync(join(HOME, rel))) statuses.push({ tool, available: false, note });
|
|
135
193
|
}
|
|
136
|
-
return { events, commits, statuses };
|
|
194
|
+
return { events, commits, activity, statuses, accounts: [...accounts].sort() };
|
|
137
195
|
}
|
package/src/lib/aggregate.mjs
CHANGED
|
@@ -141,11 +141,30 @@ function authorship(commits) {
|
|
|
141
141
|
};
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
function activitySummary(activity) {
|
|
145
|
+
const byProject = new Map();
|
|
146
|
+
const byDay = new Set();
|
|
147
|
+
let sessions = 0, files = 0;
|
|
148
|
+
for (const a of activity) {
|
|
149
|
+
if (a.kind === 'session') sessions += 1; else files += 1;
|
|
150
|
+
byProject.set(a.project, (byProject.get(a.project) ?? 0) + 1);
|
|
151
|
+
byDay.add(localDayKey(a.timestamp));
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
total: activity.length, sessions, files, activeDays: byDay.size,
|
|
155
|
+
byProject: [...byProject.entries()].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count).slice(0, 8),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function buildPayload(ds, range, now, account) {
|
|
145
160
|
const win = rangeWindow(range, now), prior = priorWindow(range, now);
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
const
|
|
161
|
+
const pick = (arr) => (account && account !== 'all' ? arr.filter((x) => x.account === account) : arr);
|
|
162
|
+
const evAll = pick(ds.events), coAll = pick(ds.commits), acAll = pick(ds.activity ?? []);
|
|
163
|
+
const cur = evAll.filter((e) => inWin(e.timestamp, win));
|
|
164
|
+
const prev = evAll.filter((e) => inWin(e.timestamp, prior));
|
|
165
|
+
const commits = coAll.filter((c) => inWin(c.timestamp, win));
|
|
166
|
+
const activity = acAll.filter((a) => inWin(a.timestamp, win));
|
|
167
|
+
const lastAll = [...cur.map((e) => e.timestamp), ...commits.map((c) => c.timestamp), ...activity.map((a) => a.timestamp)];
|
|
149
168
|
return {
|
|
150
169
|
summary: summarize(cur, prev),
|
|
151
170
|
breakdowns: { byTool: group(cur, (e) => e.tool), byModel: group(cur, (e) => e.model), byProject: group(cur, (e) => e.project, 12) },
|
|
@@ -153,7 +172,9 @@ export function buildPayload(ds, range, now) {
|
|
|
153
172
|
heatmap: heatmap(cur),
|
|
154
173
|
sessions: sessionShape(cur),
|
|
155
174
|
authorship: authorship(commits),
|
|
175
|
+
activity: activitySummary(activity),
|
|
156
176
|
tools: ds.statuses,
|
|
157
|
-
|
|
177
|
+
accounts: ds.accounts ?? [],
|
|
178
|
+
meta: { range, account: account ?? 'all', generatedAt: now.toISOString(), lastActivity: lastAll.reduce((a, t) => (!a || t > a ? t : a), undefined) },
|
|
158
179
|
};
|
|
159
180
|
}
|
package/src/public/app.js
CHANGED
|
@@ -217,9 +217,28 @@ function render(d) {
|
|
|
217
217
|
]);
|
|
218
218
|
app.append(el('div', { class: 'grid' }, [panel('CODE AUTHORSHIP · CURSOR', 'weekly AI vs human lines', authBody)]));
|
|
219
219
|
|
|
220
|
+
// activity (Antigravity / Gemini) — count-only signal, no tokens
|
|
221
|
+
if (d.activity && d.activity.total > 0) {
|
|
222
|
+
const maxA = d.activity.byProject[0]?.count || 1;
|
|
223
|
+
const actBody = el('div', {}, [
|
|
224
|
+
el('div', { class: 'statPair', style: 'margin-bottom:10px' }, [
|
|
225
|
+
el('div', {}, [el('div', { class: 'k' }, 'SESSIONS'), el('div', { class: 'v' }, fmtInt(d.activity.sessions))]),
|
|
226
|
+
el('div', {}, [el('div', { class: 'k' }, 'AI-TOUCHED FILES'), el('div', { class: 'v' }, fmtInt(d.activity.files))]),
|
|
227
|
+
el('div', {}, [el('div', { class: 'k' }, 'ACTIVE DAYS'), el('div', { class: 'v' }, fmtInt(d.activity.activeDays))]),
|
|
228
|
+
]),
|
|
229
|
+
...d.activity.byProject.map((p) => barRow(p.key, `${fmtInt(p.count)}`, (p.count / maxA) * 100, 'var(--c5)')),
|
|
230
|
+
]);
|
|
231
|
+
app.append(el('div', { class: 'grid' }, [panel('ACTIVITY · ANTIGRAVITY (GEMINI)', 'sessions & AI-touched files · no token data', actBody)]));
|
|
232
|
+
}
|
|
233
|
+
|
|
220
234
|
// tool chips
|
|
221
|
-
const
|
|
222
|
-
|
|
235
|
+
const kindLabel = { tokens: 'tokens', authorship: 'code lines', activity: 'activity' };
|
|
236
|
+
const chips = el('div', { class: 'tools' }, d.tools.map((t) => {
|
|
237
|
+
const detail = t.available
|
|
238
|
+
? `${t.count != null ? fmtInt(t.count) + ' ' + (kindLabel[t.kind] || 'records') : 'active'}${t.account ? ' · ' + esc(t.account) : ''}`
|
|
239
|
+
: esc(t.note || 'no data');
|
|
240
|
+
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}` });
|
|
241
|
+
}));
|
|
223
242
|
app.append(el('div', {}, [el('div', { class: 'clabel', style: 'margin-top:8px' }, 'TOOLS DETECTED'), chips]));
|
|
224
243
|
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
244
|
}
|
|
@@ -227,7 +246,7 @@ function render(d) {
|
|
|
227
246
|
let range = '30d';
|
|
228
247
|
async function load() {
|
|
229
248
|
try {
|
|
230
|
-
const r = await fetch(
|
|
249
|
+
const r = await fetch(`/api/dashboard?range=${range}&account=all`);
|
|
231
250
|
render(await r.json());
|
|
232
251
|
} catch (e) { $('#app').innerHTML = '<div class="loading">Failed: ' + e.message + '</div>'; }
|
|
233
252
|
}
|
package/src/public/index.html
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
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
|
+
#account{font-family:var(--mono);font-size:13px;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:7px 12px;cursor:pointer;max-width:280px}
|
|
25
27
|
.toggle{display:inline-flex;gap:2px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:3px}
|
|
26
28
|
.toggle button{border:0;background:transparent;color:var(--sub);font-weight:600;font-size:13px;
|
|
27
29
|
padding:6px 14px;border-radius:7px;cursor:pointer;font-family:var(--mono)}
|
package/src/server.mjs
CHANGED
|
@@ -29,10 +29,11 @@ function parseArgs(argv) {
|
|
|
29
29
|
|
|
30
30
|
const { port, open } = parseArgs(process.argv.slice(2));
|
|
31
31
|
|
|
32
|
-
process.stdout.write('usagelens · scanning
|
|
32
|
+
process.stdout.write('usagelens · scanning Claude, Codex, Cursor, Antigravity …\n');
|
|
33
33
|
const t0 = Date.now();
|
|
34
34
|
const dataset = loadAll();
|
|
35
|
-
process.stdout.write(`usagelens · loaded ${dataset.events.length} usage events + ${dataset.commits.length} commits in ${Date.now() - t0}ms\n`);
|
|
35
|
+
process.stdout.write(`usagelens · loaded ${dataset.events.length} usage events + ${dataset.commits.length} commits + ${dataset.activity.length} activity in ${Date.now() - t0}ms\n`);
|
|
36
|
+
process.stdout.write(`usagelens · accounts: ${dataset.accounts.join(', ') || 'none'}\n`);
|
|
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`);
|
|
37
38
|
|
|
38
39
|
const server = createServer((req, res) => {
|
|
@@ -40,7 +41,8 @@ const server = createServer((req, res) => {
|
|
|
40
41
|
if (url.pathname === '/api/dashboard') {
|
|
41
42
|
const rq = url.searchParams.get('range');
|
|
42
43
|
const range = ['7d', '30d', '90d'].includes(rq) ? rq : '30d';
|
|
43
|
-
const
|
|
44
|
+
const account = url.searchParams.get('account') || 'all';
|
|
45
|
+
const body = JSON.stringify(buildPayload(dataset, range, new Date(), account));
|
|
44
46
|
res.writeHead(200, { 'content-type': 'application/json' }).end(body);
|
|
45
47
|
return;
|
|
46
48
|
}
|