lostvibe 0.0.0-stage → 0.7.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 +39 -2
- package/bin/lostvibe.mjs +632 -0
- package/package.json +32 -4
- package/src/collectors.mjs +129 -0
- package/src/connections.mjs +116 -0
- package/src/gitinfo.mjs +176 -0
- package/src/hooks.mjs +98 -0
- package/src/logs.mjs +586 -0
- package/src/prices.mjs +1127 -0
- package/src/push.mjs +56 -0
- package/src/remote.mjs +111 -0
- package/src/reported.mjs +43 -0
- package/src/scan.mjs +212 -0
- package/src/table.mjs +100 -0
package/src/logs.mjs
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import readline from 'node:readline';
|
|
5
|
+
import zlib from 'node:zlib';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import { costOf, normalizeModel } from './prices.mjs';
|
|
8
|
+
|
|
9
|
+
const hash = (s) => crypto.createHash('sha256').update(s).digest('hex').slice(0, 32);
|
|
10
|
+
// A day means the owner's day. One computer keeps one zone forever, so a trip cannot
|
|
11
|
+
// move a past response into another day and count it twice on the server.
|
|
12
|
+
let zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
13
|
+
const formatters = new Map();
|
|
14
|
+
export function setZone(next) { if (next) zone = next; }
|
|
15
|
+
export function localDate(ts, tz = zone) {
|
|
16
|
+
const d = new Date(ts);
|
|
17
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
18
|
+
if (!formatters.has(tz)) formatters.set(tz, new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }));
|
|
19
|
+
return formatters.get(tz).format(d);
|
|
20
|
+
}
|
|
21
|
+
function* walk(dir, match, depth = 0) {
|
|
22
|
+
let entries;
|
|
23
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
24
|
+
for (const e of entries) {
|
|
25
|
+
const p = path.join(dir, e.name);
|
|
26
|
+
if (e.isDirectory()) { if (depth < 16 && e.name !== 'node_modules' && e.name !== '.git') yield* walk(p, match, depth + 1); }
|
|
27
|
+
else if (e.isFile() && match(e.name, p)) yield p;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function* walkAll(dirs, match) { for (const d of dirs) yield* walk(d, match); }
|
|
31
|
+
const isJsonl = (name) => /\.jsonl(\.zst)?$/.test(name);
|
|
32
|
+
function readJsonFile(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
async function* lines(file) {
|
|
35
|
+
const input = fs.createReadStream(file, { encoding: 'utf8' });
|
|
36
|
+
const rl = readline.createInterface({ input, crlfDelay: Infinity });
|
|
37
|
+
try { for await (const line of rl) yield line; } finally { rl.close(); input.destroy(); }
|
|
38
|
+
}
|
|
39
|
+
// A wall clock with no zone, read in the fixed zone of this computer.
|
|
40
|
+
function wallToIso(y, mo, d, h, mi, s) {
|
|
41
|
+
const want = Date.UTC(y, mo - 1, d, h, mi, s);
|
|
42
|
+
let guess = want;
|
|
43
|
+
const f = new Intl.DateTimeFormat('en-US', { timeZone: zone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
44
|
+
for (let i = 0; i < 3; i++) {
|
|
45
|
+
const p = Object.fromEntries(f.formatToParts(new Date(guess)).map((x) => [x.type, Number(x.value)]));
|
|
46
|
+
const seen = Date.UTC(p.year, p.month - 1, p.day, p.hour % 24, p.minute, p.second);
|
|
47
|
+
if (seen === want) break;
|
|
48
|
+
guess += want - seen;
|
|
49
|
+
}
|
|
50
|
+
return new Date(guess).toISOString();
|
|
51
|
+
}
|
|
52
|
+
// node:sqlite is built in from Node 22.13 but still announces itself; that notice is not the owner's concern.
|
|
53
|
+
let sqliteModule = null;
|
|
54
|
+
async function sqlite() {
|
|
55
|
+
if (sqliteModule) return sqliteModule;
|
|
56
|
+
const prev = process.listeners('warning');
|
|
57
|
+
process.removeAllListeners('warning');
|
|
58
|
+
process.on('warning', (w) => { if (!/sqlite/i.test(String(w?.message))) for (const fn of prev) fn(w); });
|
|
59
|
+
sqliteModule = await import('node:sqlite');
|
|
60
|
+
return sqliteModule;
|
|
61
|
+
}
|
|
62
|
+
async function queryDb(file, sqlFor) {
|
|
63
|
+
const { DatabaseSync } = await sqlite();
|
|
64
|
+
const db = new DatabaseSync(file, { readOnly: true });
|
|
65
|
+
try {
|
|
66
|
+
const tables = new Set(db.prepare("select name from sqlite_master where type in ('table','view')").all().map((r) => r.name));
|
|
67
|
+
const q = sqlFor(tables);
|
|
68
|
+
return q ? db.prepare(q).all() : [];
|
|
69
|
+
} finally { db.close(); }
|
|
70
|
+
}
|
|
71
|
+
async function* records(file) {
|
|
72
|
+
const raw = fs.createReadStream(file);
|
|
73
|
+
const input = file.endsWith('.zst') ? raw.pipe(zlib.createZstdDecompress()) : raw;
|
|
74
|
+
raw.on('error', (e) => input.destroy(e));
|
|
75
|
+
const lines = readline.createInterface({ input, crlfDelay: Infinity });
|
|
76
|
+
try {
|
|
77
|
+
for await (const line of lines) {
|
|
78
|
+
if (!/"(usage|session_meta|turn_context|token_usage_record|token_count)"/.test(line)) continue;
|
|
79
|
+
try { yield JSON.parse(line); } catch { /* unfinished last line is read again next time */ }
|
|
80
|
+
}
|
|
81
|
+
} finally { lines.close(); raw.destroy(); input.destroy(); }
|
|
82
|
+
}
|
|
83
|
+
const zeroTokens = () => ({ input: 0, output: 0, cache_create: 0, cache_read: 0, cc1h: 0 });
|
|
84
|
+
const num = (n) => typeof n === 'number' && Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
|
85
|
+
// A tool that prices its own responses fills in for models the table does not know.
|
|
86
|
+
function event(tool, key, cwd, model, ts, session, t, aliases = [], reportedCost = null) {
|
|
87
|
+
const at = new Date(ts);
|
|
88
|
+
if (Number.isNaN(at.getTime())) return null;
|
|
89
|
+
let cost = costOf(normalizeModel(model), t);
|
|
90
|
+
let unpriced = cost === null;
|
|
91
|
+
if (cost === null && typeof reportedCost === 'number' && Number.isFinite(reportedCost) && reportedCost >= 0) { cost = reportedCost; unpriced = false; }
|
|
92
|
+
return { id: hash(`${tool}:${key}`), aliases, tool, cwd: cwd || 'unknown', model: normalizeModel(model),
|
|
93
|
+
at: at.toISOString(), session: hash(`${tool}:${session || key}`),
|
|
94
|
+
...t, cost: cost === null ? 0 : cost, unpriced };
|
|
95
|
+
}
|
|
96
|
+
function mergeEvents(list) {
|
|
97
|
+
const out = new Map();
|
|
98
|
+
for (const e of list.filter(Boolean)) {
|
|
99
|
+
const prev = out.get(e.id);
|
|
100
|
+
if (!prev) { out.set(e.id, { ...e }); continue; }
|
|
101
|
+
for (const k of ['input', 'output', 'cache_create', 'cache_read', 'cc1h', 'cost']) prev[k] = Math.max(prev[k] || 0, e[k] || 0);
|
|
102
|
+
prev.aliases = [...new Set([...(prev.aliases || []), ...(e.aliases || [])])];
|
|
103
|
+
if (e.at < prev.at) prev.at = e.at;
|
|
104
|
+
if (prev.model.startsWith('unknown') && !e.model.startsWith('unknown')) { prev.model = e.model; prev.unpriced = e.unpriced; }
|
|
105
|
+
}
|
|
106
|
+
return [...out.values()];
|
|
107
|
+
}
|
|
108
|
+
async function claudeFile(file) {
|
|
109
|
+
const out = [];
|
|
110
|
+
for await (const o of records(file)) {
|
|
111
|
+
if (o.type !== 'assistant' || !o.message?.usage || !o.message.model || o.message.model === '<synthetic>') continue;
|
|
112
|
+
const u = o.message.usage;
|
|
113
|
+
const key = o.message.id ? `${o.message.id}:${o.requestId || ''}` : `${o.sessionId}:${o.timestamp}:${o.message.model}`;
|
|
114
|
+
out.push(event('claude-code', key, o.cwd, o.message.model, o.timestamp, o.sessionId, {
|
|
115
|
+
input: num(u.input_tokens), output: num(u.output_tokens), cache_create: num(u.cache_creation_input_tokens),
|
|
116
|
+
cache_read: num(u.cache_read_input_tokens), cc1h: num(u.cache_creation?.ephemeral_1h_input_tokens),
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
return mergeEvents(out);
|
|
120
|
+
}
|
|
121
|
+
function codexTokens(u) {
|
|
122
|
+
const cached = num(u.cached_input_tokens); const write = num(u.cache_write_input_tokens);
|
|
123
|
+
return { input: Math.max(0, num(u.input_tokens) - cached - write), output: num(u.output_tokens), cache_create: write, cache_read: cached, cc1h: 0 };
|
|
124
|
+
}
|
|
125
|
+
async function codexFile(file) {
|
|
126
|
+
let cwd = null; let model = null; let session = null;
|
|
127
|
+
const out = [];
|
|
128
|
+
for await (const o of records(file)) {
|
|
129
|
+
const p = o.payload || {};
|
|
130
|
+
if (o.type === 'session_meta') { cwd = p.cwd || cwd; session = p.session_id || p.id || session; }
|
|
131
|
+
else if (o.type === 'turn_context') { cwd = p.cwd || cwd; model = p.model || model; }
|
|
132
|
+
else if (o.type === 'token_usage_record') {
|
|
133
|
+
out.push(event('codex', `response:${p.response_id || `${session}:${o.timestamp}`}`, cwd, p.model || model || 'unknown-codex', o.timestamp, p.session_id || session, codexTokens(p.usage || {})));
|
|
134
|
+
} else if (o.type === 'event_msg' && p.type === 'token_count' && p.info?.last_token_usage) {
|
|
135
|
+
const total = p.info.total_token_usage?.total_tokens;
|
|
136
|
+
if (typeof total !== 'number') continue;
|
|
137
|
+
const e = event('codex', `total:${session || hash(file)}:${total}`, cwd, model || 'unknown-codex', o.timestamp, session, codexTokens(p.info.last_token_usage));
|
|
138
|
+
if (e) { e.legacy = true; e.cumulative = total; }
|
|
139
|
+
out.push(e);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return mergeEvents(out);
|
|
143
|
+
}
|
|
144
|
+
// Pair old/new representations before adding either, including copies in other files.
|
|
145
|
+
// Matching is one-to-one; repeated equal-size responses remain separate events.
|
|
146
|
+
function reconcileCodex(list) {
|
|
147
|
+
const unique = mergeEvents(list);
|
|
148
|
+
const legacy = new Map();
|
|
149
|
+
const signature = (e) => `${e.session}:${e.input}:${e.output}:${e.cache_create}:${e.cache_read}`;
|
|
150
|
+
for (const e of unique) if (e.legacy) {
|
|
151
|
+
const k = signature(e); if (!legacy.has(k)) legacy.set(k, []); legacy.get(k).push(e);
|
|
152
|
+
}
|
|
153
|
+
const removed = new Set();
|
|
154
|
+
const prefix = new Map();
|
|
155
|
+
const modern = unique.filter((e) => !e.legacy).sort((a,b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
|
|
156
|
+
for (const e of modern) {
|
|
157
|
+
const running = (prefix.get(e.session) || 0)+e.input+e.output+e.cache_create+e.cache_read;
|
|
158
|
+
prefix.set(e.session,running);
|
|
159
|
+
const candidates = (legacy.get(signature(e)) || []).filter((x) => !removed.has(x.id) && Math.abs(Date.parse(x.at)-Date.parse(e.at))<=60000);
|
|
160
|
+
// Prefer the counter's cumulative position. A completed response can precede
|
|
161
|
+
// its compatibility counter by several seconds while tool output is flushed.
|
|
162
|
+
const old = candidates.find((x) => x.cumulative === running) || candidates.sort((a,b) => Math.abs(Date.parse(a.at)-Date.parse(e.at))-Math.abs(Date.parse(b.at)-Date.parse(e.at)))[0];
|
|
163
|
+
if (old) { e.aliases = [...new Set([...e.aliases, old.id])]; removed.add(old.id); }
|
|
164
|
+
}
|
|
165
|
+
return unique.filter((e) => !removed.has(e.id));
|
|
166
|
+
}
|
|
167
|
+
async function readTool(buckets, tool, fileList, parseFile, cache) {
|
|
168
|
+
const entries = [];
|
|
169
|
+
let files = 0;
|
|
170
|
+
const cacheFiles = cache?.files || {};
|
|
171
|
+
const seen = new Set();
|
|
172
|
+
for (const file of fileList) {
|
|
173
|
+
if (seen.has(file)) continue;
|
|
174
|
+
seen.add(file);
|
|
175
|
+
let st;
|
|
176
|
+
try { st = fs.statSync(file); } catch { continue; }
|
|
177
|
+
files++;
|
|
178
|
+
const prev = cacheFiles[file];
|
|
179
|
+
let events;
|
|
180
|
+
if (prev?.size === st.size && prev?.mtime === st.mtimeMs && prev.tool === tool) events = prev.events;
|
|
181
|
+
else {
|
|
182
|
+
// A store that is locked or half-written keeps what the last read found.
|
|
183
|
+
let parsed;
|
|
184
|
+
try { parsed = await parseFile(file); } catch { continue; }
|
|
185
|
+
events = mergeEvents([...(prev?.tool === tool ? prev.events : []), ...parsed]);
|
|
186
|
+
}
|
|
187
|
+
cacheFiles[file] = { tool, size: st.size, mtime: st.mtimeMs, events };
|
|
188
|
+
}
|
|
189
|
+
// Cached normalized events survive log cleanup. Caches contain no conversation content.
|
|
190
|
+
for (const entry of Object.values(cacheFiles)) if (entry.tool === tool) entries.push(...entry.events);
|
|
191
|
+
const events = tool === 'codex' ? reconcileCodex(entries) : mergeEvents(entries);
|
|
192
|
+
for (const e of events) {
|
|
193
|
+
if (!buckets.has(e.cwd)) buckets.set(e.cwd, emptyBucket());
|
|
194
|
+
buckets.get(e.cwd).events.set(e.id, e);
|
|
195
|
+
}
|
|
196
|
+
for (const [cwd, b] of buckets) buckets.set(cwd, bucketFromEvents([...b.events.values()]));
|
|
197
|
+
return { files };
|
|
198
|
+
}
|
|
199
|
+
export async function readClaude(buckets, { cache = { files: {} } } = {}) {
|
|
200
|
+
return readTool(buckets, 'claude-code', walkAll([path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'projects')], isJsonl), claudeFile, cache);
|
|
201
|
+
}
|
|
202
|
+
export async function readCodex(buckets, { cache = { files: {} } } = {}) {
|
|
203
|
+
const home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
204
|
+
return readTool(buckets, 'codex', walkAll(['sessions', 'archived_sessions'].map((d) => path.join(home, d)), isJsonl), codexFile, cache);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---- Gemini CLI: ~/.gemini/tmp/<project>/chats/session-*.jsonl (older: .json), tokens on each model message.
|
|
208
|
+
function geminiProjects(tmpDir, candidates) {
|
|
209
|
+
// New folders are named by a slug and carry .project_root; older ones are sha256(path) and keep no path.
|
|
210
|
+
const bySlug = new Map();
|
|
211
|
+
const reg = readJsonFile(path.join(path.dirname(tmpDir), 'projects.json'));
|
|
212
|
+
for (const [p, slug] of Object.entries(reg?.projects || {})) if (typeof slug === 'string') bySlug.set(slug, p);
|
|
213
|
+
for (const c of candidates) bySlug.set(crypto.createHash('sha256').update(c).digest('hex'), c);
|
|
214
|
+
return (id) => {
|
|
215
|
+
try { const root = fs.readFileSync(path.join(tmpDir, id, '.project_root'), 'utf8').trim(); if (root) return root; } catch { /* older folder */ }
|
|
216
|
+
return bySlug.get(id) || null;
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function geminiMessage(tool, session, cwd, m) {
|
|
220
|
+
const t = m?.tokens;
|
|
221
|
+
if (m?.type !== 'gemini' || !t || typeof t !== 'object') return null;
|
|
222
|
+
const cached = num(t.cached);
|
|
223
|
+
return event(tool, `${session}:${m.id}`, cwd, m.model || 'unknown-gemini-cli', m.timestamp, session, {
|
|
224
|
+
input: Math.max(0, num(t.input) - cached) + num(t.tool), output: num(t.output) + num(t.thoughts), cache_create: 0, cache_read: cached, cc1h: 0,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
async function geminiFile(file, project) {
|
|
228
|
+
const chats = path.dirname(file).split(path.sep);
|
|
229
|
+
const id = chats[chats.lastIndexOf('chats') - 1];
|
|
230
|
+
const cwd = project(id);
|
|
231
|
+
const out = [];
|
|
232
|
+
if (file.endsWith('.json')) {
|
|
233
|
+
const o = readJsonFile(file);
|
|
234
|
+
for (const m of o?.messages || []) out.push(geminiMessage('gemini-cli', o.sessionId || hash(file), cwd, m));
|
|
235
|
+
return mergeEvents(out);
|
|
236
|
+
}
|
|
237
|
+
let session = null;
|
|
238
|
+
for await (const line of lines(file)) {
|
|
239
|
+
if (!/"(sessionId|tokens)"/.test(line)) continue;
|
|
240
|
+
let o; try { o = JSON.parse(line); } catch { continue; }
|
|
241
|
+
if (o.sessionId && !o.id) session = o.sessionId;
|
|
242
|
+
else out.push(geminiMessage('gemini-cli', session || hash(file), cwd, o));
|
|
243
|
+
}
|
|
244
|
+
return mergeEvents(out);
|
|
245
|
+
}
|
|
246
|
+
export async function readGemini(buckets, { cache = { files: {} }, candidates = [] } = {}) {
|
|
247
|
+
const home = process.env.GEMINI_CLI_HOME || os.homedir();
|
|
248
|
+
const roots = [path.join(home, '.gemini', 'tmp'), path.join(home, '.cache', '.gemini', 'tmp')];
|
|
249
|
+
const match = (name, p) => /\.jsonl?$/.test(name) && p.split(path.sep).includes('chats');
|
|
250
|
+
let total = { files: 0 };
|
|
251
|
+
for (const tmpDir of roots) {
|
|
252
|
+
const project = geminiProjects(tmpDir, candidates);
|
|
253
|
+
const r = await readTool(buckets, 'gemini-cli', walk(tmpDir, match), (f) => geminiFile(f, project), cache);
|
|
254
|
+
total.files += r.files;
|
|
255
|
+
}
|
|
256
|
+
return total;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---- Grok Build (xAI): ~/.grok/sessions/<cwd>/<session>/usage.json, one row per turn and model.
|
|
260
|
+
function grokCwd(sessionDir) {
|
|
261
|
+
const summary = readJsonFile(path.join(sessionDir, 'summary.json'));
|
|
262
|
+
if (typeof summary?.info?.cwd === 'string' && summary.info.cwd) return summary.info.cwd;
|
|
263
|
+
const parent = path.dirname(sessionDir);
|
|
264
|
+
try { const c = fs.readFileSync(path.join(parent, '.cwd'), 'utf8').trim(); if (c) return c; } catch { /* short name */ }
|
|
265
|
+
try { const name = decodeURIComponent(path.basename(parent)); if (/^([a-zA-Z]:)?[\\/]/.test(name)) return name; } catch { /* not a path */ }
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
function grokTurn(t, model, u) {
|
|
269
|
+
const cached = num(u.cachedReadTokens);
|
|
270
|
+
return { input: Math.max(0, num(u.inputTokens) - cached), output: num(u.outputTokens), cache_create: num(u.cacheCreationTokens), cache_read: cached, cc1h: 0 };
|
|
271
|
+
}
|
|
272
|
+
async function grokUsageFile(file) {
|
|
273
|
+
const o = readJsonFile(file);
|
|
274
|
+
if (!o || !Array.isArray(o.turns)) return [];
|
|
275
|
+
const dir = path.dirname(file);
|
|
276
|
+
const session = o.sessionId || path.basename(dir);
|
|
277
|
+
const cwd = grokCwd(dir);
|
|
278
|
+
const out = [];
|
|
279
|
+
for (const t of o.turns) {
|
|
280
|
+
const per = t.modelUsage && typeof t.modelUsage === 'object' ? Object.entries(t.modelUsage) : [];
|
|
281
|
+
const list = per.length ? per : [[t.primaryModelId || o.session?.primaryModelId || 'unknown-grok-cli', t]];
|
|
282
|
+
for (const [model, u] of list) {
|
|
283
|
+
const cost = typeof u.costUsdTicks === 'number' ? u.costUsdTicks / 1e10 : null;
|
|
284
|
+
out.push(event('grok-cli', `${session}:${t.turnNumber}:${model}`, cwd, model, t.endedAt || o.updatedAt, session, grokTurn(t, model, u), [], cost));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return mergeEvents(out);
|
|
288
|
+
}
|
|
289
|
+
// grok-dev (superagent-ai/grok-cli): ~/.grok/grok.db, usage_events joined to sessions for the cwd. No cache split is stored.
|
|
290
|
+
async function grokDbFile(file) {
|
|
291
|
+
const rows = await queryDb(file, (tables) => (tables.has('usage_events') && tables.has('sessions')
|
|
292
|
+
? 'select u.id, u.session_id, u.model, u.input_tokens, u.output_tokens, u.cost_micros, u.created_at, s.cwd_at_start, s.model as session_model from usage_events u left join sessions s on s.id = u.session_id' : null));
|
|
293
|
+
return mergeEvents(rows.map((r) => event('grok-cli', `db:${r.session_id}:${r.id}`, r.cwd_at_start, r.model || r.session_model || 'unknown-grok-cli', r.created_at, r.session_id,
|
|
294
|
+
{ input: num(r.input_tokens), output: num(r.output_tokens), cache_create: 0, cache_read: 0, cc1h: 0 }, [], typeof r.cost_micros === 'number' ? r.cost_micros / 1e6 : null)));
|
|
295
|
+
}
|
|
296
|
+
export async function readGrok(buckets, { cache = { files: {} } } = {}) {
|
|
297
|
+
const home = process.env.GROK_HOME || path.join(os.homedir(), '.grok');
|
|
298
|
+
const a = await readTool(buckets, 'grok-cli', walk(path.join(home, 'sessions'), (name) => name === 'usage.json'), grokUsageFile, cache);
|
|
299
|
+
const dbFile = path.join(home, 'grok.db');
|
|
300
|
+
const b = fs.existsSync(dbFile) ? await readTool(buckets, 'grok-cli', [dbFile], grokDbFile, cache) : { files: 0 };
|
|
301
|
+
return { files: a.files + b.files };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---- Cline, Roo Code, Kilo Code task logs: <globalStorage>/<extension>/tasks/<task>/ui_messages.json.
|
|
305
|
+
// Only the numbers inside api_req_started are read; the request text beside them never leaves the parser.
|
|
306
|
+
const TASK_LOG_EXTENSIONS = {
|
|
307
|
+
cline: ['saoudrizwan.claude-dev'],
|
|
308
|
+
'roo-code': ['rooveterinaryinc.roo-cline', 'rooveterinaryinc.roo-code', 'rooveterinaryinc.roo-code-nightly', 'roovscode.roo-cline', 'roovscode.roo-code'],
|
|
309
|
+
'kilo-code': ['kilocode.kilo-code'],
|
|
310
|
+
};
|
|
311
|
+
function vscodeStorageDirs() {
|
|
312
|
+
if (process.env.LOSTVIBE_VSCODE_STORAGE) return process.env.LOSTVIBE_VSCODE_STORAGE.split(path.delimiter).filter(Boolean);
|
|
313
|
+
const home = os.homedir();
|
|
314
|
+
const base = process.platform === 'darwin' ? path.join(home, 'Library', 'Application Support')
|
|
315
|
+
: process.platform === 'win32' ? (process.env.APPDATA || path.join(home, 'AppData', 'Roaming'))
|
|
316
|
+
: (process.env.XDG_CONFIG_HOME || path.join(home, '.config'));
|
|
317
|
+
return ['Code', 'Code - Insiders', 'Code - OSS', 'VSCodium', 'Cursor', 'Windsurf', 'Trae', 'Positron'].map((p) => path.join(base, p, 'User', 'globalStorage'));
|
|
318
|
+
}
|
|
319
|
+
function clineDataDir() {
|
|
320
|
+
return process.env.CLINE_DATA_DIR || path.join(process.env.CLINE_DIR || path.join(os.homedir(), '.cline'), 'data');
|
|
321
|
+
}
|
|
322
|
+
// Where each store keeps the workspace and model of a task: beside the task (Roo), in an index (Roo), in state/ (Cline).
|
|
323
|
+
async function taskIndex(extDir, extIds) {
|
|
324
|
+
const items = new Map();
|
|
325
|
+
const add = (it) => { if (it && typeof it.id === 'string') items.set(it.id, { ...(items.get(it.id) || {}), ...it }); };
|
|
326
|
+
for (const it of readJsonFile(path.join(extDir, 'state', 'taskHistory.json')) || []) add(it);
|
|
327
|
+
for (const it of readJsonFile(path.join(extDir, 'tasks', '_index.json'))?.entries || []) add(it);
|
|
328
|
+
const vscdb = path.join(path.dirname(extDir), 'state.vscdb');
|
|
329
|
+
if (fs.existsSync(vscdb)) {
|
|
330
|
+
try {
|
|
331
|
+
const rows = await queryDb(vscdb, (tables) => (tables.has('ItemTable') ? `select value from ItemTable where lower(key) in (${extIds.map((x) => `'${x.toLowerCase()}'`).join(',')})` : null));
|
|
332
|
+
for (const r of rows) { let v; try { v = JSON.parse(r.value); } catch { continue; } for (const it of v?.taskHistory || []) add(it); }
|
|
333
|
+
} catch { /* VS Code may hold the file */ }
|
|
334
|
+
}
|
|
335
|
+
return items;
|
|
336
|
+
}
|
|
337
|
+
function taskMeta(taskDir, taskId, index) {
|
|
338
|
+
const it = { ...(index.get(taskId) || {}), ...(readJsonFile(path.join(taskDir, 'history_item.json')) || {}) };
|
|
339
|
+
const usage = readJsonFile(path.join(taskDir, 'task_metadata.json'))?.model_usage;
|
|
340
|
+
const models = (Array.isArray(usage) ? usage : []).filter((u) => typeof u?.model_id === 'string').sort((a, b) => (a.ts || 0) - (b.ts || 0));
|
|
341
|
+
return { cwd: it.workspace || it.cwdOnTaskInitialization || null, model: it.modelId || null, models };
|
|
342
|
+
}
|
|
343
|
+
async function taskLogFile(tool, file, index) {
|
|
344
|
+
const taskDir = path.dirname(file);
|
|
345
|
+
const taskId = path.basename(taskDir);
|
|
346
|
+
const msgs = readJsonFile(file);
|
|
347
|
+
if (!Array.isArray(msgs)) return [];
|
|
348
|
+
const meta = taskMeta(taskDir, taskId, index);
|
|
349
|
+
const out = [];
|
|
350
|
+
for (const m of msgs) {
|
|
351
|
+
if (m?.say !== 'api_req_started' || typeof m.text !== 'string') continue;
|
|
352
|
+
let info; try { info = JSON.parse(m.text); } catch { continue; }
|
|
353
|
+
const t = { input: num(info.tokensIn), output: num(info.tokensOut), cache_create: num(info.cacheWrites), cache_read: num(info.cacheReads), cc1h: 0 };
|
|
354
|
+
if (!t.input && !t.output && !t.cache_create && !t.cache_read) continue;
|
|
355
|
+
// OpenAI-style counts carry the cached part inside tokensIn; Anthropic-style counts keep it apart.
|
|
356
|
+
if ((info.apiProtocol || m.apiProtocol) === 'openai') t.input = Math.max(0, t.input - t.cache_create - t.cache_read);
|
|
357
|
+
const atModel = meta.models.filter((u) => (u.ts || 0) <= (m.ts || 0)).pop() || meta.models[0];
|
|
358
|
+
const model = atModel?.model_id || meta.model || `unknown-${tool}`;
|
|
359
|
+
out.push(event(tool, `${taskId}:${m.ts}`, meta.cwd, model, m.ts, taskId, t, [], typeof info.cost === 'number' ? info.cost : null));
|
|
360
|
+
}
|
|
361
|
+
return mergeEvents(out);
|
|
362
|
+
}
|
|
363
|
+
// Cline 4.x keeps sessions in ~/.cline/data/sessions/<id>/<id>.messages.json with metrics and model on each reply.
|
|
364
|
+
function clineSessionCwd(sessionDir, id) {
|
|
365
|
+
const manifest = readJsonFile(path.join(sessionDir, `${id}.json`));
|
|
366
|
+
for (const k of ['cwd', 'workspaceRoot', 'workspace_root', 'directory']) if (typeof manifest?.[k] === 'string' && manifest[k]) return manifest[k];
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
async function clineSessionFile(file, dbRows) {
|
|
370
|
+
const dir = path.dirname(file);
|
|
371
|
+
const id = path.basename(file).replace(/\.messages\.json$/, '');
|
|
372
|
+
const cwd = clineSessionCwd(dir, id) || dbRows.get(id)?.cwd || null;
|
|
373
|
+
let msgs = readJsonFile(file);
|
|
374
|
+
if (!Array.isArray(msgs)) {
|
|
375
|
+
msgs = [];
|
|
376
|
+
for await (const line of lines(file)) { try { msgs.push(JSON.parse(line)); } catch { /* partial */ } }
|
|
377
|
+
}
|
|
378
|
+
const out = [];
|
|
379
|
+
msgs.forEach((m, i) => {
|
|
380
|
+
const x = m?.metrics;
|
|
381
|
+
if (!x || typeof x !== 'object') return;
|
|
382
|
+
const t = { input: num(x.inputTokens), output: num(x.outputTokens), cache_create: num(x.cacheWriteTokens), cache_read: num(x.cacheReadTokens), cc1h: 0 };
|
|
383
|
+
if (!t.input && !t.output && !t.cache_create && !t.cache_read) return;
|
|
384
|
+
out.push(event('cline', `sdk:${id}:${m.id || m.ts || i}`, cwd, m.modelInfo?.id || dbRows.get(id)?.model || 'unknown-cline', m.ts || dbRows.get(id)?.started || Date.now(), id, t, [], typeof x.cost === 'number' ? x.cost : null));
|
|
385
|
+
});
|
|
386
|
+
return mergeEvents(out);
|
|
387
|
+
}
|
|
388
|
+
async function clineSessionRows(dataDir) {
|
|
389
|
+
const rows = new Map();
|
|
390
|
+
const db = path.join(dataDir, 'db', 'sessions.db');
|
|
391
|
+
if (!fs.existsSync(db)) return rows;
|
|
392
|
+
try {
|
|
393
|
+
for (const r of await queryDb(db, (tables) => (tables.has('sessions') ? 'select session_id, cwd, workspace_root, model, started_at from sessions' : null))) {
|
|
394
|
+
rows.set(r.session_id, { cwd: r.cwd || r.workspace_root || null, model: r.model || null, started: r.started_at || null });
|
|
395
|
+
}
|
|
396
|
+
} catch { /* locked */ }
|
|
397
|
+
return rows;
|
|
398
|
+
}
|
|
399
|
+
export async function readTaskLogs(buckets, { cache = { files: {} } } = {}) {
|
|
400
|
+
const stores = vscodeStorageDirs();
|
|
401
|
+
const files = {};
|
|
402
|
+
for (const [tool, extIds] of Object.entries(TASK_LOG_EXTENSIONS)) {
|
|
403
|
+
files[tool] = 0;
|
|
404
|
+
const extDirs = stores.flatMap((s) => extIds.map((e) => path.join(s, e)));
|
|
405
|
+
if (tool === 'cline') extDirs.push(clineDataDir());
|
|
406
|
+
for (const extDir of extDirs) {
|
|
407
|
+
if (!fs.existsSync(path.join(extDir, 'tasks'))) continue;
|
|
408
|
+
const index = await taskIndex(extDir, extIds);
|
|
409
|
+
const r = await readTool(buckets, tool, walk(path.join(extDir, 'tasks'), (name) => name === 'ui_messages.json'), (f) => taskLogFile(tool, f, index), cache);
|
|
410
|
+
files[tool] += r.files;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const sessions = path.join(clineDataDir(), 'sessions');
|
|
414
|
+
if (fs.existsSync(sessions)) {
|
|
415
|
+
const rows = await clineSessionRows(clineDataDir());
|
|
416
|
+
const r = await readTool(buckets, 'cline', walk(sessions, (name) => name.endsWith('.messages.json')), (f) => clineSessionFile(f, rows), cache);
|
|
417
|
+
files.cline += r.files;
|
|
418
|
+
}
|
|
419
|
+
return { files };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ---- Aider: "> Tokens: 4.3k sent, 1.1k received." lines in .aider.chat.history.md at the git root.
|
|
423
|
+
// Counts are rounded to the nearest hundred or thousand in that file; nothing finer exists on disk.
|
|
424
|
+
function kTokens(s) {
|
|
425
|
+
const m = /^([\d.]+)(k)?$/.exec(String(s || ''));
|
|
426
|
+
if (!m) return 0;
|
|
427
|
+
const n = Number(m[1]) * (m[2] ? 1000 : 1);
|
|
428
|
+
return Number.isFinite(n) ? Math.round(n) : 0;
|
|
429
|
+
}
|
|
430
|
+
async function aiderFile(file) {
|
|
431
|
+
const cwd = path.dirname(file);
|
|
432
|
+
let start = null; let model = 'unknown-aider'; let n = 0;
|
|
433
|
+
const out = [];
|
|
434
|
+
for await (const line of lines(file)) {
|
|
435
|
+
let m;
|
|
436
|
+
if ((m = /^# aider chat started at (\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/.exec(line))) { start = wallToIso(...m.slice(1).map(Number)); n = 0; }
|
|
437
|
+
else if ((m = /^> (?:Main model|Model): (\S+) with /.exec(line))) model = m[1];
|
|
438
|
+
else if ((m = /^> Tokens: (\S+) sent(?:, (\S+) cache write)?(?:, (\S+) cache hit)?, (\S+) received\./.exec(line))) {
|
|
439
|
+
n++;
|
|
440
|
+
const sent = kTokens(m[1]); const write = kTokens(m[2]); const hit = kTokens(m[3]);
|
|
441
|
+
// "sent" already includes the cache write when there is one.
|
|
442
|
+
out.push(event('aider', `${cwd}:${start}:${n}`, cwd, model, start || fs.statSync(file).mtime, `${cwd}:${start}`,
|
|
443
|
+
{ input: Math.max(0, sent - write), output: kTokens(m[4]), cache_create: write, cache_read: hit, cc1h: 0 }));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return mergeEvents(out);
|
|
447
|
+
}
|
|
448
|
+
export async function readAider(buckets, { cache = { files: {} }, dirs = [] } = {}) {
|
|
449
|
+
// One folder under two names (a symlinked /var on macOS) is one history file; the first name is its project.
|
|
450
|
+
const files = new Map();
|
|
451
|
+
const env = process.env.AIDER_CHAT_HISTORY_FILE;
|
|
452
|
+
const list = [...dirs.map((d) => path.join(d, '.aider.chat.history.md')), ...(env && path.isAbsolute(env) ? [env] : [])];
|
|
453
|
+
for (const f of list) {
|
|
454
|
+
let real; try { real = fs.realpathSync(f); } catch { continue; }
|
|
455
|
+
if (!files.has(real)) files.set(real, f);
|
|
456
|
+
}
|
|
457
|
+
return readTool(buckets, 'aider', [...files.values()], aiderFile, cache);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---- OpenCode (and Kilo Code 7+, its fork): sqlite under $XDG_DATA_HOME/<app>/, assistant messages carry tokens.
|
|
461
|
+
// Only numbers and ids are selected from the database; message text stays inside sqlite.
|
|
462
|
+
function opencodeRow(tool, r, directory) {
|
|
463
|
+
const t = { input: num(r.input), output: num(r.output) + num(r.reasoning), cache_create: num(r.cache_write), cache_read: num(r.cache_read), cc1h: 0 };
|
|
464
|
+
return event(tool, `${r.session_id}:${r.id}`, directory, r.model || 'unknown-opencode', r.created || r.time_created, r.session_id, t, [], typeof r.cost === 'number' ? r.cost : null);
|
|
465
|
+
}
|
|
466
|
+
const OC_TOKENS = "json_extract(m.data,'$.tokens.input') as input, json_extract(m.data,'$.tokens.output') as output, json_extract(m.data,'$.tokens.reasoning') as reasoning, json_extract(m.data,'$.tokens.cache.read') as cache_read, json_extract(m.data,'$.tokens.cache.write') as cache_write, json_extract(m.data,'$.cost') as cost, json_extract(m.data,'$.time.created') as created, json_extract(m.data,'$.path.cwd') as cwd";
|
|
467
|
+
async function opencodeDbFile(tool, file) {
|
|
468
|
+
const rows = await queryDb(file, (tables) => {
|
|
469
|
+
if (tables.has('session_message') && tables.has('session_v2')) return `select m.id, m.session_id, m.time_created, s.directory, json_extract(m.data,'$.model.id') as model, ${OC_TOKENS} from session_message m left join session_v2 s on s.id = m.session_id where m.type = 'assistant'`;
|
|
470
|
+
if (tables.has('message') && tables.has('session')) return `select m.id, m.session_id, m.time_created, s.directory, json_extract(m.data,'$.modelID') as model, ${OC_TOKENS} from message m left join session s on s.id = m.session_id where json_extract(m.data,'$.role') = 'assistant'`;
|
|
471
|
+
return null;
|
|
472
|
+
});
|
|
473
|
+
return mergeEvents(rows.map((r) => opencodeRow(tool, r, r.directory || r.cwd || null)));
|
|
474
|
+
}
|
|
475
|
+
async function opencodeJsonFile(tool, file, sessionDir) {
|
|
476
|
+
const m = readJsonFile(file);
|
|
477
|
+
if (m?.role !== 'assistant' || !m.tokens) return [];
|
|
478
|
+
const sid = m.sessionID || path.basename(path.dirname(file));
|
|
479
|
+
let directory = m.path?.cwd || null;
|
|
480
|
+
if (!directory) for (const f of walk(sessionDir, (name) => name === `${sid}.json`)) { directory = readJsonFile(f)?.directory || null; break; }
|
|
481
|
+
const r = { id: m.id || path.basename(file, '.json'), session_id: sid, model: m.modelID, input: m.tokens.input, output: m.tokens.output, reasoning: m.tokens.reasoning, cache_read: m.tokens.cache?.read, cache_write: m.tokens.cache?.write, cost: m.cost, created: m.time?.created };
|
|
482
|
+
return mergeEvents([opencodeRow(tool, r, directory)]);
|
|
483
|
+
}
|
|
484
|
+
export async function readOpenCode(buckets, { cache = { files: {} }, tool = 'opencode', app = 'opencode' } = {}) {
|
|
485
|
+
const root = path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), app);
|
|
486
|
+
const dbs = [];
|
|
487
|
+
try { for (const e of fs.readdirSync(root)) if (/^(opencode|kilo)[\w-]*\.db$/.test(e)) dbs.push(path.join(root, e)); } catch { return { files: 0 }; }
|
|
488
|
+
const a = await readTool(buckets, tool, dbs, (f) => opencodeDbFile(tool, f), cache);
|
|
489
|
+
const sessionDir = path.join(root, 'storage', 'session');
|
|
490
|
+
const b = await readTool(buckets, tool, walk(path.join(root, 'storage', 'message'), (name) => name.endsWith('.json')), (f) => opencodeJsonFile(tool, f, sessionDir), cache);
|
|
491
|
+
return { files: a.files + b.files };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function emptyBucket() {
|
|
495
|
+
return { tokens: { input: 0, output: 0, cache_create: 0, cache_read: 0 }, models: new Map(), daily: new Map(),
|
|
496
|
+
dailyTools: new Map(), dayRows: new Map(), events: new Map(), sessions: new Set(), tools: new Set(), first: null, last: null };
|
|
497
|
+
}
|
|
498
|
+
function bucketFromEvents(events) {
|
|
499
|
+
const b = emptyBucket();
|
|
500
|
+
for (const e of mergeEvents(events)) {
|
|
501
|
+
b.events.set(e.id, e);
|
|
502
|
+
const total = e.input + e.output + e.cache_create + e.cache_read;
|
|
503
|
+
const date = localDate(e.at);
|
|
504
|
+
const rowKey = `${date}|${e.tool}|${e.model}`;
|
|
505
|
+
if (!b.dayRows.has(rowKey)) b.dayRows.set(rowKey, { date, tool: e.tool, model: e.model, input: 0, output: 0, cache_create: 0, cache_read: 0, cost: 0, unpriced: 0 });
|
|
506
|
+
const row = b.dayRows.get(rowKey);
|
|
507
|
+
for (const k of ['input', 'output', 'cache_create', 'cache_read', 'cost']) row[k] += e[k] || 0;
|
|
508
|
+
if (e.unpriced) row.unpriced += total;
|
|
509
|
+
for (const k of Object.keys(b.tokens)) b.tokens[k] += e[k];
|
|
510
|
+
if (!b.models.has(e.model)) b.models.set(e.model, zeroTokens());
|
|
511
|
+
for (const k of Object.keys(zeroTokens())) b.models.get(e.model)[k] += e[k] || 0;
|
|
512
|
+
b.daily.set(date, (b.daily.get(date) || 0) + total);
|
|
513
|
+
if (!b.dailyTools.has(date)) b.dailyTools.set(date, { claude: 0, gpt: 0, other: 0 });
|
|
514
|
+
b.dailyTools.get(date)[e.tool === 'claude-code' ? 'claude' : e.tool === 'codex' ? 'gpt' : 'other'] += total;
|
|
515
|
+
b.sessions.add(`${e.tool}:${e.session}`); b.tools.add(e.tool);
|
|
516
|
+
if (!b.first || date < b.first) b.first = date;
|
|
517
|
+
if (!b.last || date > b.last) b.last = date;
|
|
518
|
+
}
|
|
519
|
+
return b;
|
|
520
|
+
}
|
|
521
|
+
export function mergeBuckets(list) {
|
|
522
|
+
if (list.every((b) => b.events instanceof Map)) return bucketFromEvents(list.flatMap((b) => [...b.events.values()]));
|
|
523
|
+
// Also accept old buckets used by reports and callers with no response ledger.
|
|
524
|
+
const out = emptyBucket();
|
|
525
|
+
for (const b of list) {
|
|
526
|
+
for (const k of Object.keys(out.tokens)) out.tokens[k] += b.tokens[k];
|
|
527
|
+
for (const [m, t] of b.models) {
|
|
528
|
+
if (!out.models.has(m)) out.models.set(m, zeroTokens());
|
|
529
|
+
for (const k of Object.keys(zeroTokens())) out.models.get(m)[k] += t[k] || 0;
|
|
530
|
+
}
|
|
531
|
+
for (const [d, n] of b.daily) out.daily.set(d, (out.daily.get(d) || 0) + n);
|
|
532
|
+
for (const s of b.sessions) out.sessions.add(s);
|
|
533
|
+
for (const t of b.tools) out.tools.add(t);
|
|
534
|
+
if (b.first && (!out.first || b.first < out.first)) out.first = b.first;
|
|
535
|
+
if (b.last && (!out.last || b.last > out.last)) out.last = b.last;
|
|
536
|
+
}
|
|
537
|
+
return out;
|
|
538
|
+
}
|
|
539
|
+
// What leaves this computer: one summed row per day, tool and model. Never single responses.
|
|
540
|
+
export function usageDays(b) {
|
|
541
|
+
return [...(b.dayRows?.values() || [])]
|
|
542
|
+
.map((r) => ({ ...r, cost: Math.round(r.cost * 1e6) / 1e6 }))
|
|
543
|
+
.sort((a, b2) => a.date.localeCompare(b2.date) || a.tool.localeCompare(b2.tool) || a.model.localeCompare(b2.model));
|
|
544
|
+
}
|
|
545
|
+
export function bucketToAi(b, codexScope, priceDate, launchDate) {
|
|
546
|
+
const total = b.tokens.input + b.tokens.output + b.tokens.cache_create + b.tokens.cache_read;
|
|
547
|
+
const byModel = [...b.models.entries()]
|
|
548
|
+
.map(([model, t]) => ({ model, tokens: t.input + t.output + t.cache_create + t.cache_read, t }))
|
|
549
|
+
.filter((m) => m.tokens > 0)
|
|
550
|
+
.sort((x, y) => y.tokens - x.tokens);
|
|
551
|
+
let cost = 0;
|
|
552
|
+
let unpriced = 0;
|
|
553
|
+
const unpricedModels = [];
|
|
554
|
+
for (const m of byModel) {
|
|
555
|
+
const c = costOf(m.model, m.t);
|
|
556
|
+
if (c === null) { unpriced += m.tokens; unpricedModels.push(m.model); } else cost += c;
|
|
557
|
+
}
|
|
558
|
+
// Tools other than Claude Code and Codex appear in the split only when they used anything.
|
|
559
|
+
const split = (t) => (t ? { claude: t.claude, gpt: t.gpt, ...(t.other ? { other: t.other } : {}) } : {});
|
|
560
|
+
const daily = [...b.daily.entries()].sort(([a], [c]) => (a < c ? -1 : 1)).map(([date, tokens]) => ({ date, tokens, ...split(b.dailyTools?.get(date)) }));
|
|
561
|
+
let before = null;
|
|
562
|
+
let after = null;
|
|
563
|
+
if (launchDate) {
|
|
564
|
+
before = 0; after = 0;
|
|
565
|
+
for (const d of daily) { if (d.date < launchDate) before += d.tokens; else after += d.tokens; }
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
tools: [...b.tools].sort(),
|
|
569
|
+
tokens: { ...b.tokens, total },
|
|
570
|
+
...(b.dailyTools?.size ? { by_tool: split([...b.dailyTools.values()].reduce((s,d) => ({ claude: s.claude+d.claude, gpt: s.gpt+d.gpt, other: s.other+(d.other || 0) }), { claude: 0, gpt: 0, other: 0 })) } : {}),
|
|
571
|
+
by_model: byModel.map(({ model, tokens }) => ({ model, tokens })),
|
|
572
|
+
main_model: byModel[0]?.model || null,
|
|
573
|
+
daily_tokens: daily,
|
|
574
|
+
sessions: b.sessions.size,
|
|
575
|
+
first_session: b.first,
|
|
576
|
+
last_session: b.last,
|
|
577
|
+
cost_usd_estimate: Math.round(cost * 100) / 100,
|
|
578
|
+
cost_is_estimate: true,
|
|
579
|
+
unpriced_tokens: unpriced,
|
|
580
|
+
unpriced_models: unpricedModels,
|
|
581
|
+
price_table_date: priceDate,
|
|
582
|
+
before_launch_tokens: before,
|
|
583
|
+
after_launch_tokens: after,
|
|
584
|
+
codex_scope: codexScope,
|
|
585
|
+
};
|
|
586
|
+
}
|