slopmeter 0.4.1 → 0.5.1
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 +20 -2
- package/dist/cli.js +826 -45
- package/dist/cli.js.map +1 -1
- package/package.json +7 -3
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { mkdirSync, writeFileSync } from "fs";
|
|
5
|
-
import { dirname, extname, resolve as
|
|
5
|
+
import { dirname, extname, resolve as resolve6 } from "path";
|
|
6
6
|
import { parseArgs } from "util";
|
|
7
7
|
import ora from "ora";
|
|
8
8
|
import ow from "ow";
|
|
@@ -5715,6 +5715,35 @@ var heatmapThemes = {
|
|
|
5715
5715
|
]
|
|
5716
5716
|
}
|
|
5717
5717
|
},
|
|
5718
|
+
cursor: {
|
|
5719
|
+
title: "Cursor",
|
|
5720
|
+
colors: {
|
|
5721
|
+
light: [
|
|
5722
|
+
"#fff7ed",
|
|
5723
|
+
// orange-50
|
|
5724
|
+
"#fed7aa",
|
|
5725
|
+
// orange-200
|
|
5726
|
+
"#fdba74",
|
|
5727
|
+
// orange-300
|
|
5728
|
+
"#f97316",
|
|
5729
|
+
// orange-500
|
|
5730
|
+
"#9a3412"
|
|
5731
|
+
// orange-800
|
|
5732
|
+
],
|
|
5733
|
+
dark: [
|
|
5734
|
+
"#431407",
|
|
5735
|
+
// orange-950
|
|
5736
|
+
"#9a3412",
|
|
5737
|
+
// orange-800
|
|
5738
|
+
"#c2410c",
|
|
5739
|
+
// orange-700
|
|
5740
|
+
"#f97316",
|
|
5741
|
+
// orange-500
|
|
5742
|
+
"#fdba74"
|
|
5743
|
+
// orange-300
|
|
5744
|
+
]
|
|
5745
|
+
}
|
|
5746
|
+
},
|
|
5718
5747
|
opencode: {
|
|
5719
5748
|
title: "Open Code",
|
|
5720
5749
|
colors: {
|
|
@@ -5744,8 +5773,37 @@ var heatmapThemes = {
|
|
|
5744
5773
|
]
|
|
5745
5774
|
}
|
|
5746
5775
|
},
|
|
5776
|
+
pi: {
|
|
5777
|
+
title: "Pi Coding Agent",
|
|
5778
|
+
colors: {
|
|
5779
|
+
light: [
|
|
5780
|
+
"#ecfdf5",
|
|
5781
|
+
// emerald-50
|
|
5782
|
+
"#a7f3d0",
|
|
5783
|
+
// emerald-200
|
|
5784
|
+
"#6ee7b7",
|
|
5785
|
+
// emerald-300
|
|
5786
|
+
"#10b981",
|
|
5787
|
+
// emerald-500
|
|
5788
|
+
"#047857"
|
|
5789
|
+
// emerald-700
|
|
5790
|
+
],
|
|
5791
|
+
dark: [
|
|
5792
|
+
"#022c22",
|
|
5793
|
+
// emerald-950
|
|
5794
|
+
"#065f46",
|
|
5795
|
+
// emerald-800
|
|
5796
|
+
"#059669",
|
|
5797
|
+
// emerald-600
|
|
5798
|
+
"#34d399",
|
|
5799
|
+
// emerald-400
|
|
5800
|
+
"#a7f3d0"
|
|
5801
|
+
// emerald-200
|
|
5802
|
+
]
|
|
5803
|
+
}
|
|
5804
|
+
},
|
|
5747
5805
|
all: {
|
|
5748
|
-
title: "Codex / Claude Code / Open Code",
|
|
5806
|
+
title: "Codex / Claude Code / Cursor / Open Code / Pi Coding Agent",
|
|
5749
5807
|
titleCaption: "Total usage from",
|
|
5750
5808
|
colors: {
|
|
5751
5809
|
light: [
|
|
@@ -5971,7 +6029,6 @@ function drawHeatmapSection(svg, {
|
|
|
5971
6029
|
totalTokens += row.total;
|
|
5972
6030
|
}
|
|
5973
6031
|
const topMetricGap = 120;
|
|
5974
|
-
const headerLast30DaysX = rightEdge - topMetricGap * 3;
|
|
5975
6032
|
const headerInputX = rightEdge - topMetricGap * 2;
|
|
5976
6033
|
const headerOutputX = rightEdge - topMetricGap;
|
|
5977
6034
|
const totalTokensLabel = formatTokenTotal(totalTokens);
|
|
@@ -6485,6 +6542,9 @@ function getClaudeHistoryFiles() {
|
|
|
6485
6542
|
}
|
|
6486
6543
|
return files;
|
|
6487
6544
|
}
|
|
6545
|
+
function isClaudeAvailable() {
|
|
6546
|
+
return getClaudeProjectDirs().length > 0 || getClaudeStatsCacheFiles().length > 0 || getClaudeHistoryFiles().length > 0;
|
|
6547
|
+
}
|
|
6488
6548
|
async function loadClaudeStatsCacheRows(startDate, endDate, coveredDates, totals, modelTotals, recentModelTotals, recentStart) {
|
|
6489
6549
|
const statsCacheFiles = getClaudeStatsCacheFiles();
|
|
6490
6550
|
for (const file of statsCacheFiles) {
|
|
@@ -6618,6 +6678,7 @@ async function loadClaudeRows(startDate, endDate) {
|
|
|
6618
6678
|
}
|
|
6619
6679
|
|
|
6620
6680
|
// src/lib/codex.ts
|
|
6681
|
+
import { existsSync as existsSync2 } from "fs";
|
|
6621
6682
|
import { homedir as homedir2 } from "os";
|
|
6622
6683
|
import { join as join3, resolve as resolve2 } from "path";
|
|
6623
6684
|
var CLASSIFICATION_PREFIX_BYTES = 32 * 1024;
|
|
@@ -6638,6 +6699,15 @@ function normalizeCodexUsage(value) {
|
|
|
6638
6699
|
total_tokens: total > 0 ? total : input + output
|
|
6639
6700
|
};
|
|
6640
6701
|
}
|
|
6702
|
+
function addCodexUsage(base, delta) {
|
|
6703
|
+
return {
|
|
6704
|
+
input_tokens: (base?.input_tokens ?? 0) + delta.input_tokens,
|
|
6705
|
+
cached_input_tokens: (base?.cached_input_tokens ?? 0) + delta.cached_input_tokens,
|
|
6706
|
+
output_tokens: (base?.output_tokens ?? 0) + delta.output_tokens,
|
|
6707
|
+
reasoning_output_tokens: (base?.reasoning_output_tokens ?? 0) + delta.reasoning_output_tokens,
|
|
6708
|
+
total_tokens: (base?.total_tokens ?? 0) + delta.total_tokens
|
|
6709
|
+
};
|
|
6710
|
+
}
|
|
6641
6711
|
function subtractCodexUsage(current, previous) {
|
|
6642
6712
|
return {
|
|
6643
6713
|
input_tokens: Math.max(
|
|
@@ -6662,6 +6732,12 @@ function subtractCodexUsage(current, previous) {
|
|
|
6662
6732
|
)
|
|
6663
6733
|
};
|
|
6664
6734
|
}
|
|
6735
|
+
function didCodexTotalsRollback(current, previous) {
|
|
6736
|
+
if (!previous) {
|
|
6737
|
+
return false;
|
|
6738
|
+
}
|
|
6739
|
+
return current.input_tokens < previous.input_tokens || current.cached_input_tokens < previous.cached_input_tokens || current.output_tokens < previous.output_tokens || current.reasoning_output_tokens < previous.reasoning_output_tokens || current.total_tokens < previous.total_tokens;
|
|
6740
|
+
}
|
|
6665
6741
|
function asNonEmptyString(value) {
|
|
6666
6742
|
const trimmed = value?.trim();
|
|
6667
6743
|
return trimmed === "" ? void 0 : trimmed;
|
|
@@ -6688,10 +6764,16 @@ function extractCodexModel(payload) {
|
|
|
6688
6764
|
}
|
|
6689
6765
|
return void 0;
|
|
6690
6766
|
}
|
|
6767
|
+
function getCodexHome() {
|
|
6768
|
+
return process.env.CODEX_HOME?.trim() ? resolve2(process.env.CODEX_HOME) : join3(homedir2(), ".codex");
|
|
6769
|
+
}
|
|
6691
6770
|
async function getCodexFiles() {
|
|
6692
|
-
const codexHome =
|
|
6771
|
+
const codexHome = getCodexHome();
|
|
6693
6772
|
return listFilesRecursive(join3(codexHome, "sessions"), ".jsonl");
|
|
6694
6773
|
}
|
|
6774
|
+
function isCodexAvailable() {
|
|
6775
|
+
return existsSync2(join3(getCodexHome(), "sessions"));
|
|
6776
|
+
}
|
|
6695
6777
|
function readJsonString(source, start) {
|
|
6696
6778
|
if (source[start] !== '"') {
|
|
6697
6779
|
return null;
|
|
@@ -6930,12 +7012,15 @@ async function processCodexFile(filePath, start, end, maxRecordBytes) {
|
|
|
6930
7012
|
const info = entry.payload?.info;
|
|
6931
7013
|
const lastUsage = normalizeCodexUsage(info?.last_token_usage);
|
|
6932
7014
|
const totalUsage = normalizeCodexUsage(info?.total_token_usage);
|
|
6933
|
-
let rawUsage =
|
|
6934
|
-
if (!rawUsage && totalUsage) {
|
|
6935
|
-
rawUsage = subtractCodexUsage(totalUsage, previousTotals);
|
|
6936
|
-
}
|
|
7015
|
+
let rawUsage = null;
|
|
6937
7016
|
if (totalUsage) {
|
|
7017
|
+
rawUsage = didCodexTotalsRollback(totalUsage, previousTotals) ? lastUsage ?? totalUsage : subtractCodexUsage(totalUsage, previousTotals);
|
|
6938
7018
|
previousTotals = totalUsage;
|
|
7019
|
+
} else {
|
|
7020
|
+
rawUsage = lastUsage;
|
|
7021
|
+
if (rawUsage) {
|
|
7022
|
+
previousTotals = addCodexUsage(previousTotals, rawUsage);
|
|
7023
|
+
}
|
|
6939
7024
|
}
|
|
6940
7025
|
if (!rawUsage) {
|
|
6941
7026
|
continue;
|
|
@@ -7004,22 +7089,509 @@ async function loadCodexRows(start, end, warnings = []) {
|
|
|
7004
7089
|
`Skipped ${skippedOversizedIrrelevantRecords} oversized irrelevant Codex record(s) across ${skippedFiles} file(s); usage totals exclude those records. Relevant oversized records fail the file. Override ${MAX_JSONL_RECORD_BYTES_ENV} to raise the cap.`
|
|
7005
7090
|
);
|
|
7006
7091
|
}
|
|
7007
|
-
return createUsageSummary(
|
|
7092
|
+
return createUsageSummary(
|
|
7093
|
+
"codex",
|
|
7094
|
+
totals,
|
|
7095
|
+
modelTotals,
|
|
7096
|
+
recentModelTotals,
|
|
7097
|
+
end
|
|
7098
|
+
);
|
|
7099
|
+
}
|
|
7100
|
+
|
|
7101
|
+
// src/lib/cursor.ts
|
|
7102
|
+
import { existsSync as existsSync3 } from "fs";
|
|
7103
|
+
import { copyFile, mkdtemp, rm } from "fs/promises";
|
|
7104
|
+
import { homedir as homedir3, tmpdir } from "os";
|
|
7105
|
+
import { join as join4, resolve as resolve3 } from "path";
|
|
7106
|
+
import Database from "better-sqlite3";
|
|
7107
|
+
var CURSOR_CONFIG_DIR_ENV = "CURSOR_CONFIG_DIR";
|
|
7108
|
+
var CURSOR_STATE_DB_PATH_ENV = "CURSOR_STATE_DB_PATH";
|
|
7109
|
+
var CURSOR_WEB_BASE_URL_ENV = "CURSOR_WEB_BASE_URL";
|
|
7110
|
+
var CURSOR_STATE_DB_RELATIVE_PATH = join4(
|
|
7111
|
+
"User",
|
|
7112
|
+
"globalStorage",
|
|
7113
|
+
"state.vscdb"
|
|
7114
|
+
);
|
|
7115
|
+
var CURSOR_SESSION_COOKIE_NAME = "WorkosCursorSessionToken";
|
|
7116
|
+
function getCursorDefaultStateDbPath() {
|
|
7117
|
+
if (process.platform === "darwin") {
|
|
7118
|
+
return join4(
|
|
7119
|
+
homedir3(),
|
|
7120
|
+
"Library",
|
|
7121
|
+
"Application Support",
|
|
7122
|
+
"Cursor",
|
|
7123
|
+
CURSOR_STATE_DB_RELATIVE_PATH
|
|
7124
|
+
);
|
|
7125
|
+
}
|
|
7126
|
+
if (process.platform === "win32") {
|
|
7127
|
+
const appData = process.env.APPDATA?.trim() || join4(homedir3(), "AppData", "Roaming");
|
|
7128
|
+
return join4(appData, "Cursor", CURSOR_STATE_DB_RELATIVE_PATH);
|
|
7129
|
+
}
|
|
7130
|
+
const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim() || join4(homedir3(), ".config");
|
|
7131
|
+
return join4(xdgConfigHome, "Cursor", CURSOR_STATE_DB_RELATIVE_PATH);
|
|
7132
|
+
}
|
|
7133
|
+
function getCursorStateDbCandidates() {
|
|
7134
|
+
const explicitDbPath = process.env[CURSOR_STATE_DB_PATH_ENV]?.trim();
|
|
7135
|
+
if (explicitDbPath) {
|
|
7136
|
+
return [resolve3(explicitDbPath)];
|
|
7137
|
+
}
|
|
7138
|
+
const configuredDirs = process.env[CURSOR_CONFIG_DIR_ENV]?.trim();
|
|
7139
|
+
if (!configuredDirs) {
|
|
7140
|
+
return [getCursorDefaultStateDbPath()];
|
|
7141
|
+
}
|
|
7142
|
+
return configuredDirs.split(",").map((value) => value.trim()).filter((value) => value !== "").map((value) => {
|
|
7143
|
+
const resolved = resolve3(value);
|
|
7144
|
+
return resolved.endsWith(".vscdb") ? resolved : join4(resolved, CURSOR_STATE_DB_RELATIVE_PATH);
|
|
7145
|
+
});
|
|
7146
|
+
}
|
|
7147
|
+
function getCursorStateDbPath() {
|
|
7148
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7149
|
+
for (const candidate of getCursorStateDbCandidates()) {
|
|
7150
|
+
if (!seen.has(candidate) && existsSync3(candidate)) {
|
|
7151
|
+
return candidate;
|
|
7152
|
+
}
|
|
7153
|
+
seen.add(candidate);
|
|
7154
|
+
}
|
|
7155
|
+
return null;
|
|
7156
|
+
}
|
|
7157
|
+
function normalizeCursorDbValue(value) {
|
|
7158
|
+
if (typeof value === "string") {
|
|
7159
|
+
const trimmed = value.trim();
|
|
7160
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
7161
|
+
}
|
|
7162
|
+
if (Buffer.isBuffer(value)) {
|
|
7163
|
+
const trimmed = value.toString("utf8").trim();
|
|
7164
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
7165
|
+
}
|
|
7166
|
+
return void 0;
|
|
7167
|
+
}
|
|
7168
|
+
function readCursorAuthStateFromDatabase(databasePath) {
|
|
7169
|
+
const database = new Database(databasePath, {
|
|
7170
|
+
readonly: true,
|
|
7171
|
+
fileMustExist: true
|
|
7172
|
+
});
|
|
7173
|
+
try {
|
|
7174
|
+
const query = database.prepare(
|
|
7175
|
+
"SELECT value FROM ItemTable WHERE key = ? LIMIT 1"
|
|
7176
|
+
);
|
|
7177
|
+
const accessRow = query.get("cursorAuth/accessToken");
|
|
7178
|
+
const refreshRow = query.get("cursorAuth/refreshToken");
|
|
7179
|
+
return {
|
|
7180
|
+
accessToken: normalizeCursorDbValue(accessRow?.value),
|
|
7181
|
+
refreshToken: normalizeCursorDbValue(refreshRow?.value)
|
|
7182
|
+
};
|
|
7183
|
+
} finally {
|
|
7184
|
+
database.close();
|
|
7185
|
+
}
|
|
7186
|
+
}
|
|
7187
|
+
function isSqliteLockedError(error) {
|
|
7188
|
+
return error instanceof Error && /database is locked/i.test(error.message);
|
|
7189
|
+
}
|
|
7190
|
+
async function withCursorStateSnapshot(databasePath, callback) {
|
|
7191
|
+
const snapshotDir = await mkdtemp(join4(tmpdir(), "slopmeter-cursor-"));
|
|
7192
|
+
const snapshotPath = join4(snapshotDir, "state.vscdb");
|
|
7193
|
+
await copyFile(databasePath, snapshotPath);
|
|
7194
|
+
for (const suffix of ["-shm", "-wal"]) {
|
|
7195
|
+
const companionPath = `${databasePath}${suffix}`;
|
|
7196
|
+
if (!existsSync3(companionPath)) {
|
|
7197
|
+
continue;
|
|
7198
|
+
}
|
|
7199
|
+
await copyFile(companionPath, `${snapshotPath}${suffix}`);
|
|
7200
|
+
}
|
|
7201
|
+
try {
|
|
7202
|
+
return await callback(snapshotPath);
|
|
7203
|
+
} finally {
|
|
7204
|
+
await rm(snapshotDir, { recursive: true, force: true });
|
|
7205
|
+
}
|
|
7206
|
+
}
|
|
7207
|
+
async function readCursorAuthState(databasePath) {
|
|
7208
|
+
try {
|
|
7209
|
+
return readCursorAuthStateFromDatabase(databasePath);
|
|
7210
|
+
} catch (error) {
|
|
7211
|
+
if (!isSqliteLockedError(error)) {
|
|
7212
|
+
throw error;
|
|
7213
|
+
}
|
|
7214
|
+
return withCursorStateSnapshot(
|
|
7215
|
+
databasePath,
|
|
7216
|
+
async (snapshotPath) => readCursorAuthStateFromDatabase(snapshotPath)
|
|
7217
|
+
);
|
|
7218
|
+
}
|
|
7219
|
+
}
|
|
7220
|
+
async function isCursorAvailable() {
|
|
7221
|
+
const databasePath = getCursorStateDbPath();
|
|
7222
|
+
if (!databasePath) {
|
|
7223
|
+
return false;
|
|
7224
|
+
}
|
|
7225
|
+
const authState = await readCursorAuthState(databasePath);
|
|
7226
|
+
return Boolean(authState.accessToken);
|
|
7227
|
+
}
|
|
7228
|
+
function decodeJwtPayload(token) {
|
|
7229
|
+
const encodedPayload = token.split(".")[1];
|
|
7230
|
+
if (!encodedPayload) {
|
|
7231
|
+
return null;
|
|
7232
|
+
}
|
|
7233
|
+
const base64 = encodedPayload.replace(/-/g, "+").replace(/_/g, "/");
|
|
7234
|
+
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
|
|
7235
|
+
try {
|
|
7236
|
+
return JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
|
|
7237
|
+
} catch {
|
|
7238
|
+
return null;
|
|
7239
|
+
}
|
|
7240
|
+
}
|
|
7241
|
+
function getCursorWebBaseUrl() {
|
|
7242
|
+
return (process.env[CURSOR_WEB_BASE_URL_ENV]?.trim() || "https://cursor.com").replace(/\/+$/, "");
|
|
7243
|
+
}
|
|
7244
|
+
function buildCookieHeaderValue(cookieValue) {
|
|
7245
|
+
return `${CURSOR_SESSION_COOKIE_NAME}=${cookieValue}`;
|
|
7246
|
+
}
|
|
7247
|
+
function getCursorFetchAttempts(accessToken) {
|
|
7248
|
+
const attempts = [];
|
|
7249
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7250
|
+
const subject = decodeJwtPayload(accessToken)?.sub?.trim();
|
|
7251
|
+
const cookieValues = [accessToken];
|
|
7252
|
+
if (subject) {
|
|
7253
|
+
cookieValues.push(`${subject}::${accessToken}`);
|
|
7254
|
+
}
|
|
7255
|
+
const pushAttempt = (label, headers) => {
|
|
7256
|
+
const signature = JSON.stringify({
|
|
7257
|
+
label,
|
|
7258
|
+
headers: Object.entries(headers).sort(
|
|
7259
|
+
([left], [right]) => left.localeCompare(right)
|
|
7260
|
+
)
|
|
7261
|
+
});
|
|
7262
|
+
if (seen.has(signature)) {
|
|
7263
|
+
return;
|
|
7264
|
+
}
|
|
7265
|
+
seen.add(signature);
|
|
7266
|
+
attempts.push({ label, headers });
|
|
7267
|
+
};
|
|
7268
|
+
pushAttempt("bearer", {
|
|
7269
|
+
Authorization: `Bearer ${accessToken}`
|
|
7270
|
+
});
|
|
7271
|
+
for (const cookieValue of cookieValues) {
|
|
7272
|
+
pushAttempt("cookie", {
|
|
7273
|
+
Cookie: buildCookieHeaderValue(cookieValue)
|
|
7274
|
+
});
|
|
7275
|
+
pushAttempt("cookie-encoded", {
|
|
7276
|
+
Cookie: buildCookieHeaderValue(encodeURIComponent(cookieValue))
|
|
7277
|
+
});
|
|
7278
|
+
pushAttempt("bearer+cookie", {
|
|
7279
|
+
Authorization: `Bearer ${accessToken}`,
|
|
7280
|
+
Cookie: buildCookieHeaderValue(cookieValue)
|
|
7281
|
+
});
|
|
7282
|
+
pushAttempt("bearer+cookie-encoded", {
|
|
7283
|
+
Authorization: `Bearer ${accessToken}`,
|
|
7284
|
+
Cookie: buildCookieHeaderValue(encodeURIComponent(cookieValue))
|
|
7285
|
+
});
|
|
7286
|
+
}
|
|
7287
|
+
return attempts;
|
|
7288
|
+
}
|
|
7289
|
+
async function fetchCursorUsageCsv(accessToken) {
|
|
7290
|
+
const url = new URL(
|
|
7291
|
+
"/api/dashboard/export-usage-events-csv?strategy=tokens",
|
|
7292
|
+
getCursorWebBaseUrl()
|
|
7293
|
+
);
|
|
7294
|
+
const failures = [];
|
|
7295
|
+
for (const attempt of getCursorFetchAttempts(accessToken)) {
|
|
7296
|
+
const response = await fetch(url, {
|
|
7297
|
+
headers: {
|
|
7298
|
+
Accept: "text/csv,text/plain;q=0.9,*/*;q=0.8",
|
|
7299
|
+
...attempt.headers
|
|
7300
|
+
}
|
|
7301
|
+
});
|
|
7302
|
+
if (response.ok) {
|
|
7303
|
+
return response;
|
|
7304
|
+
}
|
|
7305
|
+
failures.push({
|
|
7306
|
+
label: attempt.label,
|
|
7307
|
+
status: response.status,
|
|
7308
|
+
statusText: response.statusText,
|
|
7309
|
+
body: (await response.text()).trim().slice(0, 200)
|
|
7310
|
+
});
|
|
7311
|
+
}
|
|
7312
|
+
const summary = failures.map((failure) => {
|
|
7313
|
+
const statusLine = `${failure.label}: ${failure.status} ${failure.statusText}`.trim();
|
|
7314
|
+
return failure.body ? `${statusLine} (${failure.body})` : statusLine;
|
|
7315
|
+
}).join("; ");
|
|
7316
|
+
throw new Error(
|
|
7317
|
+
`Failed to authenticate Cursor usage export with local auth state from ${url.origin}. ${summary}`
|
|
7318
|
+
);
|
|
7319
|
+
}
|
|
7320
|
+
function parseCsvLine(line) {
|
|
7321
|
+
const values = [];
|
|
7322
|
+
let current = "";
|
|
7323
|
+
let inQuotes = false;
|
|
7324
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
7325
|
+
const char = line[index];
|
|
7326
|
+
if (char === '"') {
|
|
7327
|
+
if (inQuotes && line[index + 1] === '"') {
|
|
7328
|
+
current += '"';
|
|
7329
|
+
index += 1;
|
|
7330
|
+
continue;
|
|
7331
|
+
}
|
|
7332
|
+
inQuotes = !inQuotes;
|
|
7333
|
+
continue;
|
|
7334
|
+
}
|
|
7335
|
+
if (char === "," && !inQuotes) {
|
|
7336
|
+
values.push(current);
|
|
7337
|
+
current = "";
|
|
7338
|
+
continue;
|
|
7339
|
+
}
|
|
7340
|
+
current += char;
|
|
7341
|
+
}
|
|
7342
|
+
values.push(current);
|
|
7343
|
+
return values;
|
|
7344
|
+
}
|
|
7345
|
+
function createCursorCsvRow(headers, values) {
|
|
7346
|
+
const row = {};
|
|
7347
|
+
headers.forEach((header, index) => {
|
|
7348
|
+
row[header] = values[index];
|
|
7349
|
+
});
|
|
7350
|
+
return row;
|
|
7351
|
+
}
|
|
7352
|
+
function processCursorCsvLines(lines, onRow) {
|
|
7353
|
+
let headers = null;
|
|
7354
|
+
for (const rawLine of lines) {
|
|
7355
|
+
const line = rawLine.trim();
|
|
7356
|
+
if (line === "") {
|
|
7357
|
+
continue;
|
|
7358
|
+
}
|
|
7359
|
+
const values = parseCsvLine(line);
|
|
7360
|
+
if (!headers) {
|
|
7361
|
+
headers = values;
|
|
7362
|
+
continue;
|
|
7363
|
+
}
|
|
7364
|
+
onRow(createCursorCsvRow(headers, values));
|
|
7365
|
+
}
|
|
7366
|
+
}
|
|
7367
|
+
function processCursorUsageCsvText(content, onRow) {
|
|
7368
|
+
processCursorCsvLines(content.split(/\r?\n/), onRow);
|
|
7369
|
+
}
|
|
7370
|
+
async function processCursorUsageCsvStream(response, onRow) {
|
|
7371
|
+
if (!response.body) {
|
|
7372
|
+
processCursorUsageCsvText(await response.text(), onRow);
|
|
7373
|
+
return;
|
|
7374
|
+
}
|
|
7375
|
+
let headers = null;
|
|
7376
|
+
const state = {
|
|
7377
|
+
currentField: "",
|
|
7378
|
+
currentRow: [],
|
|
7379
|
+
inQuotes: false,
|
|
7380
|
+
pendingQuote: false,
|
|
7381
|
+
sawCarriageReturn: false
|
|
7382
|
+
};
|
|
7383
|
+
const decoder = new TextDecoder();
|
|
7384
|
+
const emitField = () => {
|
|
7385
|
+
state.currentRow.push(state.currentField);
|
|
7386
|
+
state.currentField = "";
|
|
7387
|
+
};
|
|
7388
|
+
const emitRow = () => {
|
|
7389
|
+
emitField();
|
|
7390
|
+
if (state.currentRow.every((value) => value.trim() === "")) {
|
|
7391
|
+
state.currentRow = [];
|
|
7392
|
+
return;
|
|
7393
|
+
}
|
|
7394
|
+
if (!headers) {
|
|
7395
|
+
headers = state.currentRow;
|
|
7396
|
+
state.currentRow = [];
|
|
7397
|
+
return;
|
|
7398
|
+
}
|
|
7399
|
+
onRow(createCursorCsvRow(headers, state.currentRow));
|
|
7400
|
+
state.currentRow = [];
|
|
7401
|
+
};
|
|
7402
|
+
const processChunk = (chunk) => {
|
|
7403
|
+
for (const char of chunk) {
|
|
7404
|
+
for (; ; ) {
|
|
7405
|
+
if (state.sawCarriageReturn) {
|
|
7406
|
+
state.sawCarriageReturn = false;
|
|
7407
|
+
if (char === "\n") {
|
|
7408
|
+
break;
|
|
7409
|
+
}
|
|
7410
|
+
}
|
|
7411
|
+
if (state.pendingQuote) {
|
|
7412
|
+
state.pendingQuote = false;
|
|
7413
|
+
if (char === '"') {
|
|
7414
|
+
state.currentField += '"';
|
|
7415
|
+
break;
|
|
7416
|
+
}
|
|
7417
|
+
state.inQuotes = false;
|
|
7418
|
+
continue;
|
|
7419
|
+
}
|
|
7420
|
+
if (state.inQuotes) {
|
|
7421
|
+
if (char === '"') {
|
|
7422
|
+
state.pendingQuote = true;
|
|
7423
|
+
} else {
|
|
7424
|
+
state.currentField += char;
|
|
7425
|
+
}
|
|
7426
|
+
break;
|
|
7427
|
+
}
|
|
7428
|
+
if (char === '"') {
|
|
7429
|
+
state.inQuotes = true;
|
|
7430
|
+
break;
|
|
7431
|
+
}
|
|
7432
|
+
if (char === ",") {
|
|
7433
|
+
emitField();
|
|
7434
|
+
break;
|
|
7435
|
+
}
|
|
7436
|
+
if (char === "\n") {
|
|
7437
|
+
emitRow();
|
|
7438
|
+
break;
|
|
7439
|
+
}
|
|
7440
|
+
if (char === "\r") {
|
|
7441
|
+
emitRow();
|
|
7442
|
+
state.sawCarriageReturn = true;
|
|
7443
|
+
break;
|
|
7444
|
+
}
|
|
7445
|
+
state.currentField += char;
|
|
7446
|
+
break;
|
|
7447
|
+
}
|
|
7448
|
+
}
|
|
7449
|
+
};
|
|
7450
|
+
const reader = response.body.getReader();
|
|
7451
|
+
try {
|
|
7452
|
+
for (; ; ) {
|
|
7453
|
+
const { done, value } = await reader.read();
|
|
7454
|
+
if (done) {
|
|
7455
|
+
break;
|
|
7456
|
+
}
|
|
7457
|
+
processChunk(decoder.decode(value, { stream: true }));
|
|
7458
|
+
}
|
|
7459
|
+
} finally {
|
|
7460
|
+
reader.releaseLock();
|
|
7461
|
+
}
|
|
7462
|
+
processChunk(decoder.decode());
|
|
7463
|
+
if (state.pendingQuote) {
|
|
7464
|
+
state.inQuotes = false;
|
|
7465
|
+
}
|
|
7466
|
+
if (state.currentField !== "" || state.currentRow.length > 0) {
|
|
7467
|
+
emitRow();
|
|
7468
|
+
}
|
|
7469
|
+
}
|
|
7470
|
+
function addCursorUsageRow(row, start, end, recentStart, totals, modelTotals, recentModelTotals) {
|
|
7471
|
+
const date = parseCursorDate(row.Date);
|
|
7472
|
+
const rawModel = row.Model?.trim();
|
|
7473
|
+
const tokenTotals = createCursorTokenTotals(row);
|
|
7474
|
+
if (!date || !rawModel || !tokenTotals) {
|
|
7475
|
+
return;
|
|
7476
|
+
}
|
|
7477
|
+
if (date < start || date > end) {
|
|
7478
|
+
return;
|
|
7479
|
+
}
|
|
7480
|
+
const modelName = normalizeModelName(rawModel);
|
|
7481
|
+
addDailyTokenTotals(totals, date, tokenTotals, modelName);
|
|
7482
|
+
addModelTokenTotals(modelTotals, modelName, tokenTotals);
|
|
7483
|
+
if (date >= recentStart) {
|
|
7484
|
+
addModelTokenTotals(recentModelTotals, modelName, tokenTotals);
|
|
7485
|
+
}
|
|
7486
|
+
}
|
|
7487
|
+
function parseCursorDate(value) {
|
|
7488
|
+
const trimmed = value?.trim();
|
|
7489
|
+
if (!trimmed) {
|
|
7490
|
+
return null;
|
|
7491
|
+
}
|
|
7492
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
|
7493
|
+
return /* @__PURE__ */ new Date(`${trimmed}T00:00:00`);
|
|
7494
|
+
}
|
|
7495
|
+
const parsed = new Date(trimmed);
|
|
7496
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
7497
|
+
}
|
|
7498
|
+
function parseCursorNumber(value) {
|
|
7499
|
+
const numeric = Number(value?.replaceAll(",", "").trim() ?? "");
|
|
7500
|
+
if (!Number.isFinite(numeric) || numeric <= 0) {
|
|
7501
|
+
return null;
|
|
7502
|
+
}
|
|
7503
|
+
return Math.round(numeric);
|
|
7504
|
+
}
|
|
7505
|
+
function createCursorTokenTotals(row) {
|
|
7506
|
+
const total = parseCursorNumber(row["Total Tokens"]) ?? parseCursorNumber(row.Tokens);
|
|
7507
|
+
if (!total) {
|
|
7508
|
+
return null;
|
|
7509
|
+
}
|
|
7510
|
+
const inputWithCacheWrite = parseCursorNumber(row["Input (w/ Cache Write)"]) ?? 0;
|
|
7511
|
+
const inputWithoutCacheWrite = parseCursorNumber(row["Input (w/o Cache Write)"]) ?? 0;
|
|
7512
|
+
const cacheInput = parseCursorNumber(row["Cache Read"]) ?? 0;
|
|
7513
|
+
const outputTokens = parseCursorNumber(row["Output Tokens"]) ?? 0;
|
|
7514
|
+
return {
|
|
7515
|
+
input: inputWithCacheWrite + inputWithoutCacheWrite + cacheInput,
|
|
7516
|
+
output: outputTokens,
|
|
7517
|
+
cache: { input: cacheInput, output: inputWithCacheWrite },
|
|
7518
|
+
total
|
|
7519
|
+
};
|
|
7520
|
+
}
|
|
7521
|
+
async function loadCursorRows(start, end) {
|
|
7522
|
+
const databasePath = getCursorStateDbPath();
|
|
7523
|
+
const totals = /* @__PURE__ */ new Map();
|
|
7524
|
+
const modelTotals = /* @__PURE__ */ new Map();
|
|
7525
|
+
const recentModelTotals = /* @__PURE__ */ new Map();
|
|
7526
|
+
if (!databasePath) {
|
|
7527
|
+
return createUsageSummary(
|
|
7528
|
+
"cursor",
|
|
7529
|
+
totals,
|
|
7530
|
+
modelTotals,
|
|
7531
|
+
recentModelTotals,
|
|
7532
|
+
end
|
|
7533
|
+
);
|
|
7534
|
+
}
|
|
7535
|
+
const authState = await readCursorAuthState(databasePath);
|
|
7536
|
+
if (!authState.accessToken) {
|
|
7537
|
+
return createUsageSummary(
|
|
7538
|
+
"cursor",
|
|
7539
|
+
totals,
|
|
7540
|
+
modelTotals,
|
|
7541
|
+
recentModelTotals,
|
|
7542
|
+
end
|
|
7543
|
+
);
|
|
7544
|
+
}
|
|
7545
|
+
const recentStart = getRecentWindowStart(end, 30);
|
|
7546
|
+
const response = await fetchCursorUsageCsv(authState.accessToken);
|
|
7547
|
+
return summarizeCursorUsageCsv(response, start, end, recentStart);
|
|
7548
|
+
}
|
|
7549
|
+
async function summarizeCursorUsageCsv(response, start, end, recentStart = getRecentWindowStart(end, 30)) {
|
|
7550
|
+
const totals = /* @__PURE__ */ new Map();
|
|
7551
|
+
const modelTotals = /* @__PURE__ */ new Map();
|
|
7552
|
+
const recentModelTotals = /* @__PURE__ */ new Map();
|
|
7553
|
+
await processCursorUsageCsvStream(response, (row) => {
|
|
7554
|
+
addCursorUsageRow(
|
|
7555
|
+
row,
|
|
7556
|
+
start,
|
|
7557
|
+
end,
|
|
7558
|
+
recentStart,
|
|
7559
|
+
totals,
|
|
7560
|
+
modelTotals,
|
|
7561
|
+
recentModelTotals
|
|
7562
|
+
);
|
|
7563
|
+
});
|
|
7564
|
+
return createUsageSummary(
|
|
7565
|
+
"cursor",
|
|
7566
|
+
totals,
|
|
7567
|
+
modelTotals,
|
|
7568
|
+
recentModelTotals,
|
|
7569
|
+
end
|
|
7570
|
+
);
|
|
7008
7571
|
}
|
|
7009
7572
|
|
|
7010
7573
|
// src/lib/interfaces.ts
|
|
7011
|
-
var providerIds = [
|
|
7574
|
+
var providerIds = [
|
|
7575
|
+
"claude",
|
|
7576
|
+
"codex",
|
|
7577
|
+
"cursor",
|
|
7578
|
+
"opencode",
|
|
7579
|
+
"pi"
|
|
7580
|
+
];
|
|
7581
|
+
var defaultProviderIds = ["claude", "codex", "cursor"];
|
|
7012
7582
|
var providerStatusLabel = {
|
|
7013
7583
|
claude: "Claude code",
|
|
7014
7584
|
codex: "Codex",
|
|
7015
|
-
|
|
7585
|
+
cursor: "Cursor",
|
|
7586
|
+
opencode: "Open Code",
|
|
7587
|
+
pi: "Pi Coding Agent"
|
|
7016
7588
|
};
|
|
7017
7589
|
|
|
7018
7590
|
// src/lib/open-code.ts
|
|
7019
|
-
import { existsSync as
|
|
7020
|
-
import { copyFile, mkdtemp, rm } from "fs/promises";
|
|
7021
|
-
import { homedir as
|
|
7022
|
-
import { join as
|
|
7591
|
+
import { existsSync as existsSync4 } from "fs";
|
|
7592
|
+
import { copyFile as copyFile2, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
|
|
7593
|
+
import { homedir as homedir4, tmpdir as tmpdir2 } from "os";
|
|
7594
|
+
import { join as join5, resolve as resolve4 } from "path";
|
|
7023
7595
|
function sumOpenCodeTokens(tokens) {
|
|
7024
7596
|
const cacheInput = tokens?.cache?.read ?? 0;
|
|
7025
7597
|
const cacheOutput = tokens?.cache?.write ?? 0;
|
|
@@ -7036,18 +7608,22 @@ async function parseOpenCodeFile(filePath) {
|
|
|
7036
7608
|
return readJsonDocument(filePath);
|
|
7037
7609
|
}
|
|
7038
7610
|
function getOpenCodeBaseDir() {
|
|
7039
|
-
const baseDir = process.env.OPENCODE_DATA_DIR?.trim() ?
|
|
7611
|
+
const baseDir = process.env.OPENCODE_DATA_DIR?.trim() ? resolve4(process.env.OPENCODE_DATA_DIR) : join5(homedir4(), ".local", "share", "opencode");
|
|
7040
7612
|
return baseDir;
|
|
7041
7613
|
}
|
|
7042
7614
|
async function getOpenCodeSource() {
|
|
7043
7615
|
const baseDir = getOpenCodeBaseDir();
|
|
7044
|
-
const databasePath =
|
|
7045
|
-
if (
|
|
7616
|
+
const databasePath = join5(baseDir, "opencode.db");
|
|
7617
|
+
if (existsSync4(databasePath)) {
|
|
7046
7618
|
return { kind: "database", path: databasePath };
|
|
7047
7619
|
}
|
|
7048
|
-
const messagesDir =
|
|
7620
|
+
const messagesDir = join5(baseDir, "storage", "message");
|
|
7049
7621
|
return { kind: "legacy", files: await listFilesRecursive(messagesDir, ".json") };
|
|
7050
7622
|
}
|
|
7623
|
+
function isOpenCodeAvailable() {
|
|
7624
|
+
const baseDir = getOpenCodeBaseDir();
|
|
7625
|
+
return existsSync4(join5(baseDir, "opencode.db")) || existsSync4(join5(baseDir, "storage", "message"));
|
|
7626
|
+
}
|
|
7051
7627
|
async function loadSqliteModule() {
|
|
7052
7628
|
try {
|
|
7053
7629
|
const moduleName = "node:sqlite";
|
|
@@ -7084,24 +7660,24 @@ function parseOpenCodeMessageData(rowId, sourceLabel, content) {
|
|
|
7084
7660
|
id: message.id || rowId
|
|
7085
7661
|
};
|
|
7086
7662
|
}
|
|
7087
|
-
function
|
|
7663
|
+
function isSqliteLockedError2(error) {
|
|
7088
7664
|
return error instanceof Error && /database is locked/i.test(error.message);
|
|
7089
7665
|
}
|
|
7090
7666
|
async function withDatabaseSnapshot(databasePath, callback) {
|
|
7091
|
-
const snapshotDir = await
|
|
7092
|
-
const snapshotPath =
|
|
7093
|
-
await
|
|
7667
|
+
const snapshotDir = await mkdtemp2(join5(tmpdir2(), "slopmeter-opencode-"));
|
|
7668
|
+
const snapshotPath = join5(snapshotDir, "opencode.db");
|
|
7669
|
+
await copyFile2(databasePath, snapshotPath);
|
|
7094
7670
|
for (const suffix of ["-shm", "-wal"]) {
|
|
7095
7671
|
const companionPath = `${databasePath}${suffix}`;
|
|
7096
|
-
if (!
|
|
7672
|
+
if (!existsSync4(companionPath)) {
|
|
7097
7673
|
continue;
|
|
7098
7674
|
}
|
|
7099
|
-
await
|
|
7675
|
+
await copyFile2(companionPath, `${snapshotPath}${suffix}`);
|
|
7100
7676
|
}
|
|
7101
7677
|
try {
|
|
7102
7678
|
return await callback(snapshotPath);
|
|
7103
7679
|
} finally {
|
|
7104
|
-
await
|
|
7680
|
+
await rm2(snapshotDir, { recursive: true, force: true });
|
|
7105
7681
|
}
|
|
7106
7682
|
}
|
|
7107
7683
|
async function iterateOpenCodeDatabaseMessages(databasePath, onMessage) {
|
|
@@ -7130,7 +7706,7 @@ async function loadOpenCodeDatabaseMessages(databasePath, onMessage) {
|
|
|
7130
7706
|
try {
|
|
7131
7707
|
await iterateOpenCodeDatabaseMessages(databasePath, onMessage);
|
|
7132
7708
|
} catch (error) {
|
|
7133
|
-
if (!
|
|
7709
|
+
if (!isSqliteLockedError2(error)) {
|
|
7134
7710
|
throw error;
|
|
7135
7711
|
}
|
|
7136
7712
|
await withDatabaseSnapshot(databasePath, async (snapshotPath) => {
|
|
@@ -7202,7 +7778,152 @@ async function loadOpenCodeRows(start, end) {
|
|
|
7202
7778
|
);
|
|
7203
7779
|
}
|
|
7204
7780
|
|
|
7781
|
+
// src/lib/pi.ts
|
|
7782
|
+
import { existsSync as existsSync5 } from "fs";
|
|
7783
|
+
import { homedir as homedir5 } from "os";
|
|
7784
|
+
import { join as join6, resolve as resolve5 } from "path";
|
|
7785
|
+
var PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
|
|
7786
|
+
var CLASSIFICATION_PREFIX_BYTES2 = 16 * 1024;
|
|
7787
|
+
function getPiAgentDir() {
|
|
7788
|
+
const configuredAgentDir = process.env[PI_AGENT_DIR_ENV]?.trim();
|
|
7789
|
+
return configuredAgentDir ? resolve5(configuredAgentDir) : join6(homedir5(), ".pi", "agent");
|
|
7790
|
+
}
|
|
7791
|
+
async function getPiSessionFiles() {
|
|
7792
|
+
return listFilesRecursive(join6(getPiAgentDir(), "sessions"), ".jsonl");
|
|
7793
|
+
}
|
|
7794
|
+
function isPiAvailable() {
|
|
7795
|
+
return existsSync5(join6(getPiAgentDir(), "sessions"));
|
|
7796
|
+
}
|
|
7797
|
+
function classifyPiRecord(prefix) {
|
|
7798
|
+
if (prefix.includes('"type":"message"') && prefix.includes('"role":"assistant"')) {
|
|
7799
|
+
return { kind: "keep", classification: void 0 };
|
|
7800
|
+
}
|
|
7801
|
+
return { kind: "skip" };
|
|
7802
|
+
}
|
|
7803
|
+
function asNonEmptyString2(value) {
|
|
7804
|
+
const trimmed = value?.trim();
|
|
7805
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
7806
|
+
}
|
|
7807
|
+
function createPiTokenTotals(usage) {
|
|
7808
|
+
const cacheRead = usage.cacheRead ?? 0;
|
|
7809
|
+
const cacheWrite = usage.cacheWrite ?? 0;
|
|
7810
|
+
const input = (usage.input ?? 0) + cacheRead;
|
|
7811
|
+
const output = (usage.output ?? 0) + cacheWrite;
|
|
7812
|
+
const total = usage.totalTokens ?? input + output;
|
|
7813
|
+
return {
|
|
7814
|
+
input,
|
|
7815
|
+
output,
|
|
7816
|
+
cache: { input: cacheRead, output: cacheWrite },
|
|
7817
|
+
total
|
|
7818
|
+
};
|
|
7819
|
+
}
|
|
7820
|
+
function getPiTimestamp(entry) {
|
|
7821
|
+
const rawTimestamp = entry.timestamp ?? entry.message?.timestamp;
|
|
7822
|
+
if (typeof rawTimestamp === "string" || typeof rawTimestamp === "number") {
|
|
7823
|
+
return new Date(rawTimestamp);
|
|
7824
|
+
}
|
|
7825
|
+
return null;
|
|
7826
|
+
}
|
|
7827
|
+
async function loadPiRows(start, end) {
|
|
7828
|
+
const files = await getPiSessionFiles();
|
|
7829
|
+
const totals = /* @__PURE__ */ new Map();
|
|
7830
|
+
const modelTotals = /* @__PURE__ */ new Map();
|
|
7831
|
+
const recentModelTotals = /* @__PURE__ */ new Map();
|
|
7832
|
+
const recentStart = getRecentWindowStart(end, 30);
|
|
7833
|
+
const maxRecordBytes = getPositiveIntegerEnv(
|
|
7834
|
+
MAX_JSONL_RECORD_BYTES_ENV,
|
|
7835
|
+
DEFAULT_MAX_JSONL_RECORD_BYTES
|
|
7836
|
+
);
|
|
7837
|
+
for (const file of files) {
|
|
7838
|
+
for await (const record of readJsonlRecords(file, {
|
|
7839
|
+
classificationPrefixBytes: CLASSIFICATION_PREFIX_BYTES2,
|
|
7840
|
+
classify: classifyPiRecord,
|
|
7841
|
+
maxRecordBytes,
|
|
7842
|
+
oversizedErrorMessage: ({
|
|
7843
|
+
filePath,
|
|
7844
|
+
lineNumber,
|
|
7845
|
+
maxRecordBytes: maxRecordBytes2,
|
|
7846
|
+
envVarName
|
|
7847
|
+
}) => `Relevant Pi Coding Agent record exceeds ${maxRecordBytes2} bytes in ${filePath}:${lineNumber}. Increase ${envVarName} to process this file.`
|
|
7848
|
+
})) {
|
|
7849
|
+
let entry;
|
|
7850
|
+
try {
|
|
7851
|
+
entry = JSON.parse(record.rawLine);
|
|
7852
|
+
} catch {
|
|
7853
|
+
continue;
|
|
7854
|
+
}
|
|
7855
|
+
if (entry.type !== "message" || entry.message?.role !== "assistant") {
|
|
7856
|
+
continue;
|
|
7857
|
+
}
|
|
7858
|
+
const usage = entry.message.usage;
|
|
7859
|
+
if (!usage) {
|
|
7860
|
+
continue;
|
|
7861
|
+
}
|
|
7862
|
+
const timestamp = getPiTimestamp(entry);
|
|
7863
|
+
if (!timestamp || Number.isNaN(timestamp.getTime())) {
|
|
7864
|
+
continue;
|
|
7865
|
+
}
|
|
7866
|
+
if (timestamp < start || timestamp > end) {
|
|
7867
|
+
continue;
|
|
7868
|
+
}
|
|
7869
|
+
const tokenTotals = createPiTokenTotals(usage);
|
|
7870
|
+
if (tokenTotals.total <= 0) {
|
|
7871
|
+
continue;
|
|
7872
|
+
}
|
|
7873
|
+
const modelName = asNonEmptyString2(entry.message.model);
|
|
7874
|
+
const normalizedModelName = modelName ? normalizeModelName(modelName) : void 0;
|
|
7875
|
+
addDailyTokenTotals(totals, timestamp, tokenTotals, normalizedModelName);
|
|
7876
|
+
if (!normalizedModelName) {
|
|
7877
|
+
continue;
|
|
7878
|
+
}
|
|
7879
|
+
addModelTokenTotals(modelTotals, normalizedModelName, tokenTotals);
|
|
7880
|
+
if (timestamp >= recentStart) {
|
|
7881
|
+
addModelTokenTotals(
|
|
7882
|
+
recentModelTotals,
|
|
7883
|
+
normalizedModelName,
|
|
7884
|
+
tokenTotals
|
|
7885
|
+
);
|
|
7886
|
+
}
|
|
7887
|
+
}
|
|
7888
|
+
}
|
|
7889
|
+
return createUsageSummary("pi", totals, modelTotals, recentModelTotals, end);
|
|
7890
|
+
}
|
|
7891
|
+
|
|
7205
7892
|
// src/providers.ts
|
|
7893
|
+
function createEmptyProviderAvailability() {
|
|
7894
|
+
return {
|
|
7895
|
+
claude: false,
|
|
7896
|
+
codex: false,
|
|
7897
|
+
cursor: false,
|
|
7898
|
+
opencode: false,
|
|
7899
|
+
pi: false
|
|
7900
|
+
};
|
|
7901
|
+
}
|
|
7902
|
+
async function isProviderAvailable(provider) {
|
|
7903
|
+
switch (provider) {
|
|
7904
|
+
case "claude":
|
|
7905
|
+
return isClaudeAvailable();
|
|
7906
|
+
case "codex":
|
|
7907
|
+
return isCodexAvailable();
|
|
7908
|
+
case "cursor":
|
|
7909
|
+
return isCursorAvailable();
|
|
7910
|
+
case "opencode":
|
|
7911
|
+
return isOpenCodeAvailable();
|
|
7912
|
+
case "pi":
|
|
7913
|
+
return isPiAvailable();
|
|
7914
|
+
default: {
|
|
7915
|
+
const exhaustiveCheck = provider;
|
|
7916
|
+
throw new Error(`Unhandled provider: ${String(exhaustiveCheck)}`);
|
|
7917
|
+
}
|
|
7918
|
+
}
|
|
7919
|
+
}
|
|
7920
|
+
async function getProviderAvailability(providers = providerIds) {
|
|
7921
|
+
const availability = createEmptyProviderAvailability();
|
|
7922
|
+
for (const provider of providers) {
|
|
7923
|
+
availability[provider] = await isProviderAvailable(provider);
|
|
7924
|
+
}
|
|
7925
|
+
return availability;
|
|
7926
|
+
}
|
|
7206
7927
|
function mergeProviderUsage(rowsByProvider, end) {
|
|
7207
7928
|
const summaries = providerIds.map((provider) => rowsByProvider[provider]).filter((summary) => summary !== null);
|
|
7208
7929
|
if (summaries.length === 0) {
|
|
@@ -7219,11 +7940,34 @@ async function aggregateUsage({
|
|
|
7219
7940
|
const rowsByProvider = {
|
|
7220
7941
|
claude: null,
|
|
7221
7942
|
codex: null,
|
|
7222
|
-
|
|
7943
|
+
cursor: null,
|
|
7944
|
+
opencode: null,
|
|
7945
|
+
pi: null
|
|
7223
7946
|
};
|
|
7224
7947
|
const warnings = [];
|
|
7225
7948
|
for (const provider of providersToLoad) {
|
|
7226
|
-
|
|
7949
|
+
let summary;
|
|
7950
|
+
switch (provider) {
|
|
7951
|
+
case "claude":
|
|
7952
|
+
summary = await loadClaudeRows(start, end);
|
|
7953
|
+
break;
|
|
7954
|
+
case "codex":
|
|
7955
|
+
summary = await loadCodexRows(start, end, warnings);
|
|
7956
|
+
break;
|
|
7957
|
+
case "cursor":
|
|
7958
|
+
summary = await loadCursorRows(start, end);
|
|
7959
|
+
break;
|
|
7960
|
+
case "opencode":
|
|
7961
|
+
summary = await loadOpenCodeRows(start, end);
|
|
7962
|
+
break;
|
|
7963
|
+
case "pi":
|
|
7964
|
+
summary = await loadPiRows(start, end);
|
|
7965
|
+
break;
|
|
7966
|
+
default: {
|
|
7967
|
+
const exhaustiveCheck = provider;
|
|
7968
|
+
throw new Error(`Unhandled provider: ${String(exhaustiveCheck)}`);
|
|
7969
|
+
}
|
|
7970
|
+
}
|
|
7227
7971
|
rowsByProvider[provider] = hasUsage(summary) ? summary : null;
|
|
7228
7972
|
}
|
|
7229
7973
|
return { rowsByProvider, warnings };
|
|
@@ -7239,13 +7983,15 @@ var HELP_TEXT = `slopmeter
|
|
|
7239
7983
|
Generate rolling 1-year usage heatmap image(s) (today is the latest day).
|
|
7240
7984
|
|
|
7241
7985
|
Usage:
|
|
7242
|
-
slopmeter [--all] [--claude] [--codex] [--opencode] [--dark] [--format png|svg|json] [--output ./heatmap-last-year.png]
|
|
7986
|
+
slopmeter [--all] [--claude] [--codex] [--cursor] [--opencode] [--pi] [--dark] [--format png|svg|json] [--output ./heatmap-last-year.png]
|
|
7243
7987
|
|
|
7244
7988
|
Options:
|
|
7245
7989
|
--all Render one merged graph for all providers
|
|
7246
7990
|
--claude Render Claude Code graph
|
|
7247
7991
|
--codex Render Codex graph
|
|
7992
|
+
--cursor Render Cursor graph
|
|
7248
7993
|
--opencode Render Open Code graph
|
|
7994
|
+
--pi Render Pi Coding Agent graph
|
|
7249
7995
|
--dark Render with the dark theme
|
|
7250
7996
|
-f, --format Output format: png, svg, or json (default: png)
|
|
7251
7997
|
-o, --output Output file path (default: ./heatmap-last-year.png)
|
|
@@ -7265,7 +8011,9 @@ function validateArgs(values) {
|
|
|
7265
8011
|
all: ow.boolean,
|
|
7266
8012
|
claude: ow.boolean,
|
|
7267
8013
|
codex: ow.boolean,
|
|
7268
|
-
|
|
8014
|
+
cursor: ow.boolean,
|
|
8015
|
+
opencode: ow.boolean,
|
|
8016
|
+
pi: ow.boolean
|
|
7269
8017
|
})
|
|
7270
8018
|
);
|
|
7271
8019
|
}
|
|
@@ -7320,33 +8068,52 @@ function getDateWindow() {
|
|
|
7320
8068
|
end.setHours(23, 59, 59, 999);
|
|
7321
8069
|
return { start, end };
|
|
7322
8070
|
}
|
|
7323
|
-
function printProviderAvailability(
|
|
8071
|
+
function printProviderAvailability(availabilityByProvider, providers) {
|
|
7324
8072
|
for (const provider of providers) {
|
|
7325
|
-
const
|
|
7326
|
-
process.stdout.write(`${providerStatusLabel[provider]} ${
|
|
8073
|
+
const status = availabilityByProvider[provider] ? "available" : "not available";
|
|
8074
|
+
process.stdout.write(`${providerStatusLabel[provider]} ${status}
|
|
7327
8075
|
`);
|
|
7328
8076
|
}
|
|
7329
8077
|
}
|
|
7330
8078
|
function getRequestedProviders(values) {
|
|
7331
8079
|
return providerIds.filter((id) => values[id]);
|
|
7332
8080
|
}
|
|
7333
|
-
function getOutputProviders(values, rowsByProvider, end) {
|
|
8081
|
+
function getOutputProviders(values, availabilityByProvider, rowsByProvider, end) {
|
|
7334
8082
|
if (!values.all) {
|
|
7335
|
-
return selectProvidersToRender(
|
|
8083
|
+
return selectProvidersToRender(
|
|
8084
|
+
availabilityByProvider,
|
|
8085
|
+
rowsByProvider,
|
|
8086
|
+
getRequestedProviders(values)
|
|
8087
|
+
);
|
|
7336
8088
|
}
|
|
7337
8089
|
const merged = mergeProviderUsage(rowsByProvider, end);
|
|
7338
8090
|
if (!merged) {
|
|
7339
|
-
throw new Error(
|
|
7340
|
-
"No usage data found for Claude code, Codex, or Open code."
|
|
7341
|
-
);
|
|
8091
|
+
throw new Error("No usage data found for any provider.");
|
|
7342
8092
|
}
|
|
7343
8093
|
return [merged];
|
|
7344
8094
|
}
|
|
8095
|
+
function getDefaultOutputProviderIds(rowsByProvider) {
|
|
8096
|
+
const selected = [];
|
|
8097
|
+
const fallbackProviders = providerIds.filter(
|
|
8098
|
+
(provider) => !defaultProviderIds.includes(provider)
|
|
8099
|
+
);
|
|
8100
|
+
for (const provider of [...defaultProviderIds, ...fallbackProviders]) {
|
|
8101
|
+
if (!rowsByProvider[provider] || selected.includes(provider)) {
|
|
8102
|
+
continue;
|
|
8103
|
+
}
|
|
8104
|
+
selected.push(provider);
|
|
8105
|
+
if (selected.length === 3) {
|
|
8106
|
+
return selected;
|
|
8107
|
+
}
|
|
8108
|
+
}
|
|
8109
|
+
return selected;
|
|
8110
|
+
}
|
|
7345
8111
|
function getMergedProviderTitle(rowsByProvider) {
|
|
7346
8112
|
return providerIds.filter((provider) => rowsByProvider[provider] !== null).map((provider) => heatmapThemes[provider].title).join(" / ");
|
|
7347
8113
|
}
|
|
7348
|
-
function selectProvidersToRender(rowsByProvider, requested) {
|
|
7349
|
-
const
|
|
8114
|
+
function selectProvidersToRender(availabilityByProvider, rowsByProvider, requested) {
|
|
8115
|
+
const defaultProviders = getDefaultOutputProviderIds(rowsByProvider);
|
|
8116
|
+
const providersToRender = requested.length > 0 ? requested.filter((provider) => rowsByProvider[provider]) : defaultProviders.filter((provider) => rowsByProvider[provider]);
|
|
7350
8117
|
if (requested.length > 0 && providersToRender.length < requested.length) {
|
|
7351
8118
|
const missing = requested.filter((provider) => !rowsByProvider[provider]);
|
|
7352
8119
|
throw new Error(
|
|
@@ -7354,8 +8121,18 @@ function selectProvidersToRender(rowsByProvider, requested) {
|
|
|
7354
8121
|
);
|
|
7355
8122
|
}
|
|
7356
8123
|
if (providersToRender.length === 0) {
|
|
8124
|
+
const availableProviders = providerIds.filter(
|
|
8125
|
+
(provider) => availabilityByProvider[provider]
|
|
8126
|
+
);
|
|
8127
|
+
if (availableProviders.length > 0) {
|
|
8128
|
+
const availableLabels = availableProviders.map((provider) => providerStatusLabel[provider]).join(", ");
|
|
8129
|
+
const defaultLabels = defaultProviderIds.map((provider) => providerStatusLabel[provider]).join(", ");
|
|
8130
|
+
throw new Error(
|
|
8131
|
+
`No usage data found for available providers (${availableLabels}). Preferred order is ${defaultLabels}. Use --all or specify providers explicitly.`
|
|
8132
|
+
);
|
|
8133
|
+
}
|
|
7357
8134
|
throw new Error(
|
|
7358
|
-
"No usage data found for
|
|
8135
|
+
"No usage data found for any provider."
|
|
7359
8136
|
);
|
|
7360
8137
|
}
|
|
7361
8138
|
return providersToRender.map((provider) => rowsByProvider[provider]);
|
|
@@ -7388,7 +8165,9 @@ async function main() {
|
|
|
7388
8165
|
all: { type: "boolean", default: false },
|
|
7389
8166
|
claude: { type: "boolean", default: false },
|
|
7390
8167
|
codex: { type: "boolean", default: false },
|
|
7391
|
-
|
|
8168
|
+
cursor: { type: "boolean", default: false },
|
|
8169
|
+
opencode: { type: "boolean", default: false },
|
|
8170
|
+
pi: { type: "boolean", default: false }
|
|
7392
8171
|
},
|
|
7393
8172
|
allowPositionals: false
|
|
7394
8173
|
});
|
|
@@ -7408,6 +8187,7 @@ async function main() {
|
|
|
7408
8187
|
const format = inferFormat(values.format, values.output);
|
|
7409
8188
|
const requestedProviders = values.all ? providerIds : getRequestedProviders(values);
|
|
7410
8189
|
const inspectedProviders = requestedProviders.length > 0 ? requestedProviders : providerIds;
|
|
8190
|
+
const availabilityByProvider = await getProviderAvailability(inspectedProviders);
|
|
7411
8191
|
const { rowsByProvider, warnings } = await aggregateUsage({
|
|
7412
8192
|
start,
|
|
7413
8193
|
end,
|
|
@@ -7418,13 +8198,14 @@ async function main() {
|
|
|
7418
8198
|
process.stderr.write(`${warning}
|
|
7419
8199
|
`);
|
|
7420
8200
|
}
|
|
7421
|
-
printProviderAvailability(
|
|
8201
|
+
printProviderAvailability(availabilityByProvider, inspectedProviders);
|
|
7422
8202
|
const exportProviders = getOutputProviders(
|
|
7423
8203
|
values,
|
|
8204
|
+
availabilityByProvider,
|
|
7424
8205
|
rowsByProvider,
|
|
7425
8206
|
end
|
|
7426
8207
|
);
|
|
7427
|
-
const outputPath =
|
|
8208
|
+
const outputPath = resolve6(
|
|
7428
8209
|
values.output ?? `./heatmap-last-year.${format}`
|
|
7429
8210
|
);
|
|
7430
8211
|
mkdirSync(dirname(outputPath), { recursive: true });
|