great-cto 3.52.1 → 3.54.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.
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-usage — what Claude Code and Codex actually consumed on this machine,
|
|
3
|
+
* read from their own session logs.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code ~/.claude/projects/<slug>/<session>.jsonl
|
|
6
|
+
* ~/.claude/projects/<slug>/<session>/subagents/agent-*.jsonl
|
|
7
|
+
* Codex ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
|
|
8
|
+
* ~/.codex/archived_sessions/rollout-*.jsonl
|
|
9
|
+
*
|
|
10
|
+
* Both hosts already write per-response token usage, the model, every tool call
|
|
11
|
+
* and — Codex only — the plan's rate-limit window. Nothing on the board read it:
|
|
12
|
+
* the Ledger priced verdicts, which is what great_cto's agents reported, not
|
|
13
|
+
* what the hosts spent. This module is the missing reader.
|
|
14
|
+
*
|
|
15
|
+
* Rules, each from a way this goes wrong:
|
|
16
|
+
*
|
|
17
|
+
* - One assistant response is written as SEVERAL Claude Code lines, one per
|
|
18
|
+
* content block, and every one of them repeats the same `usage`. Summing lines
|
|
19
|
+
* counts a response two to three times (58 lines, 26 responses in the session
|
|
20
|
+
* this was found in). Usage is taken once per `message.id`; tool calls are
|
|
21
|
+
* taken per line, because each line carries a different block.
|
|
22
|
+
* - Codex reports `input_tokens` INCLUDING the cached part. It is split here
|
|
23
|
+
* into fresh input and cache reads so both hosts share one shape.
|
|
24
|
+
* - Dollars are not stored. Tokens are stored per day per model and priced when
|
|
25
|
+
* read, so a price override applies to history, and a model nobody priced is
|
|
26
|
+
* reported as unpriced, never as free.
|
|
27
|
+
* - A 429 is not a plan limit. Claude Code writes "not your usage limit" on a
|
|
28
|
+
* server-side throttle; the two are counted apart.
|
|
29
|
+
* - Read-only on the logs. The index lives in ~/.great_cto. A file that
|
|
30
|
+
* disappears (Claude Code prunes old transcripts) keeps its counted days: the
|
|
31
|
+
* history is ours once read, and losing it would read as a quiet month.
|
|
32
|
+
* - Append-only files are read from the last complete line, so a refresh costs
|
|
33
|
+
* the new bytes, not the gigabytes.
|
|
34
|
+
* - Nothing here leaves the machine. Titles and project names are private; the
|
|
35
|
+
* board serves them on its own host only.
|
|
36
|
+
*/
|
|
37
|
+
import fs from 'node:fs';
|
|
38
|
+
import os from 'node:os';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import { createHash } from 'node:crypto';
|
|
41
|
+
import { priceUsage, effectivePrices } from './cost-meter.mjs';
|
|
42
|
+
|
|
43
|
+
const INDEX_VERSION = 2; // 2: hourly buckets, limit kinds, Codex limit series
|
|
44
|
+
const RING = 256; // recent response ids remembered across reads
|
|
45
|
+
const KEEP_GONE_DAYS = 400; // how long a vanished file's days are kept
|
|
46
|
+
const SERIES_MAX = 4000; // Codex limit readings kept per file
|
|
47
|
+
const CHUNK = 1 << 20;
|
|
48
|
+
|
|
49
|
+
const home = () => os.homedir();
|
|
50
|
+
export const DEFAULT_CLAUDE_DIR = () => path.join(home(), '.claude', 'projects');
|
|
51
|
+
export const DEFAULT_CODEX_DIRS = () => [path.join(home(), '.codex', 'sessions'), path.join(home(), '.codex', 'archived_sessions')];
|
|
52
|
+
export const DEFAULT_CODEX_TITLES = () => path.join(home(), '.codex', 'session_index.jsonl');
|
|
53
|
+
// The version is in the name: a board started on an older release keeps running
|
|
54
|
+
// until restarted, and two versions sharing one file would rebuild it from
|
|
55
|
+
// scratch on every pass, each throwing the other's away.
|
|
56
|
+
export const DEFAULT_CACHE_FILE = () => path.join(home(), '.great_cto', `session-usage-index.v${INDEX_VERSION}.json`);
|
|
57
|
+
|
|
58
|
+
const short = (s) => createHash('sha1').update(String(s)).digest('hex').slice(0, 16);
|
|
59
|
+
|
|
60
|
+
/** Local hour (00–23) of an ISO timestamp. */
|
|
61
|
+
export function hourOf(ts) {
|
|
62
|
+
const d = new Date(ts);
|
|
63
|
+
return Number.isNaN(d.getTime()) ? null : String(d.getHours()).padStart(2, '0');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Local calendar day of an ISO timestamp — the operator's day, not UTC's. */
|
|
67
|
+
export function dayOf(ts) {
|
|
68
|
+
const d = new Date(ts);
|
|
69
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
70
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
71
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A working directory → the project a person would name.
|
|
76
|
+
* Agent worktrees are folded into the repository they belong to: forty
|
|
77
|
+
* `wt-1788…` rows would hide which project the work was for.
|
|
78
|
+
*/
|
|
79
|
+
export function projectName(cwd, homeDir = home()) {
|
|
80
|
+
if (!cwd || typeof cwd !== 'string') return null;
|
|
81
|
+
const cw = cwd.match(/^(.*)\/\.claude\/worktrees\/[^/]+/);
|
|
82
|
+
if (cw) return path.basename(cw[1]);
|
|
83
|
+
const cx = cwd.match(/\/\.codex\/worktrees\/[^/]+\/([^/]+)/);
|
|
84
|
+
if (cx) return cx[1];
|
|
85
|
+
if (path.resolve(cwd) === path.resolve(homeDir)) return '~';
|
|
86
|
+
return path.basename(cwd) || null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── file discovery ────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
function walk(dir, maxDepth, out) {
|
|
92
|
+
const go = (d, depth) => {
|
|
93
|
+
let entries;
|
|
94
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
95
|
+
for (const e of entries) {
|
|
96
|
+
const p = path.join(d, e.name);
|
|
97
|
+
if (e.isDirectory()) { if (depth < maxDepth) go(p, depth + 1); }
|
|
98
|
+
else if (e.isFile() && e.name.endsWith('.jsonl')) out.push(p);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
go(dir, 0);
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function listLogFiles({ claudeDir = DEFAULT_CLAUDE_DIR(), codexDirs = DEFAULT_CODEX_DIRS() } = {}) {
|
|
106
|
+
const files = [];
|
|
107
|
+
const seen = { claude: false, codex: false };
|
|
108
|
+
if (isDir(claudeDir)) { seen.claude = true; for (const f of walk(claudeDir, 4, [])) files.push({ host: 'claude', file: f }); }
|
|
109
|
+
for (const d of codexDirs) {
|
|
110
|
+
if (isDir(d)) { seen.codex = true; for (const f of walk(d, 4, [])) files.push({ host: 'codex', file: f }); }
|
|
111
|
+
}
|
|
112
|
+
return { files, seen };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } }
|
|
116
|
+
|
|
117
|
+
// ── shared accumulation ───────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
function emptyDay() {
|
|
120
|
+
return { in: 0, out: 0, cr: 0, cw: 0, cwh: 0, rs: 0, msgs: 0, models: {}, tools: {}, skills: {}, agents: {}, mcp: {}, lim: {}, h: {} };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The hour inside a day: tokens per model as [in, out, cacheRead, cacheWrite5m, cacheWrite1h], and limit refusals. */
|
|
124
|
+
function hourBucket(b, ts) {
|
|
125
|
+
const hh = hourOf(ts);
|
|
126
|
+
if (!hh) return null;
|
|
127
|
+
return b.h[hh] || (b.h[hh] = { m: {}, lim: {} });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function addUsage(entry, ts, model, t) {
|
|
131
|
+
const b = dayBucket(entry, ts);
|
|
132
|
+
if (!b) return null;
|
|
133
|
+
addTokens(b, model, t);
|
|
134
|
+
const hb = hourBucket(b, ts);
|
|
135
|
+
if (hb) {
|
|
136
|
+
const v = hb.m[model] || (hb.m[model] = [0, 0, 0, 0, 0]);
|
|
137
|
+
v[0] += t.in; v[1] += t.out; v[2] += t.cr; v[3] += t.cw; v[4] += t.cwh;
|
|
138
|
+
}
|
|
139
|
+
return b;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function dayBucket(entry, ts) {
|
|
143
|
+
const day = dayOf(ts);
|
|
144
|
+
if (!day) return null;
|
|
145
|
+
return entry.days[day] || (entry.days[day] = emptyDay());
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const bump = (map, key, n = 1) => { if (key) map[key] = (map[key] || 0) + n; };
|
|
149
|
+
|
|
150
|
+
function addTokens(b, model, t) {
|
|
151
|
+
b.in += t.in; b.out += t.out; b.cr += t.cr; b.cw += t.cw; b.cwh += t.cwh; b.rs += t.rs; b.msgs += 1;
|
|
152
|
+
const m = b.models[model] || (b.models[model] = { in: 0, out: 0, cr: 0, cw: 0, cwh: 0, msgs: 0 });
|
|
153
|
+
m.in += t.in; m.out += t.out; m.cr += t.cr; m.cw += t.cw; m.cwh += t.cwh; m.msgs += 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function countTool(b, name) {
|
|
157
|
+
if (!name) return;
|
|
158
|
+
if (name.startsWith('mcp__')) {
|
|
159
|
+
const server = name.split('__')[1] || name;
|
|
160
|
+
bump(b.mcp, server);
|
|
161
|
+
}
|
|
162
|
+
bump(b.tools, name);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function remember(ring, id) {
|
|
166
|
+
ring.push(id);
|
|
167
|
+
if (ring.length > RING) ring.splice(0, ring.length - RING);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function touchSpan(meta, ts) {
|
|
171
|
+
if (!ts) return;
|
|
172
|
+
if (!meta.first || ts < meta.first) meta.first = ts;
|
|
173
|
+
if (!meta.last || ts > meta.last) meta.last = ts;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Claude Code ───────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
/** Session id, parent and function of a Claude Code transcript, from its path. */
|
|
179
|
+
function claudeFileMeta(file) {
|
|
180
|
+
const base = path.basename(file, '.jsonl');
|
|
181
|
+
const dir = path.dirname(file);
|
|
182
|
+
if (path.basename(dir) === 'subagents') {
|
|
183
|
+
const parent = path.basename(path.dirname(dir));
|
|
184
|
+
let agentType = null;
|
|
185
|
+
try { agentType = JSON.parse(fs.readFileSync(path.join(dir, `${base}.meta.json`), 'utf8')).agentType || null; } catch { /* older layout: no meta */ }
|
|
186
|
+
return { session: base, parent, fn: 'subagent', agentType };
|
|
187
|
+
}
|
|
188
|
+
return { session: base, parent: null, fn: null, agentType: null };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Which limit refused a request, from the words Claude Code shows. Seen on this
|
|
193
|
+
* machine: "session limit" (the 5-hour window), "weekly limit", "monthly spend
|
|
194
|
+
* limit", "reached your <model> limit", "out of usage credits", and the server's
|
|
195
|
+
* own throttle, which says outright that it is "not your usage limit".
|
|
196
|
+
*/
|
|
197
|
+
export function limitKind(text) {
|
|
198
|
+
const t = String(text || '');
|
|
199
|
+
if (/not your usage limit/i.test(t)) return 'server';
|
|
200
|
+
if (/monthly spend limit/i.test(t)) return 'spend';
|
|
201
|
+
if (/out of usage credits/i.test(t)) return 'credits';
|
|
202
|
+
if (/session limit/i.test(t)) return 'session';
|
|
203
|
+
if (/weekly limit/i.test(t)) return 'weekly';
|
|
204
|
+
if (/reached your .{1,40} limit/i.test(t)) return 'model';
|
|
205
|
+
return 'other';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** `great-cto:senior-dev` and `senior-dev` are one agent; `feature-dev:code-reviewer` is not ours. */
|
|
209
|
+
export function agentName(raw) {
|
|
210
|
+
if (typeof raw !== 'string' || !raw) return 'general-purpose';
|
|
211
|
+
return raw.replace(/^great[-_]cto:/, '');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function claudeLine(entry, line) {
|
|
215
|
+
// Most bytes in a transcript are tool RESULTS in user lines. They carry no
|
|
216
|
+
// usage, so they are not parsed — except to name a session that has no title.
|
|
217
|
+
const isAssistant = line.includes('"type":"assistant"');
|
|
218
|
+
const isTitle = line.includes('"custom-title"');
|
|
219
|
+
const wantsPrompt = !entry.meta.prompt && !entry.meta.title && line.includes('"type":"user"');
|
|
220
|
+
if (!isAssistant && !isTitle && !wantsPrompt) return;
|
|
221
|
+
let r;
|
|
222
|
+
try { r = JSON.parse(line); } catch { return; }
|
|
223
|
+
const meta = entry.meta;
|
|
224
|
+
|
|
225
|
+
if (r.type === 'custom-title' && r.customTitle) { meta.title = String(r.customTitle).slice(0, 160); return; }
|
|
226
|
+
if (r.type === 'user') {
|
|
227
|
+
if (r.isCompactSummary || r.isMeta) return;
|
|
228
|
+
const c = r.message?.content;
|
|
229
|
+
const text = typeof c === 'string' ? c : Array.isArray(c) ? c.filter((b) => b?.type === 'text').map((b) => b.text || '').join(' ') : '';
|
|
230
|
+
const clean = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
231
|
+
if (clean && !clean.startsWith('Caveat:')) meta.prompt = clean.slice(0, 120);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (r.type !== 'assistant') return;
|
|
235
|
+
|
|
236
|
+
const ts = typeof r.timestamp === 'string' ? r.timestamp : null;
|
|
237
|
+
if (!ts) return;
|
|
238
|
+
touchSpan(meta, ts);
|
|
239
|
+
if (!meta.cwd && r.cwd) meta.cwd = r.cwd;
|
|
240
|
+
if (!meta.entry && r.entrypoint) meta.entry = r.entrypoint;
|
|
241
|
+
if (!meta.fn && !r.isSidechain) meta.fn = /^sdk/.test(r.entrypoint || '') ? 'headless' : 'chat';
|
|
242
|
+
const b = dayBucket(entry, ts);
|
|
243
|
+
if (!b) return;
|
|
244
|
+
|
|
245
|
+
if (r.isApiErrorMessage && r.error === 'rate_limit') {
|
|
246
|
+
const c = r.message?.content;
|
|
247
|
+
const text = Array.isArray(c) ? c.map((x) => x?.text || '').join(' ') : String(c || '');
|
|
248
|
+
const kind = limitKind(text);
|
|
249
|
+
bump(b.lim, kind);
|
|
250
|
+
const hb = hourBucket(b, ts);
|
|
251
|
+
if (hb) bump(hb.lim, kind);
|
|
252
|
+
// The words carry the reset time ("resets Sep 12 at 3pm"); the latest is kept to show.
|
|
253
|
+
if (!meta.lastHit || ts > meta.lastHit.at) meta.lastHit = { at: ts, kind, text: text.replace(/\s+/g, ' ').trim().slice(0, 200) };
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const msg = r.message || {};
|
|
258
|
+
for (const block of Array.isArray(msg.content) ? msg.content : []) {
|
|
259
|
+
if (block?.type !== 'tool_use') continue;
|
|
260
|
+
countTool(b, block.name);
|
|
261
|
+
if (block.name === 'Skill') bump(b.skills, typeof block.input?.skill === 'string' ? block.input.skill : null);
|
|
262
|
+
if (block.name === 'Agent' || block.name === 'Task') bump(b.agents, agentName(block.input?.subagent_type));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const u = msg.usage;
|
|
266
|
+
const model = msg.model;
|
|
267
|
+
if (!u || !model || model === '<synthetic>') return;
|
|
268
|
+
const id = msg.id || r.requestId || r.uuid;
|
|
269
|
+
if (id && entry.state.ids.includes(id)) return;
|
|
270
|
+
if (id) remember(entry.state.ids, id);
|
|
271
|
+
const cwh = u.cache_creation?.ephemeral_1h_input_tokens || 0;
|
|
272
|
+
const t = {
|
|
273
|
+
in: u.input_tokens || 0,
|
|
274
|
+
out: u.output_tokens || 0,
|
|
275
|
+
cr: u.cache_read_input_tokens || 0,
|
|
276
|
+
cw: Math.max(0, (u.cache_creation_input_tokens || 0) - cwh),
|
|
277
|
+
cwh,
|
|
278
|
+
rs: u.output_tokens_details?.thinking_tokens || 0,
|
|
279
|
+
};
|
|
280
|
+
addUsage(entry, ts, u.speed === 'fast' ? `${model}@fast` : model, t);
|
|
281
|
+
// Older Claude Code wrote subagent turns into the parent's own file.
|
|
282
|
+
if (r.isSidechain && meta.fn !== 'subagent') b.side = (b.side || 0) + t.in + t.out + t.cr + t.cw + t.cwh;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── Codex ─────────────────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
const SKILL_PATH = /skills\/(?:\.system\/)?([A-Za-z0-9_.-]+)\/SKILL\.md/g;
|
|
288
|
+
const TOOL_CALL = /\btools\.([A-Za-z_][A-Za-z0-9_]*)\s*\(/g;
|
|
289
|
+
|
|
290
|
+
function codexUsage(u) {
|
|
291
|
+
const input = u.input_tokens || 0;
|
|
292
|
+
const cached = Math.min(u.cached_input_tokens || 0, input);
|
|
293
|
+
return { in: input - cached, out: u.output_tokens || 0, cr: cached, cw: u.cache_write_input_tokens || 0, cwh: 0, rs: u.reasoning_output_tokens || 0 };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function codexLine(entry, line) {
|
|
297
|
+
const hit = line.includes('"token_usage_record"') || line.includes('"token_count"')
|
|
298
|
+
|| line.includes('"turn_context"') || line.includes('"session_meta"')
|
|
299
|
+
|| line.includes('"custom_tool_call"') || line.includes('"function_call"');
|
|
300
|
+
if (!hit) return;
|
|
301
|
+
let r;
|
|
302
|
+
try { r = JSON.parse(line); } catch { return; }
|
|
303
|
+
const p = r.payload || {};
|
|
304
|
+
const ts = typeof r.timestamp === 'string' ? r.timestamp : null;
|
|
305
|
+
const meta = entry.meta;
|
|
306
|
+
const st = entry.state;
|
|
307
|
+
|
|
308
|
+
if (r.type === 'session_meta') {
|
|
309
|
+
meta.session = p.id || p.session_id || meta.session;
|
|
310
|
+
if (p.cwd) meta.cwd = p.cwd;
|
|
311
|
+
meta.entry = p.originator || null;
|
|
312
|
+
const sub = p.source && typeof p.source === 'object' ? p.source.subagent?.thread_spawn : null;
|
|
313
|
+
if (sub) {
|
|
314
|
+
meta.fn = 'subagent';
|
|
315
|
+
meta.parent = sub.parent_thread_id || null;
|
|
316
|
+
meta.agentType = sub.agent_role || p.agent_role || null;
|
|
317
|
+
const b = ts && dayBucket(entry, ts);
|
|
318
|
+
if (b) bump(b.agents, meta.agentType || 'subagent');
|
|
319
|
+
} else if (p.thread_source === 'automation') meta.fn = 'automation';
|
|
320
|
+
else if (p.originator === 'codex_exec' || p.source === 'exec') meta.fn = 'headless';
|
|
321
|
+
else meta.fn = 'chat';
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (r.type === 'turn_context') { if (p.model) st.model = p.model; return; }
|
|
325
|
+
if (!ts) return;
|
|
326
|
+
|
|
327
|
+
if (r.type === 'response_item' && (p.type === 'custom_tool_call' || p.type === 'function_call')) {
|
|
328
|
+
const b = dayBucket(entry, ts);
|
|
329
|
+
if (!b) return;
|
|
330
|
+
touchSpan(meta, ts);
|
|
331
|
+
const input = String(p.input ?? p.arguments ?? '');
|
|
332
|
+
// `exec` is Codex's code-mode wrapper: the tools are called inside it.
|
|
333
|
+
const inner = p.name === 'exec' ? [...input.matchAll(TOOL_CALL)].map((m) => m[1]) : [];
|
|
334
|
+
if (inner.length) for (const n of inner) countTool(b, n); else countTool(b, p.name);
|
|
335
|
+
for (const m of input.matchAll(SKILL_PATH)) bump(b.skills, m[1]);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (r.type === 'token_usage_record' && p.usage) {
|
|
340
|
+
const id = p.response_id;
|
|
341
|
+
if (id && st.ids.includes(id)) return;
|
|
342
|
+
if (id) remember(st.ids, id);
|
|
343
|
+
st.records = true;
|
|
344
|
+
if (!addUsage(entry, ts, st.model || 'unknown', codexUsage(p.usage))) return;
|
|
345
|
+
touchSpan(meta, ts);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (r.type === 'event_msg' && p.type === 'token_count') {
|
|
350
|
+
const rl = p.rate_limits;
|
|
351
|
+
if (rl && (rl.primary || rl.secondary)) {
|
|
352
|
+
meta.limits = {
|
|
353
|
+
at: ts, plan: rl.plan_type || meta.limits?.plan || null, reached: rl.rate_limit_reached_type || null,
|
|
354
|
+
primary: rl.primary ? { used: rl.primary.used_percent, minutes: rl.primary.window_minutes, resets: rl.primary.resets_at } : null,
|
|
355
|
+
secondary: rl.secondary ? { used: rl.secondary.used_percent, minutes: rl.secondary.window_minutes, resets: rl.secondary.resets_at } : null,
|
|
356
|
+
credits: rl.credits ? { has: !!rl.credits.has_credits, unlimited: !!rl.credits.unlimited, balance: rl.credits.balance ?? null } : null,
|
|
357
|
+
};
|
|
358
|
+
// The window's history: one point per change, not per event — Codex
|
|
359
|
+
// repeats the same reading after every response.
|
|
360
|
+
const L = meta.limits;
|
|
361
|
+
const pt = [Date.parse(ts), L.primary?.used ?? null, L.primary?.minutes ?? null, L.primary?.resets ?? null,
|
|
362
|
+
L.secondary?.used ?? null, L.secondary?.minutes ?? null, L.secondary?.resets ?? null, L.reached ? 1 : 0];
|
|
363
|
+
const series = meta.series || (meta.series = []);
|
|
364
|
+
const last = series[series.length - 1];
|
|
365
|
+
if (Number.isFinite(pt[0]) && (!last || [1, 3, 4, 6, 7].some((i) => last[i] !== pt[i]))) {
|
|
366
|
+
series.push(pt);
|
|
367
|
+
if (series.length > SERIES_MAX) series.splice(0, series.length - SERIES_MAX);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Older sessions have no token_usage_record. Their usage is the growth of
|
|
371
|
+
// the cumulative total — token_count is emitted more than once per response,
|
|
372
|
+
// so `last_token_usage` would count a response twice.
|
|
373
|
+
const tot = p.info?.total_token_usage;
|
|
374
|
+
if (!st.records && tot && typeof tot.total_tokens === 'number') {
|
|
375
|
+
const prev = st.prev;
|
|
376
|
+
st.prev = tot;
|
|
377
|
+
if (prev && tot.total_tokens <= prev.total_tokens) return;
|
|
378
|
+
const delta = {};
|
|
379
|
+
for (const k of ['input_tokens', 'cached_input_tokens', 'cache_write_input_tokens', 'output_tokens', 'reasoning_output_tokens']) {
|
|
380
|
+
delta[k] = Math.max(0, (tot[k] || 0) - ((prev && prev[k]) || 0));
|
|
381
|
+
}
|
|
382
|
+
// A thread forked from another opens with a total and no breakdown — the
|
|
383
|
+
// context it inherited, not a response. It is the base, never usage.
|
|
384
|
+
if (!delta.input_tokens && !delta.output_tokens && !delta.cached_input_tokens) return;
|
|
385
|
+
if (!addUsage(entry, ts, st.model || 'unknown', codexUsage(delta))) return;
|
|
386
|
+
touchSpan(meta, ts);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ── incremental reading ───────────────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
/** Feed every COMPLETE line in [start, size) to onLine; return where the next read starts. */
|
|
394
|
+
async function readFrom(file, start, onLine) {
|
|
395
|
+
const fh = await fs.promises.open(file, 'r');
|
|
396
|
+
try {
|
|
397
|
+
const buf = Buffer.allocUnsafe(CHUNK);
|
|
398
|
+
let pos = start;
|
|
399
|
+
let carry = null;
|
|
400
|
+
let committed = start;
|
|
401
|
+
for (;;) {
|
|
402
|
+
const { bytesRead } = await fh.read(buf, 0, CHUNK, pos);
|
|
403
|
+
if (bytesRead === 0) break;
|
|
404
|
+
pos += bytesRead;
|
|
405
|
+
let data = buf.subarray(0, bytesRead);
|
|
406
|
+
if (carry) { data = Buffer.concat([carry, data]); carry = null; }
|
|
407
|
+
let from = 0;
|
|
408
|
+
for (;;) {
|
|
409
|
+
const nl = data.indexOf(10, from);
|
|
410
|
+
if (nl === -1) break;
|
|
411
|
+
if (nl > from) onLine(data.toString('utf8', from, nl));
|
|
412
|
+
committed += nl + 1 - from;
|
|
413
|
+
from = nl + 1;
|
|
414
|
+
}
|
|
415
|
+
// A line still being written stays unread until it has its newline.
|
|
416
|
+
if (from < data.length) carry = Buffer.from(data.subarray(from));
|
|
417
|
+
}
|
|
418
|
+
return committed;
|
|
419
|
+
} finally {
|
|
420
|
+
await fh.close();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function freshEntry(host, file) {
|
|
425
|
+
const meta = host === 'claude' ? claudeFileMeta(file) : { session: null, parent: null, fn: null, agentType: null };
|
|
426
|
+
return { host, size: 0, mtimeMs: 0, offset: 0, meta: { ...meta, title: null, prompt: null, cwd: null, entry: null, first: null, last: null }, state: { ids: [], model: null, prev: null, records: false }, days: {} };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function loadIndex(cacheFile) {
|
|
430
|
+
try {
|
|
431
|
+
const x = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
|
432
|
+
if (x && x.version === INDEX_VERSION && x.files && typeof x.files === 'object') return x;
|
|
433
|
+
} catch { /* missing or corrupt: rebuilt, never trusted */ }
|
|
434
|
+
return { version: INDEX_VERSION, files: {} };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function saveIndex(cacheFile, index) {
|
|
438
|
+
try {
|
|
439
|
+
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
|
|
440
|
+
const tmp = `${cacheFile}.${process.pid}.tmp`;
|
|
441
|
+
fs.writeFileSync(tmp, JSON.stringify(index));
|
|
442
|
+
fs.renameSync(tmp, cacheFile);
|
|
443
|
+
} catch { /* unsaved: the next pass re-reads, nothing else is lost */ }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Bring the index up to date with the logs on disk.
|
|
448
|
+
* @returns {Promise<{index:object, seen:{claude:boolean,codex:boolean}, read:number, files:number}>}
|
|
449
|
+
*/
|
|
450
|
+
export async function scanUsage({ claudeDir = DEFAULT_CLAUDE_DIR(), codexDirs = DEFAULT_CODEX_DIRS(), cacheFile = DEFAULT_CACHE_FILE(), now = Date.now() } = {}) {
|
|
451
|
+
const index = loadIndex(cacheFile);
|
|
452
|
+
const { files, seen } = listLogFiles({ claudeDir, codexDirs });
|
|
453
|
+
const live = new Set();
|
|
454
|
+
let read = 0;
|
|
455
|
+
for (const { host, file } of files) {
|
|
456
|
+
const key = short(file);
|
|
457
|
+
live.add(key);
|
|
458
|
+
let st;
|
|
459
|
+
try { st = fs.statSync(file); } catch { continue; }
|
|
460
|
+
let e = index.files[key];
|
|
461
|
+
if (e && e.size === st.size && e.mtimeMs === st.mtimeMs) continue;
|
|
462
|
+
// Smaller than what was read means rewritten, not appended: start over.
|
|
463
|
+
if (!e || e.host !== host || st.size < e.offset) e = freshEntry(host, file);
|
|
464
|
+
try {
|
|
465
|
+
const onLine = host === 'claude' ? (l) => claudeLine(e, l) : (l) => codexLine(e, l);
|
|
466
|
+
e.offset = await readFrom(file, e.offset, onLine);
|
|
467
|
+
e.size = st.size;
|
|
468
|
+
e.mtimeMs = st.mtimeMs;
|
|
469
|
+
index.files[key] = e;
|
|
470
|
+
read++;
|
|
471
|
+
} catch { /* unreadable now: tried again next pass, never counted as empty */ }
|
|
472
|
+
}
|
|
473
|
+
// A file that is gone keeps its days until they are too old to matter.
|
|
474
|
+
const cutoff = dayOf(now - KEEP_GONE_DAYS * 86400000);
|
|
475
|
+
for (const [key, e] of Object.entries(index.files)) {
|
|
476
|
+
if (live.has(key)) continue;
|
|
477
|
+
e.gone = true;
|
|
478
|
+
if (!e.meta?.last || dayOf(e.meta.last) < cutoff) delete index.files[key];
|
|
479
|
+
}
|
|
480
|
+
saveIndex(cacheFile, index);
|
|
481
|
+
return { index, seen, read, files: files.length };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ── the summary the board draws ───────────────────────────────────────────────
|
|
485
|
+
|
|
486
|
+
/** Codex thread names, last write wins. */
|
|
487
|
+
export function readCodexTitles(file = DEFAULT_CODEX_TITLES()) {
|
|
488
|
+
const out = {};
|
|
489
|
+
let text = '';
|
|
490
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return out; }
|
|
491
|
+
for (const line of text.split('\n')) {
|
|
492
|
+
if (!line.trim()) continue;
|
|
493
|
+
try { const r = JSON.parse(line); if (r.id && r.thread_name) out[r.id] = String(r.thread_name).slice(0, 160); } catch { /* partial */ }
|
|
494
|
+
}
|
|
495
|
+
return out;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const FAST = '@fast';
|
|
499
|
+
|
|
500
|
+
/** Dollars for one model bucket, or null when nobody priced the model. */
|
|
501
|
+
function priceBucket(model, m, prices) {
|
|
502
|
+
const fast = model.endsWith(FAST);
|
|
503
|
+
const base = fast ? model.slice(0, -FAST.length) : model;
|
|
504
|
+
const p = priceUsage({
|
|
505
|
+
model: base,
|
|
506
|
+
prices,
|
|
507
|
+
usage: {
|
|
508
|
+
input_tokens: m.in, output_tokens: m.out, cache_read_input_tokens: m.cr,
|
|
509
|
+
cache_creation_input_tokens: m.cw + m.cwh,
|
|
510
|
+
cache_creation: { ephemeral_1h_input_tokens: m.cwh, ephemeral_5m_input_tokens: m.cw },
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
if (!p.priced) return { usd: null, assumed: false };
|
|
514
|
+
// Fast mode bills at twice the standard rate (Opus 5: $10/$50 against $5/$25).
|
|
515
|
+
return { usd: fast ? p.usd * 2 : p.usd, assumed: p.assumed };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const tokensOf = (t) => t.in + t.out + t.cr + t.cw + t.cwh;
|
|
519
|
+
|
|
520
|
+
function rank(map, limit, label = (n) => n) {
|
|
521
|
+
return Object.entries(map).sort((a, b) => b[1] - a[1]).slice(0, limit).map(([name, n]) => ({ name: label(name), n }));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
525
|
+
/** claude.ai connectors reach the transcript as a bare UUID; say what it is rather than print it. */
|
|
526
|
+
export function mcpLabel(server) {
|
|
527
|
+
return UUID.test(server) ? `claude.ai connector ${server.slice(0, 8)}` : server;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const HOUR = 3600000;
|
|
531
|
+
|
|
532
|
+
/** Start of the local hour for a local day + hour key. */
|
|
533
|
+
function hourStart(day, hh) {
|
|
534
|
+
const [y, m, d] = day.split('-').map(Number);
|
|
535
|
+
return new Date(y, m - 1, d, Number(hh)).getTime();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function median(xs) {
|
|
539
|
+
if (!xs.length) return null;
|
|
540
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
541
|
+
const mid = Math.floor(s.length / 2);
|
|
542
|
+
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Claude Code's limits as far as its logs can say. The logs carry no plan
|
|
547
|
+
* percentage — only what was spent and when a request was refused, and by which
|
|
548
|
+
* limit. So this reports spend in the last 5 hours and 7 days, and, for every
|
|
549
|
+
* refusal, what had been spent in that window when it came: the ceiling as
|
|
550
|
+
* observed, an estimate with its count, never a stated limit.
|
|
551
|
+
*/
|
|
552
|
+
function claudeLimits(hourly, { fromMs, now, lastHit }) {
|
|
553
|
+
const nowH = Math.floor(now / HOUR) * HOUR;
|
|
554
|
+
const startH = Math.floor(fromMs / HOUR) * HOUR;
|
|
555
|
+
const at = (t) => hourly.get(t) || { usd: 0, tokens: 0, lim: {} };
|
|
556
|
+
const roll = (t, n) => {
|
|
557
|
+
let usd = 0; let tokens = 0;
|
|
558
|
+
for (let i = 0; i < n; i++) { const x = at(t - i * HOUR); usd += x.usd; tokens += x.tokens; }
|
|
559
|
+
return { usd, tokens };
|
|
560
|
+
};
|
|
561
|
+
const series = [];
|
|
562
|
+
const hits = [];
|
|
563
|
+
const ceilings = { session: [], weekly: [] };
|
|
564
|
+
for (let t = startH; t <= nowH; t += HOUR) {
|
|
565
|
+
series.push(Number(roll(t, 5).usd.toFixed(4)));
|
|
566
|
+
const lim = at(t).lim;
|
|
567
|
+
for (const [kind, n] of Object.entries(lim)) {
|
|
568
|
+
hits.push({ t, kind, n });
|
|
569
|
+
// One reading per refused hour: ten retries against the same wall are one wall.
|
|
570
|
+
if (kind === 'session') ceilings.session.push(roll(t, 5).usd);
|
|
571
|
+
if (kind === 'weekly') ceilings.weekly.push(roll(t, 168).usd);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const est = (xs) => (xs.length ? { median: median(xs), min: Math.min(...xs), max: Math.max(...xs), n: xs.length } : null);
|
|
575
|
+
return {
|
|
576
|
+
fiveHour: roll(nowH, 5),
|
|
577
|
+
sevenDay: roll(nowH, 168),
|
|
578
|
+
series: { from: startH, stepMs: HOUR, usd5h: series },
|
|
579
|
+
hits,
|
|
580
|
+
ceilings: { session: est(ceilings.session), weekly: est(ceilings.weekly) },
|
|
581
|
+
lastHit: lastHit || null,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** A Codex window by its length — the field that carries it moved from `secondary` to `primary` between plans. */
|
|
586
|
+
export function laneName(minutes) {
|
|
587
|
+
if (minutes === 10080) return 'weekly';
|
|
588
|
+
if (minutes === 300) return 'fiveHour';
|
|
589
|
+
return `${minutes}m`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Codex's plan windows from its own readings: every window the period touched
|
|
594
|
+
* with its peak and when it filled, the readings to draw, and — for the window
|
|
595
|
+
* still open — where the current pace ends up by the reset.
|
|
596
|
+
*
|
|
597
|
+
* Windows are keyed by their length, not by `primary`/`secondary`: on one plan
|
|
598
|
+
* the 5-hour window was primary and the week secondary, on the next the week was
|
|
599
|
+
* primary. And `resets_at` drifts by seconds between readings of one window, so
|
|
600
|
+
* it is grouped to ten minutes — unrounded, one week read as forty windows.
|
|
601
|
+
*/
|
|
602
|
+
function codexLimitDetail(points, { fromMs, now }) {
|
|
603
|
+
if (!points.length) return null;
|
|
604
|
+
const pts = [...points].sort((a, b) => a[0] - b[0]);
|
|
605
|
+
const lanes = {};
|
|
606
|
+
for (const p of pts) {
|
|
607
|
+
for (const [iu, im, ir] of [[1, 2, 3], [4, 5, 6]]) {
|
|
608
|
+
const used = p[iu]; const minutes = p[im]; const resets = p[ir];
|
|
609
|
+
if (used == null || !minutes || !resets) continue;
|
|
610
|
+
const resetMs = resets * 1000;
|
|
611
|
+
if (resetMs < fromMs) continue;
|
|
612
|
+
const lane = lanes[laneName(minutes)] || (lanes[laneName(minutes)] = { minutes, windows: new Map(), series: [] });
|
|
613
|
+
if (p[0] >= fromMs) {
|
|
614
|
+
const prev = lane.series[lane.series.length - 1];
|
|
615
|
+
if (!prev || prev[1] !== used) lane.series.push([p[0], used]);
|
|
616
|
+
}
|
|
617
|
+
const key = Math.round(resets / 600);
|
|
618
|
+
const w = lane.windows.get(key) || { start: resetMs - minutes * 60000, resets: resetMs, peak: 0, fullAt: null, last: null };
|
|
619
|
+
w.resets = Math.max(w.resets, resetMs);
|
|
620
|
+
w.peak = Math.max(w.peak, used);
|
|
621
|
+
if (used >= 100 && !w.fullAt) w.fullAt = p[0];
|
|
622
|
+
if (!w.last || p[0] >= w.last[0]) w.last = [p[0], used];
|
|
623
|
+
lane.windows.set(key, w);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
const out = {};
|
|
627
|
+
for (const [name, lane] of Object.entries(lanes)) {
|
|
628
|
+
const list = [...lane.windows.values()].sort((a, b) => a.start - b.start);
|
|
629
|
+
// The open window is the one the LATEST reading belongs to. Codex has reset a
|
|
630
|
+
// week early more than once, so an older window can still look "open" on paper.
|
|
631
|
+
const latest = list.reduce((a, w) => (!a || (w.last && w.last[0] > a.last[0]) ? w : a), null);
|
|
632
|
+
const cur = latest && latest.resets > now ? latest : null;
|
|
633
|
+
let projection = null;
|
|
634
|
+
if (cur && cur.last) {
|
|
635
|
+
const [t, used] = cur.last;
|
|
636
|
+
if (used >= 100) projection = { state: 'full', resets: cur.resets, readAt: t };
|
|
637
|
+
else if (used > 0 && t > cur.start) {
|
|
638
|
+
const fullBy = cur.start + ((t - cur.start) * 100) / used;
|
|
639
|
+
projection = fullBy < cur.resets
|
|
640
|
+
? { state: 'will-fill', fullBy, resets: cur.resets, readAt: t, used }
|
|
641
|
+
: { state: 'fits', atReset: (used * (cur.resets - cur.start)) / (t - cur.start), resets: cur.resets, readAt: t, used };
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
out[name] = {
|
|
645
|
+
minutes: lane.minutes,
|
|
646
|
+
windows: list.map((w) => ({ start: w.start, resets: w.resets, peak: w.peak, fullAt: w.fullAt })),
|
|
647
|
+
filled: list.filter((w) => w.fullAt).length,
|
|
648
|
+
series: lane.series,
|
|
649
|
+
projection,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
return Object.keys(out).length ? out : null;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* The window the board shows.
|
|
657
|
+
* @param {object} index from scanUsage
|
|
658
|
+
* @param {{days?:number, now?:number, codexTitles?:Record<string,string>, prices?:object, top?:number}} opts
|
|
659
|
+
*/
|
|
660
|
+
export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles = {}, prices = effectivePrices(), top = 10 } = {}) {
|
|
661
|
+
const span = Math.max(1, Math.min(365, Math.floor(days) || 30));
|
|
662
|
+
const dates = [];
|
|
663
|
+
for (let i = span - 1; i >= 0; i--) dates.push(dayOf(now - i * 86400000));
|
|
664
|
+
const inWindow = new Set(dates);
|
|
665
|
+
const from = dates[0];
|
|
666
|
+
|
|
667
|
+
const blank = () => ({ in: 0, out: 0, cr: 0, cw: 0, cwh: 0, rs: 0, msgs: 0, usd: 0, unpricedMsgs: 0, sessions: 0, byFn: {}, lim: {} });
|
|
668
|
+
const hosts = { claude: blank(), codex: blank() };
|
|
669
|
+
const daily = Object.fromEntries(dates.map((d) => [d, { claude: { tokens: 0, usd: 0, byFn: {} }, codex: { tokens: 0, usd: 0, byFn: {} } }]));
|
|
670
|
+
const models = {};
|
|
671
|
+
const lists = { claude: { tools: {}, skills: {}, agents: {}, mcp: {} }, codex: { tools: {}, skills: {}, agents: {}, mcp: {} } };
|
|
672
|
+
const sessions = {};
|
|
673
|
+
const unpriced = new Set();
|
|
674
|
+
let codexLimits = null;
|
|
675
|
+
let codexPlan = null;
|
|
676
|
+
const fromMs = hourStart(from, '00');
|
|
677
|
+
// Hours from a week before the period: a 7-day window ending on its first day needs them.
|
|
678
|
+
const hourlyFrom = dayOf(fromMs - 7 * 86400000);
|
|
679
|
+
const hourly = { claude: new Map(), codex: new Map() };
|
|
680
|
+
const codexPoints = [];
|
|
681
|
+
let lastHit = null;
|
|
682
|
+
|
|
683
|
+
for (const e of Object.values(index.files || {})) {
|
|
684
|
+
const host = e.host;
|
|
685
|
+
if (!hosts[host]) continue;
|
|
686
|
+
const lim = host === 'codex' ? e.meta?.limits : null;
|
|
687
|
+
if (lim && (!codexLimits || lim.at > codexLimits.at)) codexLimits = lim;
|
|
688
|
+
if (lim?.plan && (!codexPlan || lim.at > codexPlan.at)) codexPlan = { at: lim.at, plan: lim.plan };
|
|
689
|
+
if (host === 'codex' && Array.isArray(e.meta?.series)) for (const p of e.meta.series) codexPoints.push(p);
|
|
690
|
+
if (host === 'claude' && e.meta?.lastHit && (!lastHit || e.meta.lastHit.at > lastHit.at)) lastHit = e.meta.lastHit;
|
|
691
|
+
for (const [day, b] of Object.entries(e.days || {})) {
|
|
692
|
+
if (day < hourlyFrom || !b.h) continue;
|
|
693
|
+
for (const [hh, hb] of Object.entries(b.h)) {
|
|
694
|
+
const t = hourStart(day, hh);
|
|
695
|
+
const slot = hourly[host].get(t) || { usd: 0, tokens: 0, lim: {} };
|
|
696
|
+
for (const [model, v] of Object.entries(hb.m || {})) {
|
|
697
|
+
const { usd } = priceBucket(model, { in: v[0], out: v[1], cr: v[2], cw: v[3], cwh: v[4] }, prices);
|
|
698
|
+
if (usd != null) slot.usd += usd;
|
|
699
|
+
slot.tokens += v[0] + v[1] + v[2] + v[3] + v[4];
|
|
700
|
+
}
|
|
701
|
+
for (const [k, n] of Object.entries(hb.lim || {})) bump(slot.lim, k, n);
|
|
702
|
+
hourly[host].set(t, slot);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const fn = e.meta?.fn || 'chat';
|
|
706
|
+
let touched = false;
|
|
707
|
+
for (const [day, b] of Object.entries(e.days || {})) {
|
|
708
|
+
if (!inWindow.has(day)) continue;
|
|
709
|
+
touched = true;
|
|
710
|
+
const h = hosts[host];
|
|
711
|
+
for (const [k, n] of Object.entries(b.lim || {})) bump(h.lim, k, n);
|
|
712
|
+
for (const kind of ['tools', 'skills', 'agents', 'mcp']) for (const [k, n] of Object.entries(b[kind] || {})) bump(lists[host][kind], k, n);
|
|
713
|
+
let dayUsd = 0;
|
|
714
|
+
let dayUnpriced = false;
|
|
715
|
+
for (const [model, m] of Object.entries(b.models || {})) {
|
|
716
|
+
const { usd } = priceBucket(model, m, prices);
|
|
717
|
+
if (usd == null) dayUnpriced = true;
|
|
718
|
+
const mk = `${host}|${model}`;
|
|
719
|
+
const row = models[mk] || (models[mk] = { host, model, in: 0, out: 0, cr: 0, cw: 0, cwh: 0, msgs: 0, usd: 0, priced: usd != null });
|
|
720
|
+
row.in += m.in; row.out += m.out; row.cr += m.cr; row.cw += m.cw; row.cwh += m.cwh; row.msgs += m.msgs;
|
|
721
|
+
if (usd != null) { row.usd += usd; dayUsd += usd; } else { row.priced = false; h.unpricedMsgs += m.msgs; unpriced.add(model); }
|
|
722
|
+
}
|
|
723
|
+
const tk = tokensOf(b);
|
|
724
|
+
const side = Math.min(b.side || 0, tk);
|
|
725
|
+
h.in += b.in; h.out += b.out; h.cr += b.cr; h.cw += b.cw; h.cwh += b.cwh; h.rs += b.rs; h.msgs += b.msgs; h.usd += dayUsd;
|
|
726
|
+
const dd = daily[day][host];
|
|
727
|
+
dd.tokens += tk; dd.usd += dayUsd;
|
|
728
|
+
for (const [f, n] of [[fn, tk - side], ['subagent', side]]) {
|
|
729
|
+
if (!n) continue;
|
|
730
|
+
h.byFn[f] = (h.byFn[f] || 0) + n;
|
|
731
|
+
dd.byFn[f] = (dd.byFn[f] || 0) + n;
|
|
732
|
+
}
|
|
733
|
+
// A subagent's spend belongs to the conversation that dispatched it.
|
|
734
|
+
const sid = `${host}|${e.meta?.parent || e.meta?.session || 'unknown'}`;
|
|
735
|
+
const s = sessions[sid] || (sessions[sid] = { host, id: e.meta?.parent || e.meta?.session || null, tokens: 0, usd: 0, priced: true, msgs: 0, subagentTokens: 0, last: null, title: null, project: null, fn: null });
|
|
736
|
+
s.tokens += tk; s.usd += dayUsd; s.msgs += b.msgs;
|
|
737
|
+
s.subagentTokens += fn === 'subagent' ? tk : side;
|
|
738
|
+
if (dayUnpriced) s.priced = false;
|
|
739
|
+
}
|
|
740
|
+
if (!touched) continue;
|
|
741
|
+
const sid = `${host}|${e.meta?.parent || e.meta?.session || 'unknown'}`;
|
|
742
|
+
const s = sessions[sid];
|
|
743
|
+
if (e.meta?.last && (!s.last || e.meta.last > s.last)) s.last = e.meta.last;
|
|
744
|
+
// The conversation's own file names it; a subagent's file only fills a gap.
|
|
745
|
+
const own = !e.meta?.parent;
|
|
746
|
+
if (own || !s.title) s.title = (host === 'codex' ? codexTitles[e.meta?.session] : null) || e.meta?.title || e.meta?.prompt || s.title;
|
|
747
|
+
if (own || !s.project) s.project = projectName(e.meta?.cwd) || s.project;
|
|
748
|
+
if (own || !s.fn) s.fn = fn;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const sess = Object.values(sessions);
|
|
752
|
+
for (const host of ['claude', 'codex']) hosts[host].sessions = sess.filter((s) => s.host === host).length;
|
|
753
|
+
const topFor = (host) => {
|
|
754
|
+
const all = sess.filter((s) => s.host === host);
|
|
755
|
+
const total = all.reduce((a, s) => a + s.tokens, 0) || 1;
|
|
756
|
+
return all.sort((a, b) => b.tokens - a.tokens).slice(0, top).map((s) => ({
|
|
757
|
+
title: s.title || '(untitled)', project: s.project, fn: s.fn, tokens: s.tokens,
|
|
758
|
+
usd: s.priced ? s.usd : null, share: s.tokens / total, subagentTokens: s.subagentTokens, last: s.last,
|
|
759
|
+
}));
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
const cache = (h) => {
|
|
763
|
+
const read = h.cr; const all = h.in + h.cr + h.cw + h.cwh;
|
|
764
|
+
return { hitRate: all ? read / all : null };
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
return {
|
|
768
|
+
state: 'counted',
|
|
769
|
+
window: { from, to: dates[dates.length - 1], days: span },
|
|
770
|
+
hosts: Object.fromEntries(Object.entries(hosts).map(([k, h]) => [k, {
|
|
771
|
+
tokens: tokensOf(h), input: h.in, output: h.out, cacheRead: h.cr, cacheWrite: h.cw + h.cwh, reasoning: h.rs,
|
|
772
|
+
responses: h.msgs, sessions: h.sessions, usd: h.unpricedMsgs === h.msgs && h.msgs > 0 ? null : h.usd,
|
|
773
|
+
unpricedResponses: h.unpricedMsgs, byFn: h.byFn, ...cache(h),
|
|
774
|
+
// `plan` is every refusal by a limit of the account; `server` is the API's own throttle.
|
|
775
|
+
limitHits: { ...h.lim, plan: Object.entries(h.lim).filter(([k]) => k !== 'server').reduce((a, [, n]) => a + n, 0), server: h.lim.server || 0 },
|
|
776
|
+
}])),
|
|
777
|
+
daily: dates.map((d) => ({ date: d, ...daily[d] })),
|
|
778
|
+
models: Object.values(models).sort((a, b) => tokensOf(b) - tokensOf(a)).map((m) => ({
|
|
779
|
+
host: m.host, model: m.model, responses: m.msgs, tokens: tokensOf(m), output: m.out, usd: m.priced ? m.usd : null,
|
|
780
|
+
})),
|
|
781
|
+
top: { claude: topFor('claude'), codex: topFor('codex') },
|
|
782
|
+
lists: Object.fromEntries(Object.entries(lists).map(([host, l]) => [host, {
|
|
783
|
+
tools: rank(l.tools, 15), skills: rank(l.skills, 15), agents: rank(l.agents, 15), mcp: rank(l.mcp, 15, mcpLabel),
|
|
784
|
+
}])),
|
|
785
|
+
limits: {
|
|
786
|
+
codex: codexLimits ? { ...codexLimits, plan: codexLimits.plan || codexPlan?.plan || null, detail: codexLimitDetail(codexPoints, { fromMs, now }) } : null,
|
|
787
|
+
claude: claudeLimits(hourly.claude, { fromMs, now, lastHit }),
|
|
788
|
+
},
|
|
789
|
+
unpricedModels: [...unpriced].sort(),
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* A request-safe view: `get()` answers at once and refreshes in the background.
|
|
795
|
+
* The first pass over a real history reads gigabytes; a board request cannot
|
|
796
|
+
* wait for it, and a second request must not start a second pass.
|
|
797
|
+
*/
|
|
798
|
+
export function usageIndexSnapshot({ scan = () => scanUsage(), ttlMs = 60 * 1000, now = () => Date.now() } = {}) {
|
|
799
|
+
let last = null;
|
|
800
|
+
let at = 0;
|
|
801
|
+
let running = null;
|
|
802
|
+
const start = () => {
|
|
803
|
+
if (running) return;
|
|
804
|
+
let pass;
|
|
805
|
+
try { pass = Promise.resolve(scan()); } catch (e) { pass = Promise.reject(e); }
|
|
806
|
+
running = pass
|
|
807
|
+
.then((r) => { last = { state: 'ready', ...r }; }, (e) => { last = last?.state === 'ready' ? last : { state: 'unavailable', why: `session logs could not be read: ${e?.message || e}` }; })
|
|
808
|
+
.finally(() => { at = now(); running = null; });
|
|
809
|
+
};
|
|
810
|
+
return {
|
|
811
|
+
get() {
|
|
812
|
+
if (!last) { start(); return { state: 'computing', why: 'reading the Claude Code and Codex session logs — the first pass over a large history takes a minute or two' }; }
|
|
813
|
+
if (now() - at > ttlMs) start();
|
|
814
|
+
return last;
|
|
815
|
+
},
|
|
816
|
+
/** For tests and the CLI: wait for the pass in flight. */
|
|
817
|
+
async settle() { start(); while (running) await running; return last; },
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// CLI: node scripts/lib/session-usage.mjs [--days N] [--json]
|
|
822
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
|
|
823
|
+
if (isMain) {
|
|
824
|
+
const i = process.argv.indexOf('--days');
|
|
825
|
+
const days = i > -1 ? Number(process.argv[i + 1]) : 30;
|
|
826
|
+
const t0 = Date.now();
|
|
827
|
+
const r = await scanUsage();
|
|
828
|
+
const s = summarizeUsage(r.index, { days, codexTitles: readCodexTitles() });
|
|
829
|
+
if (process.argv.includes('--json')) { console.log(JSON.stringify(s, null, 2)); process.exit(0); }
|
|
830
|
+
const fmt = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(2)}B` : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n));
|
|
831
|
+
console.log(`session-usage: ${r.files} log files, ${r.read} read in ${((Date.now() - t0) / 1000).toFixed(1)}s · window ${s.window.from} → ${s.window.to}`);
|
|
832
|
+
for (const [h, v] of Object.entries(s.hosts)) {
|
|
833
|
+
console.log(` ${h.padEnd(7)} ${fmt(v.tokens).padStart(8)} tokens · ${v.responses} responses · ${v.sessions} sessions · cache ${v.hitRate == null ? '—' : `${Math.round(v.hitRate * 100)}%`} · ${v.usd == null ? 'unpriced' : `$${v.usd.toFixed(2)} at list price`}`);
|
|
834
|
+
}
|
|
835
|
+
if (s.limits.codex?.primary) console.log(` codex limit: ${s.limits.codex.primary.used}% of ${s.limits.codex.primary.minutes} min window (plan ${s.limits.codex.plan})`);
|
|
836
|
+
}
|