whoburnedmore 0.9.10 → 0.9.14
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/dist/index.js +379 -138
- package/package.json +3 -4
package/dist/index.js
CHANGED
|
@@ -8,10 +8,10 @@ var __export = (target, all) => {
|
|
|
8
8
|
// src/index.ts
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
|
-
import { mkdirSync as
|
|
11
|
+
import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "node:fs";
|
|
12
12
|
import { createRequire as createRequire4 } from "node:module";
|
|
13
13
|
import { platform as platform3 } from "node:os";
|
|
14
|
-
import { join as
|
|
14
|
+
import { join as join11 } from "node:path";
|
|
15
15
|
import { createInterface } from "node:readline/promises";
|
|
16
16
|
import pc2 from "picocolors";
|
|
17
17
|
|
|
@@ -96,15 +96,15 @@ async function readJson(res) {
|
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
|
-
async function
|
|
99
|
+
async function send(method, path, body, token) {
|
|
100
100
|
const headers = { "Content-Type": "application/json" };
|
|
101
101
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
102
102
|
let res;
|
|
103
103
|
try {
|
|
104
104
|
res = await fetch(`${apiBase()}${path}`, {
|
|
105
|
-
method
|
|
105
|
+
method,
|
|
106
106
|
headers,
|
|
107
|
-
body: JSON.stringify(body),
|
|
107
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) },
|
|
108
108
|
// Bound the request so a slow/black-holing/hostile server can't hang the CLI
|
|
109
109
|
// — or the unattended 15-minute background sync — indefinitely.
|
|
110
110
|
signal: AbortSignal.timeout(3e4)
|
|
@@ -116,6 +116,9 @@ async function post(path, body, token) {
|
|
|
116
116
|
}
|
|
117
117
|
return { status: res.status, body: await readJson(res) };
|
|
118
118
|
}
|
|
119
|
+
async function post(path, body, token) {
|
|
120
|
+
return send("POST", path, body, token);
|
|
121
|
+
}
|
|
119
122
|
async function verifyUsage(token, payload) {
|
|
120
123
|
const { status, body } = await post("/v1/verify", payload, token);
|
|
121
124
|
if (status === 401) throw new UnauthorizedError();
|
|
@@ -127,18 +130,6 @@ async function verifyUsage(token, payload) {
|
|
|
127
130
|
}
|
|
128
131
|
return body;
|
|
129
132
|
}
|
|
130
|
-
var ANON_RETIRED_MESSAGE = "Anonymous mode has been retired. Run `npx whoburnedmore` and sign in, or `npx whoburnedmore link --token=\u2026` for a server/CI, to put your usage on the leaderboard.";
|
|
131
|
-
async function anonSubmit(anonKey, payload) {
|
|
132
|
-
const { status, body } = await post("/v1/anon/submit", { ...payload, anonKey });
|
|
133
|
-
if (status === 410) throw new Error(ANON_RETIRED_MESSAGE);
|
|
134
|
-
if (status !== 200) {
|
|
135
|
-
const err = body;
|
|
136
|
-
const details = err.details?.length ? `
|
|
137
|
-
- ${err.details.join("\n - ")}` : "";
|
|
138
|
-
throw new Error(`${err.error ?? `submit failed (HTTP ${status})`}${details}`);
|
|
139
|
-
}
|
|
140
|
-
return body;
|
|
141
|
-
}
|
|
142
133
|
var UnauthorizedError = class extends Error {
|
|
143
134
|
constructor(message = "your sign-in has expired") {
|
|
144
135
|
super(message);
|
|
@@ -207,31 +198,21 @@ async function submit(token, payload) {
|
|
|
207
198
|
}
|
|
208
199
|
return body;
|
|
209
200
|
}
|
|
210
|
-
function
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
async function anonVisibility(anonKey, listed) {
|
|
214
|
-
const { status, body } = await post(
|
|
215
|
-
"/v1/anon/visibility",
|
|
216
|
-
{ anonKey, listed }
|
|
217
|
-
);
|
|
218
|
-
if (status === 410) throw new Error(ANON_RETIRED_MESSAGE);
|
|
201
|
+
async function setVisibility(token, listed) {
|
|
202
|
+
const { status, body } = await post("/v1/cli/visibility", { listed }, token);
|
|
203
|
+
if (status === 401) throw new UnauthorizedError();
|
|
219
204
|
if (status !== 200) {
|
|
220
205
|
throw new Error(body.error ?? `failed (HTTP ${status})`);
|
|
221
206
|
}
|
|
207
|
+
return { listed: body.listed === true, eligible: body.eligible === true };
|
|
222
208
|
}
|
|
223
|
-
async function
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
body
|
|
228
|
-
signal: AbortSignal.timeout(3e4)
|
|
229
|
-
});
|
|
230
|
-
if (res.status === 410) throw new Error(ANON_RETIRED_MESSAGE);
|
|
231
|
-
if (res.status !== 200) {
|
|
232
|
-
const b = await res.json().catch(() => ({}));
|
|
233
|
-
throw new Error(b.error ?? `failed (HTTP ${res.status})`);
|
|
209
|
+
async function removeUsage(token) {
|
|
210
|
+
const { status, body } = await send("DELETE", "/v1/cli/usage", void 0, token);
|
|
211
|
+
if (status === 401) throw new UnauthorizedError();
|
|
212
|
+
if (status !== 200) {
|
|
213
|
+
throw new Error(body.error ?? `failed (HTTP ${status})`);
|
|
234
214
|
}
|
|
215
|
+
return { deletedDays: body.deletedDays ?? 0 };
|
|
235
216
|
}
|
|
236
217
|
async function redeemServerInstall(token, anonKey) {
|
|
237
218
|
const { status, body } = await post("/v1/server-install/redeem", { token, anonKey });
|
|
@@ -805,7 +786,7 @@ async function daemonLoop(deps) {
|
|
|
805
786
|
// src/collect.ts
|
|
806
787
|
import { execFile } from "node:child_process";
|
|
807
788
|
import { createRequire as createRequire3 } from "node:module";
|
|
808
|
-
import { dirname as
|
|
789
|
+
import { dirname as dirname5, join as join10 } from "node:path";
|
|
809
790
|
import { promisify } from "node:util";
|
|
810
791
|
|
|
811
792
|
// src/attribution.ts
|
|
@@ -1174,7 +1155,10 @@ function collectCursorViaTokscale(lookbackDays = LOOKBACK_DAYS) {
|
|
|
1174
1155
|
const bin = resolveTokscaleBin();
|
|
1175
1156
|
if (!bin) return [];
|
|
1176
1157
|
const today = /* @__PURE__ */ new Date();
|
|
1177
|
-
const day = (offset) =>
|
|
1158
|
+
const day = (offset) => {
|
|
1159
|
+
const d = new Date(today.getTime() - offset * 864e5);
|
|
1160
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
1161
|
+
};
|
|
1178
1162
|
if (mapTokscaleDay(day(0), runTokscaleDay(bin, day(0))).length === 0) {
|
|
1179
1163
|
if (mapTokscaleDay(day(1), runTokscaleDay(bin, day(1))).length === 0) {
|
|
1180
1164
|
return [];
|
|
@@ -1243,7 +1227,7 @@ function mapCursorEvents(events) {
|
|
|
1243
1227
|
const ms = Number(e.timestamp);
|
|
1244
1228
|
if (!tu || !Number.isFinite(ms)) continue;
|
|
1245
1229
|
const d = new Date(ms);
|
|
1246
|
-
const date = d.
|
|
1230
|
+
const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
1247
1231
|
const model = e.model || "cursor";
|
|
1248
1232
|
const input = num2(tu.inputTokens);
|
|
1249
1233
|
const output = num2(tu.outputTokens);
|
|
@@ -1333,9 +1317,9 @@ async function collectCursor() {
|
|
|
1333
1317
|
}
|
|
1334
1318
|
|
|
1335
1319
|
// src/native/claude.ts
|
|
1336
|
-
import { readdir, readFile } from "node:fs/promises";
|
|
1320
|
+
import { readdir, readFile as readFile2 } from "node:fs/promises";
|
|
1337
1321
|
import { homedir as homedir5 } from "node:os";
|
|
1338
|
-
import { join as
|
|
1322
|
+
import { join as join7 } from "node:path";
|
|
1339
1323
|
|
|
1340
1324
|
// ../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/external.js
|
|
1341
1325
|
var external_exports = {};
|
|
@@ -6780,7 +6764,8 @@ var ConnectorProvider = external_exports.enum([
|
|
|
6780
6764
|
"openai-api",
|
|
6781
6765
|
"openrouter",
|
|
6782
6766
|
"google-api",
|
|
6783
|
-
"cursor"
|
|
6767
|
+
"cursor",
|
|
6768
|
+
"azure-foundry"
|
|
6784
6769
|
]);
|
|
6785
6770
|
var UsageOrigin = external_exports.enum([
|
|
6786
6771
|
"cli",
|
|
@@ -6789,6 +6774,7 @@ var UsageOrigin = external_exports.enum([
|
|
|
6789
6774
|
"openrouter",
|
|
6790
6775
|
"google-api",
|
|
6791
6776
|
"cursor",
|
|
6777
|
+
"azure-foundry",
|
|
6792
6778
|
"import"
|
|
6793
6779
|
]);
|
|
6794
6780
|
var DailyUsageEntry = external_exports.object({
|
|
@@ -7081,6 +7067,76 @@ var OrgJoinInput = external_exports.object({
|
|
|
7081
7067
|
code: external_exports.string().min(1).max(64).optional()
|
|
7082
7068
|
});
|
|
7083
7069
|
|
|
7070
|
+
// src/native/file-cache.ts
|
|
7071
|
+
import { mkdirSync as mkdirSync3, renameSync as renameSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
7072
|
+
import { readFile, stat } from "node:fs/promises";
|
|
7073
|
+
import { dirname as dirname3, join as join6 } from "node:path";
|
|
7074
|
+
function nativeCachePath(reader, env = process.env) {
|
|
7075
|
+
const override = env.WHOBURNEDMORE_CONFIG_DIR?.trim();
|
|
7076
|
+
const dir = override || defaultConfigDir();
|
|
7077
|
+
return join6(dir, `native-cache-${reader}.json`);
|
|
7078
|
+
}
|
|
7079
|
+
async function loadCache(path, version) {
|
|
7080
|
+
try {
|
|
7081
|
+
const raw = await readFile(path, "utf8");
|
|
7082
|
+
const parsed = JSON.parse(raw);
|
|
7083
|
+
if (parsed && parsed.v === version && parsed.files && typeof parsed.files === "object") {
|
|
7084
|
+
return parsed.files;
|
|
7085
|
+
}
|
|
7086
|
+
} catch {
|
|
7087
|
+
}
|
|
7088
|
+
return {};
|
|
7089
|
+
}
|
|
7090
|
+
function saveCache(path, version, files) {
|
|
7091
|
+
try {
|
|
7092
|
+
mkdirSync3(dirname3(path), { recursive: true });
|
|
7093
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
7094
|
+
writeFileSync3(tmp, JSON.stringify({ v: version, files }));
|
|
7095
|
+
renameSync3(tmp, path);
|
|
7096
|
+
} catch {
|
|
7097
|
+
}
|
|
7098
|
+
}
|
|
7099
|
+
async function readFilesWithCache(opts) {
|
|
7100
|
+
const now = opts.now ?? Date.now;
|
|
7101
|
+
const cached = await loadCache(opts.cachePath, opts.version);
|
|
7102
|
+
const fresh = {};
|
|
7103
|
+
const itemsByFile = [];
|
|
7104
|
+
let filesRead = 0;
|
|
7105
|
+
for (const f of opts.files) {
|
|
7106
|
+
let size;
|
|
7107
|
+
let mtimeMs;
|
|
7108
|
+
try {
|
|
7109
|
+
const s = await stat(f);
|
|
7110
|
+
size = s.size;
|
|
7111
|
+
mtimeMs = s.mtimeMs;
|
|
7112
|
+
} catch {
|
|
7113
|
+
continue;
|
|
7114
|
+
}
|
|
7115
|
+
const hit = cached[f];
|
|
7116
|
+
if (hit && hit.size === size && hit.mtimeMs === mtimeMs) {
|
|
7117
|
+
fresh[f] = hit;
|
|
7118
|
+
itemsByFile.push(hit.items);
|
|
7119
|
+
continue;
|
|
7120
|
+
}
|
|
7121
|
+
if (now() > opts.deadline) {
|
|
7122
|
+
saveCache(opts.cachePath, opts.version, { ...cached, ...fresh });
|
|
7123
|
+
return { itemsByFile: null, filesRead, timedOut: true };
|
|
7124
|
+
}
|
|
7125
|
+
let content;
|
|
7126
|
+
try {
|
|
7127
|
+
content = await readFile(f, "utf8");
|
|
7128
|
+
} catch {
|
|
7129
|
+
continue;
|
|
7130
|
+
}
|
|
7131
|
+
const items = opts.parseFile(content, f);
|
|
7132
|
+
fresh[f] = { size, mtimeMs, items };
|
|
7133
|
+
itemsByFile.push(items);
|
|
7134
|
+
filesRead += 1;
|
|
7135
|
+
}
|
|
7136
|
+
saveCache(opts.cachePath, opts.version, fresh);
|
|
7137
|
+
return { itemsByFile, filesRead, timedOut: false };
|
|
7138
|
+
}
|
|
7139
|
+
|
|
7084
7140
|
// src/native/claude.ts
|
|
7085
7141
|
function num3(n) {
|
|
7086
7142
|
const v = Math.round(Number(n));
|
|
@@ -7186,10 +7242,10 @@ function finalizeClaudeEntries(acc) {
|
|
|
7186
7242
|
}
|
|
7187
7243
|
function resolveClaudeConfigRoots(env = process.env) {
|
|
7188
7244
|
const override = env.CLAUDE_CONFIG_DIR;
|
|
7189
|
-
return override ? override.split(",").map((s) => s.trim()).filter(Boolean) : [
|
|
7245
|
+
return override ? override.split(",").map((s) => s.trim()).filter(Boolean) : [join7(homedir5(), ".claude"), join7(homedir5(), ".config", "claude")];
|
|
7190
7246
|
}
|
|
7191
7247
|
function resolveClaudeProjectDirs(env = process.env) {
|
|
7192
|
-
return resolveClaudeConfigRoots(env).map((r) =>
|
|
7248
|
+
return resolveClaudeConfigRoots(env).map((r) => join7(r, "projects"));
|
|
7193
7249
|
}
|
|
7194
7250
|
async function listJsonl(dir) {
|
|
7195
7251
|
let dirents;
|
|
@@ -7200,7 +7256,7 @@ async function listJsonl(dir) {
|
|
|
7200
7256
|
}
|
|
7201
7257
|
const out = [];
|
|
7202
7258
|
for (const d of dirents) {
|
|
7203
|
-
const full =
|
|
7259
|
+
const full = join7(dir, d.name);
|
|
7204
7260
|
if (d.isDirectory()) {
|
|
7205
7261
|
out.push(...await listJsonl(full));
|
|
7206
7262
|
} else if (d.isFile() && d.name.endsWith(".jsonl")) {
|
|
@@ -7209,7 +7265,43 @@ async function listJsonl(dir) {
|
|
|
7209
7265
|
}
|
|
7210
7266
|
return out;
|
|
7211
7267
|
}
|
|
7212
|
-
var NATIVE_READ_BUDGET_MS =
|
|
7268
|
+
var NATIVE_READ_BUDGET_MS = 45e3;
|
|
7269
|
+
var CLAUDE_CACHE_VERSION = 1;
|
|
7270
|
+
function toCached(r) {
|
|
7271
|
+
return [
|
|
7272
|
+
r.key,
|
|
7273
|
+
r.hasRealId ? 1 : 0,
|
|
7274
|
+
r.date,
|
|
7275
|
+
r.model,
|
|
7276
|
+
r.inputTokens,
|
|
7277
|
+
r.outputTokens,
|
|
7278
|
+
r.cacheCreationTokens,
|
|
7279
|
+
r.cacheReadTokens
|
|
7280
|
+
];
|
|
7281
|
+
}
|
|
7282
|
+
function fromCached(t) {
|
|
7283
|
+
return {
|
|
7284
|
+
key: t[0],
|
|
7285
|
+
hasRealId: t[1] === 1,
|
|
7286
|
+
date: t[2],
|
|
7287
|
+
ts: 0,
|
|
7288
|
+
model: t[3],
|
|
7289
|
+
inputTokens: t[4],
|
|
7290
|
+
outputTokens: t[5],
|
|
7291
|
+
cacheCreationTokens: t[6],
|
|
7292
|
+
cacheReadTokens: t[7]
|
|
7293
|
+
};
|
|
7294
|
+
}
|
|
7295
|
+
function parseFileToCache(content, path) {
|
|
7296
|
+
const acc = /* @__PURE__ */ new Map();
|
|
7297
|
+
accumulateClaudeLines(acc, splitLines(content));
|
|
7298
|
+
let synIndex = 0;
|
|
7299
|
+
return [...acc.values()].map(
|
|
7300
|
+
(r) => toCached(
|
|
7301
|
+
r.hasRealId ? r : { ...r, key: `synthetic|${path}|${synIndex += 1}` }
|
|
7302
|
+
)
|
|
7303
|
+
);
|
|
7304
|
+
}
|
|
7213
7305
|
function* splitLines(content) {
|
|
7214
7306
|
let start = 0;
|
|
7215
7307
|
for (let i = 0; i < content.length; i++) {
|
|
@@ -7226,23 +7318,35 @@ async function collectClaudeNative(env = process.env, opts = {}) {
|
|
|
7226
7318
|
for (const dir of dirs) files.push(...await listJsonl(dir));
|
|
7227
7319
|
if (files.length === 0) return { entries: [], found: false, filesScanned: 0 };
|
|
7228
7320
|
const now = opts.now ?? Date.now;
|
|
7229
|
-
const
|
|
7321
|
+
const res = await readFilesWithCache({
|
|
7322
|
+
files,
|
|
7323
|
+
cachePath: opts.cachePath ?? nativeCachePath("claude", env),
|
|
7324
|
+
version: CLAUDE_CACHE_VERSION,
|
|
7325
|
+
parseFile: parseFileToCache,
|
|
7326
|
+
deadline: now() + (opts.budgetMs ?? NATIVE_READ_BUDGET_MS),
|
|
7327
|
+
now
|
|
7328
|
+
});
|
|
7329
|
+
if (!res.itemsByFile) {
|
|
7330
|
+
return {
|
|
7331
|
+
entries: [],
|
|
7332
|
+
found: false,
|
|
7333
|
+
filesScanned: res.filesRead,
|
|
7334
|
+
timedOut: true
|
|
7335
|
+
};
|
|
7336
|
+
}
|
|
7230
7337
|
const acc = /* @__PURE__ */ new Map();
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
7234
|
-
|
|
7338
|
+
for (const items of res.itemsByFile) {
|
|
7339
|
+
for (const t of items) {
|
|
7340
|
+
const r = fromCached(t);
|
|
7341
|
+
const prev = acc.get(r.key);
|
|
7342
|
+
if (!prev || reqTokens(r) > reqTokens(prev)) acc.set(r.key, r);
|
|
7235
7343
|
}
|
|
7236
|
-
let content;
|
|
7237
|
-
try {
|
|
7238
|
-
content = await readFile(f, "utf8");
|
|
7239
|
-
} catch {
|
|
7240
|
-
continue;
|
|
7241
|
-
}
|
|
7242
|
-
accumulateClaudeLines(acc, splitLines(content));
|
|
7243
|
-
scanned += 1;
|
|
7244
7344
|
}
|
|
7245
|
-
return {
|
|
7345
|
+
return {
|
|
7346
|
+
entries: finalizeClaudeEntries(acc),
|
|
7347
|
+
found: true,
|
|
7348
|
+
filesScanned: res.filesRead
|
|
7349
|
+
};
|
|
7246
7350
|
}
|
|
7247
7351
|
async function collectClaudeRequests(env = process.env, opts = {}) {
|
|
7248
7352
|
const dirs = resolveClaudeProjectDirs(env);
|
|
@@ -7258,7 +7362,7 @@ async function collectClaudeRequests(env = process.env, opts = {}) {
|
|
|
7258
7362
|
}
|
|
7259
7363
|
let content;
|
|
7260
7364
|
try {
|
|
7261
|
-
content = await
|
|
7365
|
+
content = await readFile2(f, "utf8");
|
|
7262
7366
|
} catch {
|
|
7263
7367
|
continue;
|
|
7264
7368
|
}
|
|
@@ -7268,9 +7372,9 @@ async function collectClaudeRequests(env = process.env, opts = {}) {
|
|
|
7268
7372
|
}
|
|
7269
7373
|
|
|
7270
7374
|
// src/native/codex.ts
|
|
7271
|
-
import { readdir as readdir2
|
|
7375
|
+
import { readdir as readdir2 } from "node:fs/promises";
|
|
7272
7376
|
import { homedir as homedir6 } from "node:os";
|
|
7273
|
-
import { join as
|
|
7377
|
+
import { join as join8 } from "node:path";
|
|
7274
7378
|
function num4(n) {
|
|
7275
7379
|
const v = Math.round(Number(n));
|
|
7276
7380
|
return Number.isFinite(v) && v > 0 ? v : 0;
|
|
@@ -7359,8 +7463,8 @@ function parseCodexRollout(lines) {
|
|
|
7359
7463
|
}
|
|
7360
7464
|
return out;
|
|
7361
7465
|
}
|
|
7362
|
-
function
|
|
7363
|
-
for (const s of
|
|
7466
|
+
function foldCodexSessions(acc, sessions) {
|
|
7467
|
+
for (const s of sessions) {
|
|
7364
7468
|
const k = `${s.date}|${s.model}`;
|
|
7365
7469
|
let b = acc.get(k);
|
|
7366
7470
|
if (!b) {
|
|
@@ -7402,8 +7506,8 @@ function finalizeCodexEntries(acc) {
|
|
|
7402
7506
|
return entries;
|
|
7403
7507
|
}
|
|
7404
7508
|
function resolveCodexSessionsDir(env = process.env) {
|
|
7405
|
-
const home = env.CODEX_HOME && env.CODEX_HOME.trim() ? env.CODEX_HOME.trim() :
|
|
7406
|
-
return
|
|
7509
|
+
const home = env.CODEX_HOME && env.CODEX_HOME.trim() ? env.CODEX_HOME.trim() : join8(homedir6(), ".codex");
|
|
7510
|
+
return join8(home, "sessions");
|
|
7407
7511
|
}
|
|
7408
7512
|
async function listJsonl2(dir) {
|
|
7409
7513
|
let dirents;
|
|
@@ -7414,7 +7518,7 @@ async function listJsonl2(dir) {
|
|
|
7414
7518
|
}
|
|
7415
7519
|
const out = [];
|
|
7416
7520
|
for (const d of dirents) {
|
|
7417
|
-
const full =
|
|
7521
|
+
const full = join8(dir, d.name);
|
|
7418
7522
|
if (d.isDirectory()) out.push(...await listJsonl2(full));
|
|
7419
7523
|
else if (d.isFile() && d.name.endsWith(".jsonl")) out.push(full);
|
|
7420
7524
|
}
|
|
@@ -7430,38 +7534,69 @@ function* splitLines2(content) {
|
|
|
7430
7534
|
}
|
|
7431
7535
|
if (start < content.length) yield content.slice(start);
|
|
7432
7536
|
}
|
|
7433
|
-
var NATIVE_READ_BUDGET_MS2 =
|
|
7537
|
+
var NATIVE_READ_BUDGET_MS2 = 45e3;
|
|
7538
|
+
var CODEX_CACHE_VERSION = 1;
|
|
7539
|
+
function toCachedSession(s) {
|
|
7540
|
+
return [
|
|
7541
|
+
s.date,
|
|
7542
|
+
s.model,
|
|
7543
|
+
s.inputTokens,
|
|
7544
|
+
s.outputTokens,
|
|
7545
|
+
s.cacheCreationTokens,
|
|
7546
|
+
s.cacheReadTokens,
|
|
7547
|
+
s.turnCount
|
|
7548
|
+
];
|
|
7549
|
+
}
|
|
7550
|
+
function fromCachedSession(t) {
|
|
7551
|
+
return {
|
|
7552
|
+
date: t[0],
|
|
7553
|
+
model: t[1],
|
|
7554
|
+
inputTokens: t[2],
|
|
7555
|
+
outputTokens: t[3],
|
|
7556
|
+
cacheCreationTokens: t[4],
|
|
7557
|
+
cacheReadTokens: t[5],
|
|
7558
|
+
turnCount: t[6]
|
|
7559
|
+
};
|
|
7560
|
+
}
|
|
7434
7561
|
async function collectCodexNative(env = process.env, opts = {}) {
|
|
7435
7562
|
const dir = resolveCodexSessionsDir(env);
|
|
7436
7563
|
const files = await listJsonl2(dir);
|
|
7437
7564
|
if (files.length === 0) return { entries: [], found: false, filesScanned: 0 };
|
|
7438
7565
|
const now = opts.now ?? Date.now;
|
|
7439
|
-
const
|
|
7566
|
+
const res = await readFilesWithCache({
|
|
7567
|
+
files,
|
|
7568
|
+
cachePath: opts.cachePath ?? nativeCachePath("codex", env),
|
|
7569
|
+
version: CODEX_CACHE_VERSION,
|
|
7570
|
+
parseFile: (content) => parseCodexRollout(splitLines2(content)).map(toCachedSession),
|
|
7571
|
+
deadline: now() + (opts.budgetMs ?? NATIVE_READ_BUDGET_MS2),
|
|
7572
|
+
now
|
|
7573
|
+
});
|
|
7574
|
+
if (!res.itemsByFile) {
|
|
7575
|
+
return {
|
|
7576
|
+
entries: [],
|
|
7577
|
+
found: false,
|
|
7578
|
+
filesScanned: res.filesRead,
|
|
7579
|
+
timedOut: true
|
|
7580
|
+
};
|
|
7581
|
+
}
|
|
7440
7582
|
const acc = /* @__PURE__ */ new Map();
|
|
7441
|
-
|
|
7442
|
-
|
|
7443
|
-
if (now() > deadline) {
|
|
7444
|
-
return { entries: [], found: false, filesScanned: scanned, timedOut: true };
|
|
7445
|
-
}
|
|
7446
|
-
let content;
|
|
7447
|
-
try {
|
|
7448
|
-
content = await readFile2(f, "utf8");
|
|
7449
|
-
} catch {
|
|
7450
|
-
continue;
|
|
7451
|
-
}
|
|
7452
|
-
accumulateCodexSession(acc, splitLines2(content));
|
|
7453
|
-
scanned += 1;
|
|
7583
|
+
for (const items of res.itemsByFile) {
|
|
7584
|
+
foldCodexSessions(acc, items.map(fromCachedSession));
|
|
7454
7585
|
}
|
|
7455
|
-
return {
|
|
7586
|
+
return {
|
|
7587
|
+
entries: finalizeCodexEntries(acc),
|
|
7588
|
+
found: true,
|
|
7589
|
+
filesScanned: res.filesRead
|
|
7590
|
+
};
|
|
7456
7591
|
}
|
|
7457
7592
|
|
|
7458
7593
|
// src/pricing-live.ts
|
|
7459
7594
|
import { mkdir, readFile as readFile3, rename, writeFile } from "node:fs/promises";
|
|
7460
|
-
import { dirname as
|
|
7595
|
+
import { dirname as dirname4, join as join9 } from "node:path";
|
|
7461
7596
|
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
7462
7597
|
var FETCH_TIMEOUT_MS = 5e3;
|
|
7463
7598
|
function pricingCachePath(dir = defaultConfigDir()) {
|
|
7464
|
-
return
|
|
7599
|
+
return join9(dir, "pricing-cache.json");
|
|
7465
7600
|
}
|
|
7466
7601
|
async function readCache(path) {
|
|
7467
7602
|
try {
|
|
@@ -7497,7 +7632,7 @@ async function loadLivePricing(env = process.env, now = Date.now, cachePath = pr
|
|
|
7497
7632
|
if (live) {
|
|
7498
7633
|
setLivePricing(live);
|
|
7499
7634
|
try {
|
|
7500
|
-
await mkdir(
|
|
7635
|
+
await mkdir(dirname4(path), { recursive: true });
|
|
7501
7636
|
const tmp = `${path}.${process.pid}.tmp`;
|
|
7502
7637
|
await writeFile(tmp, JSON.stringify({ fetchedAt: now(), table: live }));
|
|
7503
7638
|
await rename(tmp, path);
|
|
@@ -7652,7 +7787,7 @@ function resolveCcusageBin() {
|
|
|
7652
7787
|
const pkgPath = require3.resolve("ccusage/package.json");
|
|
7653
7788
|
const pkg = require3("ccusage/package.json");
|
|
7654
7789
|
const rel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.ccusage ?? "ccusage";
|
|
7655
|
-
const binPath =
|
|
7790
|
+
const binPath = join10(dirname5(pkgPath), rel);
|
|
7656
7791
|
if (/\.(c|m)?js$/.test(binPath)) {
|
|
7657
7792
|
return { cmd: process.execPath, prefixArgs: [binPath] };
|
|
7658
7793
|
}
|
|
@@ -8004,17 +8139,16 @@ function renderDashboardHtml(entries, generatedAt = /* @__PURE__ */ new Date(),
|
|
|
8004
8139
|
(p) => `<div class="tbcell"><div class="tblabel"><span class="dot" style="background:${p.color}"></span>${p.label}</div><div class="tbval">${esc(formatTokens(p.value))}</div><div class="tbpct">${Math.round(p.value / tbSum * 100)}%</div></div>`
|
|
8005
8140
|
).join("");
|
|
8006
8141
|
const connectCta = connect ? `
|
|
8007
|
-
<
|
|
8008
|
-
<input type="hidden" name="payload" value="${Buffer.from(JSON.stringify(connect.payload)).toString("base64")}">
|
|
8142
|
+
<div class="connect">
|
|
8009
8143
|
<div class="connect-row">
|
|
8010
8144
|
<div>
|
|
8011
|
-
<div class="connect-title">
|
|
8012
|
-
<div class="connect-sub">
|
|
8145
|
+
<div class="connect-title">Claim your spot on the public leaderboard</div>
|
|
8146
|
+
<div class="connect-sub">Run the command below in your terminal \u2014 it signs you in and puts these numbers on <a href="${esc(connect.webBaseUrl)}">whoburnedmore.com</a> under your handle. Nothing has left your machine yet.</div>
|
|
8013
8147
|
</div>
|
|
8014
|
-
<
|
|
8148
|
+
<code class="connect-cmd">npx whoburnedmore</code>
|
|
8015
8149
|
</div>
|
|
8016
|
-
<div class="connect-note">
|
|
8017
|
-
</
|
|
8150
|
+
<div class="connect-note">Only daily totals are submitted \u2014 never your prompts or code. Background sync keeps your page fresh afterwards.</div>
|
|
8151
|
+
</div>` : "";
|
|
8018
8152
|
return `<!doctype html>
|
|
8019
8153
|
<html lang="en">
|
|
8020
8154
|
<head>
|
|
@@ -8074,8 +8208,8 @@ function renderDashboardHtml(entries, generatedAt = /* @__PURE__ */ new Date(),
|
|
|
8074
8208
|
@media (min-width: 640px) { .connect-row { flex-direction: row; align-items: center; justify-content: space-between; } }
|
|
8075
8209
|
.connect-title { font-size: 16px; font-weight: 700; }
|
|
8076
8210
|
.connect-sub { color: #d6d3d1; font-size: 13px; margin-top: 4px; max-width: 60ch; }
|
|
8077
|
-
.connect
|
|
8078
|
-
.connect
|
|
8211
|
+
.connect-cmd { flex-shrink: 0; display: inline-block; border-radius: 10px; background: rgba(0,0,0,.35); border: 1px solid rgba(234,88,12,.5); color: #fed7aa; font-size: 14px; font-weight: 600; padding: 11px 18px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; user-select: all; }
|
|
8212
|
+
.connect-sub a { color: #fed7aa; }
|
|
8079
8213
|
.connect-note { color: #a8a29e; font-size: 12px; margin-top: 14px; padding-top: 12px; border-top: 1px solid rgba(234,88,12,.25); }
|
|
8080
8214
|
.connect-note code { color: #fed7aa; background: rgba(0,0,0,.25); padding: 1px 6px; border-radius: 5px; }
|
|
8081
8215
|
</style>
|
|
@@ -8145,20 +8279,24 @@ ${connectCta}
|
|
|
8145
8279
|
|
|
8146
8280
|
// src/publish.ts
|
|
8147
8281
|
async function publishLocal(payload, deps) {
|
|
8148
|
-
if (!await deps.confirm(" Publish this to the public leaderboard?")) {
|
|
8282
|
+
if (!await deps.confirm(" Publish this to the public leaderboard? (You'll sign in first.)")) {
|
|
8149
8283
|
deps.log(" Kept local. Nothing left your machine.");
|
|
8150
8284
|
return false;
|
|
8151
8285
|
}
|
|
8152
|
-
const
|
|
8153
|
-
|
|
8154
|
-
|
|
8155
|
-
|
|
8156
|
-
|
|
8286
|
+
const auth = await deps.signIn();
|
|
8287
|
+
if (!auth) {
|
|
8288
|
+
deps.log(" Sign-in didn't complete \u2014 kept local. Nothing left your machine.");
|
|
8289
|
+
return false;
|
|
8290
|
+
}
|
|
8291
|
+
const res = await deps.submit(auth.token, payload);
|
|
8292
|
+
deps.log(` Published \u2014 you're on the board: ${sanitizeServerText(res.profileUrl)}`);
|
|
8293
|
+
if (isTrustedWebUrl(res.profileUrl)) {
|
|
8294
|
+
deps.openBrowser(res.profileUrl);
|
|
8157
8295
|
} else {
|
|
8158
8296
|
deps.log(" (The server returned an unexpected address, so it was not auto-opened.)");
|
|
8159
8297
|
}
|
|
8160
8298
|
deps.log(
|
|
8161
|
-
"
|
|
8299
|
+
" Manage anytime: `npx whoburnedmore private` hides you, `remove` deletes your data."
|
|
8162
8300
|
);
|
|
8163
8301
|
return true;
|
|
8164
8302
|
}
|
|
@@ -8237,14 +8375,11 @@ async function confirm(question) {
|
|
|
8237
8375
|
}
|
|
8238
8376
|
function showLocalDashboard(payload) {
|
|
8239
8377
|
const dir = defaultConfigDir();
|
|
8240
|
-
|
|
8241
|
-
const file =
|
|
8242
|
-
|
|
8378
|
+
mkdirSync4(dir, { recursive: true });
|
|
8379
|
+
const file = join11(dir, "dashboard.html");
|
|
8380
|
+
writeFileSync4(
|
|
8243
8381
|
file,
|
|
8244
|
-
renderDashboardHtml(payload.entries, /* @__PURE__ */ new Date(), {
|
|
8245
|
-
payload,
|
|
8246
|
-
webBaseUrl: webBase()
|
|
8247
|
-
})
|
|
8382
|
+
renderDashboardHtml(payload.entries, /* @__PURE__ */ new Date(), { webBaseUrl: webBase() })
|
|
8248
8383
|
);
|
|
8249
8384
|
console.log();
|
|
8250
8385
|
console.log(` Local dashboard: ${pc2.cyan(`file://${file}`)}`);
|
|
@@ -8300,8 +8435,23 @@ async function run(flags) {
|
|
|
8300
8435
|
if (!flags.quiet && process.stdin.isTTY) {
|
|
8301
8436
|
await publishLocal(payload, {
|
|
8302
8437
|
confirm,
|
|
8303
|
-
|
|
8304
|
-
|
|
8438
|
+
signIn: ensureSignedIn,
|
|
8439
|
+
submit: async (token, p) => {
|
|
8440
|
+
const result = await submit(token, p);
|
|
8441
|
+
try {
|
|
8442
|
+
recordSync();
|
|
8443
|
+
} catch {
|
|
8444
|
+
}
|
|
8445
|
+
try {
|
|
8446
|
+
const cfgNow = loadConfig();
|
|
8447
|
+
if (!cfgNow?.deviceBoundAt) {
|
|
8448
|
+
const key = ensureAnonKey();
|
|
8449
|
+
if (await bindDeviceKey(token, key)) recordDeviceBound();
|
|
8450
|
+
}
|
|
8451
|
+
} catch {
|
|
8452
|
+
}
|
|
8453
|
+
return result;
|
|
8454
|
+
},
|
|
8305
8455
|
openBrowser,
|
|
8306
8456
|
log: (line) => console.log(pc2.dim(line))
|
|
8307
8457
|
});
|
|
@@ -8478,7 +8628,13 @@ async function submitSignedIn(token, payload, flags, interactive) {
|
|
|
8478
8628
|
afterSubmitChores(flags);
|
|
8479
8629
|
}
|
|
8480
8630
|
function afterSubmitChores(flags) {
|
|
8481
|
-
if (flags.quiet)
|
|
8631
|
+
if (flags.quiet) {
|
|
8632
|
+
try {
|
|
8633
|
+
if (autoSyncInstalled()) reconcileAutoSync();
|
|
8634
|
+
} catch {
|
|
8635
|
+
}
|
|
8636
|
+
return;
|
|
8637
|
+
}
|
|
8482
8638
|
try {
|
|
8483
8639
|
reconcileAutoSync();
|
|
8484
8640
|
} catch {
|
|
@@ -8566,6 +8722,11 @@ async function runDaemon() {
|
|
|
8566
8722
|
runOnce: async () => {
|
|
8567
8723
|
rotateLogIfLarge();
|
|
8568
8724
|
await run({ dryRun: false, noSubmit: false, local: false, quiet: true });
|
|
8725
|
+
if (!loadConfig()?.cliToken) {
|
|
8726
|
+
throw new Error(
|
|
8727
|
+
"not linked \u2014 nothing submitted (run `npx whoburnedmore link --token=\u2026` from your signed-in profile first)"
|
|
8728
|
+
);
|
|
8729
|
+
}
|
|
8569
8730
|
}
|
|
8570
8731
|
});
|
|
8571
8732
|
console.log();
|
|
@@ -8667,6 +8828,102 @@ async function runVerify() {
|
|
|
8667
8828
|
);
|
|
8668
8829
|
}
|
|
8669
8830
|
}
|
|
8831
|
+
async function requireSignedIn(commandName) {
|
|
8832
|
+
const cfg = loadConfig();
|
|
8833
|
+
if (cfg?.cliToken) return { token: cfg.cliToken, handle: cfg.handle ?? "" };
|
|
8834
|
+
const healed = await refreshCliTokenFromConfig();
|
|
8835
|
+
if (healed) {
|
|
8836
|
+
saveAuth(void 0, { cliToken: healed.token, handle: healed.handle });
|
|
8837
|
+
return healed;
|
|
8838
|
+
}
|
|
8839
|
+
if (process.stdout.isTTY) return ensureSignedIn();
|
|
8840
|
+
console.log(
|
|
8841
|
+
pc2.yellow(
|
|
8842
|
+
` Sign in first \u2014 run \`npx whoburnedmore ${commandName}\` in an interactive terminal.`
|
|
8843
|
+
)
|
|
8844
|
+
);
|
|
8845
|
+
return null;
|
|
8846
|
+
}
|
|
8847
|
+
async function runVisibility(listed) {
|
|
8848
|
+
const auth = await requireSignedIn(listed ? "public" : "private");
|
|
8849
|
+
if (!auth) return;
|
|
8850
|
+
let res;
|
|
8851
|
+
try {
|
|
8852
|
+
res = await setVisibility(auth.token, listed);
|
|
8853
|
+
} catch (err) {
|
|
8854
|
+
if (err instanceof UnauthorizedError) {
|
|
8855
|
+
clearAuth();
|
|
8856
|
+
console.log(
|
|
8857
|
+
pc2.yellow(
|
|
8858
|
+
` Your sign-in expired \u2014 run \`npx whoburnedmore ${listed ? "public" : "private"}\` again to retry.`
|
|
8859
|
+
)
|
|
8860
|
+
);
|
|
8861
|
+
return;
|
|
8862
|
+
}
|
|
8863
|
+
throw err;
|
|
8864
|
+
}
|
|
8865
|
+
if (!listed) {
|
|
8866
|
+
console.log(" \u2713 You're off the public leaderboard. `npx whoburnedmore public` puts you back.");
|
|
8867
|
+
return;
|
|
8868
|
+
}
|
|
8869
|
+
if (res.listed) {
|
|
8870
|
+
console.log(" \u2713 You're back on the public leaderboard.");
|
|
8871
|
+
} else if (!res.eligible) {
|
|
8872
|
+
console.log(
|
|
8873
|
+
pc2.yellow(" You're signed in, but not on the leaderboard yet \u2014 it lists accounts with a social handle.")
|
|
8874
|
+
);
|
|
8875
|
+
console.log(
|
|
8876
|
+
pc2.dim(" Add your X, GitHub or Instagram on your profile at whoburnedmore.com, then you're on.")
|
|
8877
|
+
);
|
|
8878
|
+
} else {
|
|
8879
|
+
console.log(" \u2713 Leaderboard listing is on.");
|
|
8880
|
+
}
|
|
8881
|
+
}
|
|
8882
|
+
async function runRemove() {
|
|
8883
|
+
if (!process.stdin.isTTY) {
|
|
8884
|
+
console.log(
|
|
8885
|
+
pc2.yellow(" `remove` deletes data, so it needs an interactive terminal to confirm.")
|
|
8886
|
+
);
|
|
8887
|
+
console.log(pc2.dim(" Run `npx whoburnedmore remove` in a real terminal."));
|
|
8888
|
+
process.exitCode = 1;
|
|
8889
|
+
return;
|
|
8890
|
+
}
|
|
8891
|
+
const auth = await requireSignedIn("remove");
|
|
8892
|
+
if (!auth) return;
|
|
8893
|
+
const who = auth.handle ? `@${sanitizeServerText(auth.handle)}` : "your account";
|
|
8894
|
+
console.log(
|
|
8895
|
+
` This deletes ALL usage data stored for ${who} on whoburnedmore.com and turns off this machine's background sync.`
|
|
8896
|
+
);
|
|
8897
|
+
console.log(
|
|
8898
|
+
pc2.dim(" Your local logs are untouched, and the account itself survives (delete it from your dashboard settings on the web).")
|
|
8899
|
+
);
|
|
8900
|
+
if (!await confirm(" Delete your usage data?")) {
|
|
8901
|
+
console.log(pc2.dim(" Cancelled \u2014 nothing was deleted."));
|
|
8902
|
+
return;
|
|
8903
|
+
}
|
|
8904
|
+
let res;
|
|
8905
|
+
try {
|
|
8906
|
+
res = await removeUsage(auth.token);
|
|
8907
|
+
} catch (err) {
|
|
8908
|
+
if (err instanceof UnauthorizedError) {
|
|
8909
|
+
clearAuth();
|
|
8910
|
+
console.log(
|
|
8911
|
+
pc2.yellow(" Your sign-in expired \u2014 run `npx whoburnedmore remove` again to retry.")
|
|
8912
|
+
);
|
|
8913
|
+
return;
|
|
8914
|
+
}
|
|
8915
|
+
throw err;
|
|
8916
|
+
}
|
|
8917
|
+
try {
|
|
8918
|
+
uninstallAutoSync();
|
|
8919
|
+
} catch {
|
|
8920
|
+
}
|
|
8921
|
+
clearAuth();
|
|
8922
|
+
console.log(
|
|
8923
|
+
pc2.green(` \u2713 Deleted your usage data (${res.deletedDays} day${res.deletedDays === 1 ? "" : "s"}) and turned off background sync.`)
|
|
8924
|
+
);
|
|
8925
|
+
console.log(pc2.dim(" Run `npx whoburnedmore` anytime to start fresh."));
|
|
8926
|
+
}
|
|
8670
8927
|
async function main() {
|
|
8671
8928
|
const major = Number(process.versions.node.split(".")[0]);
|
|
8672
8929
|
if (major < 20) {
|
|
@@ -8711,28 +8968,12 @@ async function main() {
|
|
|
8711
8968
|
break;
|
|
8712
8969
|
}
|
|
8713
8970
|
case "private":
|
|
8714
|
-
case "public":
|
|
8715
|
-
|
|
8716
|
-
if (!cfg?.anonKey) {
|
|
8717
|
-
console.log(" No anonymous dashboard on this machine \u2014 run `npx whoburnedmore` first.");
|
|
8718
|
-
break;
|
|
8719
|
-
}
|
|
8720
|
-
await anonVisibility(cfg.anonKey, command === "public");
|
|
8721
|
-
console.log(
|
|
8722
|
-
command === "public" ? " Your dashboard is public on the leaderboard again." : " Your dashboard is now private \u2014 off the public leaderboard."
|
|
8723
|
-
);
|
|
8971
|
+
case "public":
|
|
8972
|
+
await runVisibility(command === "public");
|
|
8724
8973
|
break;
|
|
8725
|
-
|
|
8726
|
-
|
|
8727
|
-
const cfg = loadConfig();
|
|
8728
|
-
if (!cfg?.anonKey) {
|
|
8729
|
-
console.log(" No anonymous dashboard on this machine \u2014 nothing to remove.");
|
|
8730
|
-
break;
|
|
8731
|
-
}
|
|
8732
|
-
await anonRemove(cfg.anonKey);
|
|
8733
|
-
console.log(" Removed your anonymous dashboard and all its data.");
|
|
8974
|
+
case "remove":
|
|
8975
|
+
await runRemove();
|
|
8734
8976
|
break;
|
|
8735
|
-
}
|
|
8736
8977
|
case "install-sync":
|
|
8737
8978
|
console.log(` ${installAutoSync()}`);
|
|
8738
8979
|
break;
|
|
@@ -8764,9 +9005,9 @@ function printHelp() {
|
|
|
8764
9005
|
npx whoburnedmore --no-submit collect locally, send nothing (no dashboard)
|
|
8765
9006
|
npx whoburnedmore link --token=TOKEN link this server/VM to your signed-in account
|
|
8766
9007
|
npx whoburnedmore daemon keep syncing in the foreground (VMs/containers with no cron)
|
|
8767
|
-
npx whoburnedmore private
|
|
8768
|
-
npx whoburnedmore public put
|
|
8769
|
-
npx whoburnedmore remove delete your
|
|
9008
|
+
npx whoburnedmore private take yourself off the public leaderboard
|
|
9009
|
+
npx whoburnedmore public put yourself back on it
|
|
9010
|
+
npx whoburnedmore remove delete your usage data and stop background sync
|
|
8770
9011
|
npx whoburnedmore verify delisted? re-verify your usage to get back on (sends a detailed breakdown)
|
|
8771
9012
|
npx whoburnedmore status check background-sync health (last sync, staleness)
|
|
8772
9013
|
npx whoburnedmore uninstall-sync turn off the background sync
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "whoburnedmore",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.14",
|
|
4
4
|
"description": "Find out who burned more — submit your AI coding-agent token usage to the public leaderboard at whoburnedmore.com",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -38,11 +38,10 @@
|
|
|
38
38
|
"homepage": "https://whoburnedmore.com",
|
|
39
39
|
"repository": {
|
|
40
40
|
"type": "git",
|
|
41
|
-
"url": "git+https://github.com/
|
|
42
|
-
"directory": "packages/cli"
|
|
41
|
+
"url": "git+https://github.com/arhxam/whoburnedmore.git"
|
|
43
42
|
},
|
|
44
43
|
"bugs": {
|
|
45
|
-
"url": "https://github.com/
|
|
44
|
+
"url": "https://github.com/arhxam/whoburnedmore/issues"
|
|
46
45
|
},
|
|
47
46
|
"keywords": [
|
|
48
47
|
"ai",
|