tokentracker-cli 0.18.1 → 0.20.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.
Files changed (43) hide show
  1. package/README.md +5 -4
  2. package/README.zh-CN.md +4 -3
  3. package/dashboard/dist/assets/{Card-8ZPdKuRR.js → Card-jA08WeEw.js} +1 -1
  4. package/dashboard/dist/assets/{DashboardPage-OVP6u_7i.js → DashboardPage-chDVOYmG.js} +1 -1
  5. package/dashboard/dist/assets/{FadeIn-BxnaPv7O.js → FadeIn-DqSYXuUL.js} +1 -1
  6. package/dashboard/dist/assets/{HeaderGithubStar-8z6DrTLD.js → HeaderGithubStar-C11rWv0B.js} +1 -1
  7. package/dashboard/dist/assets/{IpCheckPage-yagKgpi7.js → IpCheckPage-CkEZ9yLK.js} +1 -1
  8. package/dashboard/dist/assets/{LandingPage-Ca72J5F0.js → LandingPage-BgckTHRQ.js} +1 -1
  9. package/dashboard/dist/assets/{LeaderboardPage-CSmW4lBz.js → LeaderboardPage-BCNW7UWp.js} +1 -1
  10. package/dashboard/dist/assets/{LeaderboardProfilePage-BOihURRE.js → LeaderboardProfilePage-BLATxMt-.js} +1 -1
  11. package/dashboard/dist/assets/{LimitsPage-Bq4zB2w9.js → LimitsPage-arF--WgR.js} +1 -1
  12. package/dashboard/dist/assets/{LoginPage-CoB1ZkE6.js → LoginPage-DpoFP0va.js} +1 -1
  13. package/dashboard/dist/assets/{PopoverPopup-D7d5-v70.js → PopoverPopup-kdgc2H6C.js} +1 -1
  14. package/dashboard/dist/assets/{ProviderIcon-DzvUcjPu.js → ProviderIcon-DV5r9qqP.js} +1 -1
  15. package/dashboard/dist/assets/{SettingsPage-BeyW1iTj.js → SettingsPage-Bb22ORmU.js} +1 -1
  16. package/dashboard/dist/assets/{SkillsPage-B6auz1NO.js → SkillsPage-xhtBqVKC.js} +1 -1
  17. package/dashboard/dist/assets/{WidgetsPage-C9t8qw0F.js → WidgetsPage-CUoSVDET.js} +1 -1
  18. package/dashboard/dist/assets/{chevron-down-C8RgL-uJ.js → chevron-down-DYb2EChD.js} +1 -1
  19. package/dashboard/dist/assets/{download-C90EEqc8.js → download-C-_8o6dh.js} +1 -1
  20. package/dashboard/dist/assets/{leaderboard-columns-BgqTAms5.js → leaderboard-columns-BgzBlYo7.js} +1 -1
  21. package/dashboard/dist/assets/{main-DJcfmlDf.js → main-11hApDak.js} +6 -4
  22. package/dashboard/dist/assets/{use-limits-display-prefs-BUBBOUIF.js → use-limits-display-prefs-BeGKWUuk.js} +1 -1
  23. package/dashboard/dist/assets/{use-native-settings-CFUEzyoi.js → use-native-settings-nTTHktn0.js} +1 -1
  24. package/dashboard/dist/assets/{use-reduced-motion-NZDZrVKK.js → use-reduced-motion-DU8Gm6j1.js} +1 -1
  25. package/dashboard/dist/assets/{use-usage-limits-CoOOhZrW.js → use-usage-limits-DTPmEB8Y.js} +1 -1
  26. package/dashboard/dist/brand-logos/codebuddy.svg +1 -0
  27. package/dashboard/dist/brand-logos/every-code.svg +1 -0
  28. package/dashboard/dist/brand-logos/grok.svg +1 -0
  29. package/dashboard/dist/brand-logos/hermes.svg +1 -11
  30. package/dashboard/dist/brand-logos/kilo-code.svg +1 -0
  31. package/dashboard/dist/brand-logos/oh-my-pi.svg +1 -0
  32. package/dashboard/dist/index.html +1 -1
  33. package/dashboard/dist/share.html +1 -1
  34. package/package.json +1 -1
  35. package/src/commands/init.js +9 -5
  36. package/src/commands/serve.js +0 -12
  37. package/src/commands/sync.js +370 -7
  38. package/src/lib/grok-hook.js +86 -7
  39. package/src/lib/pricing/curated-overrides.json +1 -1
  40. package/src/lib/pricing/seed-snapshot.json +1 -1
  41. package/src/lib/rollout.js +438 -148
  42. package/src/lib/subscriptions.js +92 -40
  43. package/src/lib/usage-limits.js +1 -1
@@ -3005,18 +3005,61 @@ async function parseKiroIncremental({ dbPath, jsonlPath, cursors, queuePath, onP
3005
3005
  // Hermes Agent — SQLite-based (sessions table in ~/.hermes/state.db)
3006
3006
  // ─────────────────────────────────────────────────────────────────────────────
3007
3007
 
3008
- function resolveHermesDbPath() {
3008
+ function resolveHermesPath(env = process.env) {
3009
+ const override = env.TOKENTRACKER_HERMES_HOME;
3010
+ if (typeof override === "string" && override.trim().length > 0) {
3011
+ return override.trim();
3012
+ }
3009
3013
  const home = require("node:os").homedir();
3010
- return path.join(home, ".hermes", "state.db");
3014
+ return path.join(home, ".hermes");
3015
+ }
3016
+
3017
+ function resolveHermesDbPath(env = process.env) {
3018
+ return path.join(resolveHermesPath(env), "state.db");
3019
+ }
3020
+
3021
+ function resolveAllHermesDBPaths({ hermesPath, dbPath } = {}) {
3022
+ const hermesDir = hermesPath ?? (dbPath ? path.dirname(dbPath) : resolveHermesPath());
3023
+ const defaultDbPath = dbPath ?? path.join(hermesDir, "state.db");
3024
+ const profilePaths = {};
3025
+ try {
3026
+ const profilesDir = path.join(hermesDir, "profiles");
3027
+ const profiles = fssync.readdirSync(profilesDir, { withFileTypes: true })
3028
+ .filter((entry) => entry.isDirectory())
3029
+ .sort((a, b) => a.name.localeCompare(b.name));
3030
+
3031
+ for (const entry of profiles) {
3032
+ const dbPath = path.join(profilesDir, entry.name, "state.db");
3033
+ if (fssync.existsSync(dbPath)) {
3034
+ profilePaths[entry.name] = dbPath;
3035
+ }
3036
+ }
3037
+ } catch (_e) { }
3038
+
3039
+ return {
3040
+ default: fssync.existsSync(defaultDbPath) ? defaultDbPath : null,
3041
+ profiles: profilePaths,
3042
+ }
3043
+ }
3044
+
3045
+ function sqliteStringLiteral(value) {
3046
+ return `'${String(value).replace(/'/g, "''")}'`;
3011
3047
  }
3012
3048
 
3013
- function readHermesSessions(dbPath, lastCompletedEpoch) {
3049
+ function readHermesSessions(dbPath, lastCompletedEpoch, unfinishedSessionIds = []) {
3014
3050
  if (!dbPath || !fssync.existsSync(dbPath)) return [];
3015
3051
  const since = Number.isFinite(lastCompletedEpoch) && lastCompletedEpoch > 0 ? lastCompletedEpoch : 0;
3016
- // Fetch sessions that started after the cursor, OR sessions that are still
3017
- // in-progress (ended_at IS NULL). Hermes updates token counts in real-time,
3018
- // so an active session keeps growing and must be re-read on every sync.
3019
- const sql = `SELECT id, model, started_at, ended_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, message_count FROM sessions WHERE (started_at > ${since} OR ended_at IS NULL) AND (input_tokens > 0 OR output_tokens > 0 OR cache_read_tokens > 0 OR reasoning_tokens > 0) ORDER BY started_at ASC`;
3052
+ const forceIds = Array.isArray(unfinishedSessionIds)
3053
+ ? [...new Set(unfinishedSessionIds.filter((id) => typeof id === "string" && id.length > 0))]
3054
+ : [];
3055
+ const forceIncludeSql = forceIds.length > 0
3056
+ ? ` OR id IN (${forceIds.map(sqliteStringLiteral).join(",")})`
3057
+ : "";
3058
+ // Fetch sessions that started at/after the cursor, sessions that are still
3059
+ // in-progress (ended_at IS NULL), OR sessions that were previously observed
3060
+ // unfinished. Hermes updates token counts in real-time, including a final
3061
+ // delta when an active session later gets ended_at set.
3062
+ const sql = `SELECT id, model, started_at, ended_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, message_count FROM sessions WHERE (started_at >= ${since} OR ended_at IS NULL${forceIncludeSql}) AND (input_tokens > 0 OR output_tokens > 0 OR cache_read_tokens > 0 OR reasoning_tokens > 0) ORDER BY started_at ASC`;
3020
3063
  let raw;
3021
3064
  try {
3022
3065
  raw = cp.execFileSync("sqlite3", ["-json", dbPath, sql], {
@@ -3037,122 +3080,173 @@ function readHermesSessions(dbPath, lastCompletedEpoch) {
3037
3080
  return Array.isArray(rows) ? rows : [];
3038
3081
  }
3039
3082
 
3040
- async function parseHermesIncremental({ dbPath, cursors, queuePath, onProgress }) {
3083
+ function hasLegacyHermesDefaultState(hermesState) {
3084
+ return (
3085
+ typeof hermesState.lastStartedAt === "number" ||
3086
+ typeof hermesState.lastCompletedStartedAt === "number" ||
3087
+ (hermesState.snapshots && typeof hermesState.snapshots === "object")
3088
+ );
3089
+ }
3090
+
3091
+ async function parseHermesIncremental({ hermesPath, dbPath, cursors, queuePath, onProgress }) {
3041
3092
  await ensureDir(path.dirname(queuePath));
3042
3093
  const hermesState = cursors.hermes && typeof cursors.hermes === "object" ? cursors.hermes : {};
3043
3094
 
3044
- // Only advance past sessions that have fully ended. Active sessions
3045
- // (ended_at IS NULL) must be re-read every sync because Hermes updates
3046
- // their token counts in real-time after each turn.
3047
- const lastCompletedStartedAt =
3048
- typeof hermesState.lastCompletedStartedAt === "number" ? hermesState.lastCompletedStartedAt : 0;
3049
-
3050
- // Per-session snapshot from the previous sync: { [sessionId]: { in, out, cacheRead, cacheWrite, reasoning } }
3051
- const prevSnapshots = (hermesState.snapshots && typeof hermesState.snapshots === "object")
3052
- ? hermesState.snapshots : {};
3053
-
3054
- const resolvedDbPath = dbPath || resolveHermesDbPath();
3055
- if (!fssync.existsSync(resolvedDbPath)) {
3056
- return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
3057
- }
3058
-
3059
- const rows = readHermesSessions(resolvedDbPath, lastCompletedStartedAt);
3060
- if (rows.length === 0) {
3061
- cursors.hermes = { ...hermesState, lastCompletedStartedAt, updatedAt: new Date().toISOString() };
3095
+ const dbPaths = resolveAllHermesDBPaths({ hermesPath, dbPath });
3096
+ if (dbPaths.default === null && Object.keys(dbPaths.profiles).length === 0) {
3097
+ // No state in any profile
3062
3098
  return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
3063
3099
  }
3064
3100
 
3065
3101
  const hourlyState = normalizeHourlyState(cursors?.hourly);
3066
- const touchedBuckets = new Set();
3067
3102
  const cb = typeof onProgress === "function" ? onProgress : null;
3103
+ const updatedAt = new Date().toISOString();
3104
+ let recordsProcessed = 0;
3068
3105
  let eventsAggregated = 0;
3069
- let maxCompletedStartedAt = lastCompletedStartedAt;
3070
- const nextSnapshots = {};
3106
+ const touchedBuckets = new Set();
3071
3107
 
3072
- for (let i = 0; i < rows.length; i++) {
3073
- const row = rows[i];
3074
- const inputTokens = toNonNegativeInt(row.input_tokens);
3075
- const outputTokens = toNonNegativeInt(row.output_tokens);
3076
- const cacheRead = toNonNegativeInt(row.cache_read_tokens);
3077
- const cacheWrite = toNonNegativeInt(row.cache_write_tokens);
3078
- const reasoning = toNonNegativeInt(row.reasoning_tokens);
3079
- if (inputTokens === 0 && outputTokens === 0 && cacheRead === 0 && reasoning === 0) continue;
3080
-
3081
- // Save current snapshot for next sync
3082
- nextSnapshots[row.id] = { in: inputTokens, out: outputTokens, cacheRead, cacheWrite, reasoning };
3083
-
3084
- // Compute delta from previous snapshot (if any) so that we only count
3085
- // new tokens since the last sync. First time we see a session the
3086
- // previous snapshot is absent, so the full amount is the delta.
3087
- const prev = prevSnapshots[row.id];
3088
- let dInput = inputTokens;
3089
- let dOutput = outputTokens;
3090
- let dCacheRead = cacheRead;
3091
- let dCacheWrite = cacheWrite;
3092
- let dReasoning = reasoning;
3093
- if (prev) {
3094
- dInput = Math.max(0, inputTokens - (prev.in || 0));
3095
- dOutput = Math.max(0, outputTokens - (prev.out || 0));
3096
- dCacheRead = Math.max(0, cacheRead - (prev.cacheRead || 0));
3097
- dCacheWrite = Math.max(0, cacheWrite - (prev.cacheWrite || 0));
3098
- dReasoning = Math.max(0, reasoning - (prev.reasoning || 0));
3099
- }
3100
- // Skip if delta is zero (session unchanged since last sync)
3101
- if (dInput === 0 && dOutput === 0 && dCacheRead === 0 && dCacheWrite === 0 && dReasoning === 0) continue;
3102
-
3103
- // Prefer ended_at for bucket placement; fall back to started_at
3104
- const epochSec = row.ended_at || row.started_at;
3105
- if (!epochSec || !Number.isFinite(epochSec)) continue;
3106
- const tsIso = new Date(epochSec * 1000).toISOString();
3107
- const bucketStart = toUtcHalfHourStart(tsIso);
3108
- if (!bucketStart) continue;
3108
+ function ingestProfile(dbPath, dbState) {
3109
+ const trackedUnfinishedSessionIds = Array.isArray(dbState.unfinishedSessionIds)
3110
+ ? dbState.unfinishedSessionIds
3111
+ : [];
3112
+ const rows = readHermesSessions(dbPath, dbState.lastCompletedStartedAt, trackedUnfinishedSessionIds);
3113
+ recordsProcessed += rows.length;
3114
+ if (rows.length === 0) {
3115
+ dbState.updatedAt = updatedAt;
3116
+ return;
3117
+ }
3118
+
3119
+ // Per-session snapshot from the previous sync: { [sessionId]: { in, out, cacheRead, cacheWrite, reasoning } }
3120
+ const prevSnapshots = (dbState.snapshots && typeof dbState.snapshots === "object")
3121
+ ? dbState.snapshots : {};
3122
+
3123
+ // Only advance past sessions that have fully ended. Active sessions
3124
+ // (ended_at IS NULL) must be re-read every sync because Hermes updates
3125
+ // their token counts in real-time after each turn.
3126
+ const lastCompletedStartedAt =
3127
+ typeof dbState.lastCompletedStartedAt === "number" ? dbState.lastCompletedStartedAt : 0;
3128
+
3129
+ let maxCompletedStartedAt = lastCompletedStartedAt;
3130
+ let oldestUnfinishedStartedAt = Infinity;
3131
+ const nextUnfinishedSessionIds = new Set();
3132
+ const nextSnapshots = {};
3133
+
3134
+ for (let i = 0; i < rows.length; i++) {
3135
+ const row = rows[i];
3136
+ const inputTokens = toNonNegativeInt(row.input_tokens);
3137
+ const outputTokens = toNonNegativeInt(row.output_tokens);
3138
+ const cacheRead = toNonNegativeInt(row.cache_read_tokens);
3139
+ const cacheWrite = toNonNegativeInt(row.cache_write_tokens);
3140
+ const reasoning = toNonNegativeInt(row.reasoning_tokens);
3141
+ const messageCount = toNonNegativeInt(row.message_count);
3142
+ if (inputTokens === 0 && outputTokens === 0 && cacheRead === 0 && reasoning === 0) continue;
3143
+
3144
+ // Save current snapshot for next sync
3145
+ nextSnapshots[row.id] = { in: inputTokens, out: outputTokens, cacheRead, cacheWrite, reasoning, message_count: messageCount };
3146
+
3147
+ const startedAt = Number(row.started_at);
3148
+ const endedAt = row.ended_at == null ? null : Number(row.ended_at);
3149
+ if (endedAt == null) {
3150
+ if (row.id && Number.isFinite(startedAt)) {
3151
+ nextUnfinishedSessionIds.add(row.id);
3152
+ oldestUnfinishedStartedAt = Math.min(oldestUnfinishedStartedAt, startedAt);
3153
+ }
3154
+ } else if (Number.isFinite(startedAt) && startedAt > maxCompletedStartedAt) {
3155
+ maxCompletedStartedAt = startedAt;
3156
+ }
3109
3157
 
3110
- const model = normalizeModelInput(row.model) || "hermes-agent";
3158
+ // Compute delta from previous snapshot (if any) so that we only count
3159
+ // new usage since the last sync. First time we see a session the
3160
+ // previous snapshot is absent, so the full amount is the delta.
3161
+ const prev = prevSnapshots[row.id];
3162
+ let dInput = inputTokens;
3163
+ let dOutput = outputTokens;
3164
+ let dCacheRead = cacheRead;
3165
+ let dCacheWrite = cacheWrite;
3166
+ let dReasoning = reasoning;
3167
+ let dMessageCount = messageCount;
3168
+ if (prev) {
3169
+ dInput = Math.max(0, inputTokens - (prev.in || 0));
3170
+ dOutput = Math.max(0, outputTokens - (prev.out || 0));
3171
+ dCacheRead = Math.max(0, cacheRead - (prev.cacheRead || 0));
3172
+ dCacheWrite = Math.max(0, cacheWrite - (prev.cacheWrite || 0));
3173
+ dReasoning = Math.max(0, reasoning - (prev.reasoning || 0));
3174
+ dMessageCount = Math.max(0, messageCount - (prev.message_count || 0));
3175
+ }
3176
+ // Skip if delta is zero (session unchanged since last sync)
3177
+ if (dInput === 0 && dOutput === 0 && dCacheRead === 0 && dCacheWrite === 0 && dReasoning === 0) continue;
3111
3178
 
3112
- const delta = {
3113
- input_tokens: dInput,
3114
- cached_input_tokens: dCacheRead,
3115
- cache_creation_input_tokens: dCacheWrite,
3116
- output_tokens: dOutput,
3117
- reasoning_output_tokens: dReasoning,
3118
- total_tokens: dInput + dOutput + dCacheRead + dCacheWrite + dReasoning,
3119
- conversation_count: toNonNegativeInt(row.message_count) || 1,
3120
- };
3179
+ // Prefer ended_at for bucket placement; fall back to started_at
3180
+ const epochSec = endedAt ?? startedAt;
3181
+ if (!epochSec || !Number.isFinite(epochSec)) continue;
3182
+ const tsIso = new Date(epochSec * 1000).toISOString();
3183
+ const bucketStart = toUtcHalfHourStart(tsIso);
3184
+ if (!bucketStart) continue;
3121
3185
 
3122
- const bucket = getHourlyBucket(hourlyState, "hermes", model, bucketStart);
3123
- addTotals(bucket.totals, delta);
3124
- touchedBuckets.add(bucketKey("hermes", model, bucketStart));
3125
- eventsAggregated++;
3186
+ const model = normalizeModelInput(row.model) || "hermes-agent";
3126
3187
 
3127
- // Only advance cursor past sessions that have ended
3128
- if (row.ended_at && row.started_at > maxCompletedStartedAt) {
3129
- maxCompletedStartedAt = row.started_at;
3130
- }
3188
+ const delta = {
3189
+ input_tokens: dInput,
3190
+ cached_input_tokens: dCacheRead,
3191
+ cache_creation_input_tokens: dCacheWrite,
3192
+ output_tokens: dOutput,
3193
+ reasoning_output_tokens: dReasoning,
3194
+ total_tokens: dInput + dOutput + dCacheRead + dCacheWrite + dReasoning,
3195
+ conversation_count: dMessageCount,
3196
+ };
3131
3197
 
3132
- if (cb) {
3133
- cb({
3134
- index: i + 1,
3135
- total: rows.length,
3136
- recordsProcessed: i + 1,
3137
- eventsAggregated,
3138
- bucketsQueued: touchedBuckets.size,
3139
- });
3198
+ const bucket = getHourlyBucket(hourlyState, "hermes", model, bucketStart);
3199
+ addTotals(bucket.totals, delta);
3200
+ touchedBuckets.add(bucketKey("hermes", model, bucketStart));
3201
+ eventsAggregated++;
3202
+
3203
+ if (cb) {
3204
+ cb({
3205
+ index: i + 1,
3206
+ total: rows.length,
3207
+ recordsProcessed: i + 1,
3208
+ eventsAggregated,
3209
+ bucketsQueued: touchedBuckets.size,
3210
+ });
3211
+ }
3140
3212
  }
3213
+
3214
+ const nextLastCompletedStartedAt = Number.isFinite(oldestUnfinishedStartedAt)
3215
+ ? Math.min(maxCompletedStartedAt, oldestUnfinishedStartedAt)
3216
+ : maxCompletedStartedAt;
3217
+
3218
+ Object.assign(dbState, {
3219
+ lastStartedAt: nextLastCompletedStartedAt,
3220
+ lastCompletedStartedAt: nextLastCompletedStartedAt,
3221
+ unfinishedSessionIds: Array.from(nextUnfinishedSessionIds),
3222
+ snapshots: nextSnapshots,
3223
+ updatedAt,
3224
+ });
3225
+ }
3226
+
3227
+ if (dbPaths.default) {
3228
+ ingestProfile(dbPaths.default, hermesState);
3229
+ }
3230
+
3231
+ hermesState.profiles = hermesState.profiles && typeof hermesState.profiles === "object" ? hermesState.profiles : {};
3232
+
3233
+ for (const [profileName, dbPath] of Object.entries(dbPaths.profiles)) {
3234
+ const profileState = hermesState.profiles[profileName] && typeof hermesState.profiles[profileName] === "object"
3235
+ ? hermesState.profiles[profileName]
3236
+ : {};
3237
+ hermesState.profiles[profileName] = profileState;
3238
+ ingestProfile(dbPath, profileState);
3141
3239
  }
3142
3240
 
3143
3241
  const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
3144
- const updatedAt = new Date().toISOString();
3145
3242
  hourlyState.updatedAt = updatedAt;
3146
3243
  cursors.hourly = hourlyState;
3147
3244
  cursors.hermes = {
3148
3245
  ...hermesState,
3149
- lastStartedAt: maxCompletedStartedAt, // keep for backward compat
3150
- lastCompletedStartedAt: maxCompletedStartedAt,
3151
- snapshots: nextSnapshots,
3152
- updatedAt,
3246
+ updatedAt, // Update the overall profile state timestamp even if the DB doesn't exist for the fast-path check
3153
3247
  };
3154
3248
 
3155
- return { recordsProcessed: rows.length, eventsAggregated, bucketsQueued };
3249
+ return { recordsProcessed, eventsAggregated, bucketsQueued };
3156
3250
  }
3157
3251
 
3158
3252
  // ─────────────────────────────────────────────────────────────────────────────
@@ -5893,15 +5987,15 @@ async function parseCopilotIncremental({ otelPaths, cursors, queuePath, onProgre
5893
5987
  }
5894
5988
 
5895
5989
  // ─────────────────────────────────────────────────────────────────────────────
5896
- // Grok Build (xAI) — passive reader for ~/.grok/sessions/**/signals.json + summary.json
5990
+ // Grok Build (xAI) — passive reader for ~/.grok/sessions/**/updates.jsonl + signals.json
5897
5991
  // Triggered either by full scan in sync or by the SessionEnd hook writing a signal.
5898
- // Grok exposes contextTokensUsed, which appears to be a snapshot rather than
5899
- // per-call telemetry, so these rows are estimates and only enqueue observed
5900
- // increases for a session.
5992
+ // updates.jsonl exposes cumulative totalTokens metadata. Grok still does not
5993
+ // expose a stable prompt/output/cache split locally, so these rows keep the
5994
+ // estimated input/output split while using better local telemetry for totals.
5901
5995
  // ─────────────────────────────────────────────────────────────────────────────
5902
5996
 
5903
5997
  const GROK_ESTIMATED_INPUT_RATIO = 0.8;
5904
- const GROK_CURSOR_VERSION = 2;
5998
+ const GROK_CURSOR_VERSION = 3;
5905
5999
 
5906
6000
  function resolveGrokBuildHome(env = process.env) {
5907
6001
  return (
@@ -5936,9 +6030,11 @@ function resolveGrokBuildSessions(env = process.env) {
5936
6030
  for (const sid of sessionIds) {
5937
6031
  const sessionDir = path.join(cwdPath, sid);
5938
6032
  const signalsPath = path.join(sessionDir, "signals.json");
5939
- if (fssync.existsSync(signalsPath)) {
6033
+ const updatesPath = path.join(sessionDir, "updates.jsonl");
6034
+ if (fssync.existsSync(signalsPath) || fssync.existsSync(updatesPath)) {
5940
6035
  results.push({
5941
6036
  sessionDir,
6037
+ updatesPath,
5942
6038
  signalsPath,
5943
6039
  summaryPath: path.join(sessionDir, "summary.json"),
5944
6040
  sessionId: sid,
@@ -5961,7 +6057,11 @@ function normalizeGrokSessionSnapshots(grokState) {
5961
6057
  totalTokens,
5962
6058
  messageCount: normalizeNonNegativeNumber(snapshot.messageCount),
5963
6059
  model: normalizeModelInput(snapshot.model) || null,
6060
+ source: normalizeModelInput(snapshot.source) || null,
6061
+ lastEventId: normalizeModelInput(snapshot.lastEventId) || null,
6062
+ lastEventTimestamp: normalizeModelInput(snapshot.lastEventTimestamp) || null,
5964
6063
  updatedAt: normalizeModelInput(snapshot.updatedAt) || null,
6064
+ legacySeen: snapshot.legacySeen === true,
5965
6065
  };
5966
6066
  }
5967
6067
  }
@@ -5971,7 +6071,7 @@ function normalizeGrokSessionSnapshots(grokState) {
5971
6071
  const safeSessionId = normalizeModelInput(sessionId);
5972
6072
  if (!safeSessionId || snapshots[safeSessionId]) continue;
5973
6073
  snapshots[safeSessionId] = {
5974
- totalTokens: Number.MAX_SAFE_INTEGER,
6074
+ totalTokens: 0,
5975
6075
  messageCount: 0,
5976
6076
  model: null,
5977
6077
  updatedAt: normalizeModelInput(grokState.updatedAt) || null,
@@ -5998,6 +6098,14 @@ function readGrokJsonFile(filePath) {
5998
6098
  }
5999
6099
  }
6000
6100
 
6101
+ function grokUpdatesPathForSession(sess) {
6102
+ if (typeof sess?.updatesPath === "string" && sess.updatesPath.trim()) return sess.updatesPath;
6103
+ if (typeof sess?.sessionDir === "string" && sess.sessionDir.trim()) {
6104
+ return path.join(sess.sessionDir, "updates.jsonl");
6105
+ }
6106
+ return null;
6107
+ }
6108
+
6001
6109
  function grokSessionIdFor(sess) {
6002
6110
  return (
6003
6111
  normalizeModelInput(sess?.sessionId) ||
@@ -6025,11 +6133,113 @@ function grokLastActiveFromSignals(signals, summary) {
6025
6133
  );
6026
6134
  }
6027
6135
 
6028
- function estimateGrokTokenDelta(totalTokens, conversationCount) {
6136
+ function grokMessageCountFromSignals(signals) {
6137
+ return normalizeNonNegativeNumber(
6138
+ signals?.assistantMessageCount ??
6139
+ signals?.turnCount ??
6140
+ signals?.num_chat_messages ??
6141
+ signals?.messageCount,
6142
+ );
6143
+ }
6144
+
6145
+ function grokEffectiveTotalFromSignals(signals) {
6146
+ if (!signals || typeof signals !== "object") return 0;
6147
+ const beforeCompaction = normalizeNonNegativeNumber(signals.totalTokensBeforeCompaction);
6148
+ const totalTokens = normalizeNonNegativeNumber(signals.totalTokens);
6149
+ if (signals.contextTokensUsed == null) {
6150
+ return beforeCompaction + totalTokens;
6151
+ }
6152
+ return Math.max(
6153
+ totalTokens,
6154
+ beforeCompaction + normalizeNonNegativeNumber(signals.contextTokensUsed),
6155
+ );
6156
+ }
6157
+
6158
+ function grokTimestampToIso(value) {
6159
+ if (value == null) return null;
6160
+ if (typeof value === "number") {
6161
+ if (!Number.isFinite(value) || value <= 0) return null;
6162
+ const millis = value < 10_000_000_000 ? value * 1000 : value;
6163
+ const dt = new Date(millis);
6164
+ return Number.isFinite(dt.getTime()) ? dt.toISOString() : null;
6165
+ }
6166
+ if (typeof value === "string") {
6167
+ const trimmed = value.trim();
6168
+ if (!trimmed) return null;
6169
+ if (/^[0-9]+(?:\.[0-9]+)?$/.test(trimmed)) {
6170
+ return grokTimestampToIso(Number(trimmed));
6171
+ }
6172
+ const dt = new Date(trimmed);
6173
+ return Number.isFinite(dt.getTime()) ? dt.toISOString() : null;
6174
+ }
6175
+ return null;
6176
+ }
6177
+
6178
+ function grokTimestampFromUpdate(meta, record, fallback) {
6179
+ return (
6180
+ grokTimestampToIso(meta?.agentTimestampMs) ||
6181
+ grokTimestampToIso(meta?.timestampMs) ||
6182
+ grokTimestampToIso(record?.timestamp_ms) ||
6183
+ grokTimestampToIso(record?.timestamp) ||
6184
+ grokTimestampToIso(record?.time) ||
6185
+ fallback ||
6186
+ null
6187
+ );
6188
+ }
6189
+
6190
+ function grokEventId(value, fallback) {
6191
+ if (typeof value === "string" && value.trim()) return value.trim();
6192
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
6193
+ return fallback;
6194
+ }
6195
+
6196
+ async function readGrokUpdateTokenEvents(updatesPath, fallbackTimestamp) {
6197
+ if (!updatesPath) return { events: [], recordsProcessed: 0 };
6198
+ try {
6199
+ const stat = fssync.statSync(updatesPath);
6200
+ if (!stat.isFile()) return { events: [], recordsProcessed: 0 };
6201
+ } catch {
6202
+ return { events: [], recordsProcessed: 0 };
6203
+ }
6204
+
6205
+ const events = [];
6206
+ let lineIndex = 0;
6207
+ const input = fssync.createReadStream(updatesPath, { encoding: "utf8" });
6208
+ const rl = readline.createInterface({ input, crlfDelay: Infinity });
6209
+ try {
6210
+ for await (const line of rl) {
6211
+ lineIndex++;
6212
+ if (!line || !line.trim()) continue;
6213
+ let record;
6214
+ try {
6215
+ record = JSON.parse(line);
6216
+ } catch {
6217
+ continue;
6218
+ }
6219
+ const meta = record?.params?._meta || record?._meta;
6220
+ if (!meta || typeof meta !== "object") continue;
6221
+ const totalTokens = normalizeNonNegativeNumber(meta.totalTokens);
6222
+ if (totalTokens <= 0) continue;
6223
+ const timestamp = grokTimestampFromUpdate(meta, record, fallbackTimestamp);
6224
+ events.push({
6225
+ totalTokens,
6226
+ timestamp,
6227
+ eventId: grokEventId(meta.eventId ?? record?.eventId ?? record?.id, String(lineIndex)),
6228
+ });
6229
+ }
6230
+ } catch {
6231
+ return { events, recordsProcessed: events.length };
6232
+ }
6233
+
6234
+ return { events, recordsProcessed: events.length };
6235
+ }
6236
+
6237
+ function estimateGrokTokenDelta(totalTokens, conversationCount, options = {}) {
6029
6238
  const total = Math.trunc(normalizeNonNegativeNumber(totalTokens));
6030
6239
  const inputTokens = Math.round(total * GROK_ESTIMATED_INPUT_RATIO);
6031
6240
  const outputTokens = Math.max(0, total - inputTokens);
6032
- const conversations = Math.max(1, Math.trunc(normalizeNonNegativeNumber(conversationCount)));
6241
+ const rawConversations = Math.trunc(normalizeNonNegativeNumber(conversationCount));
6242
+ const conversations = options.allowZeroConversationCount ? rawConversations : Math.max(1, rawConversations);
6033
6243
 
6034
6244
  return {
6035
6245
  input_tokens: inputTokens,
@@ -6062,51 +6272,103 @@ async function parseGrokBuildIncremental({
6062
6272
 
6063
6273
  let eventsAggregated = 0;
6064
6274
 
6065
- for (const sess of sessionList) {
6275
+ for (let index = 0; index < sessionList.length; index++) {
6276
+ const sess = sessionList[index];
6066
6277
  const sessionId = grokSessionIdFor(sess);
6067
- if (!sessionId) continue;
6278
+ if (!sessionId) {
6279
+ if (onProgress) onProgress({ index: index + 1, total: sessionList.length, bucketsQueued: touchedBuckets.size });
6280
+ continue;
6281
+ }
6068
6282
 
6069
6283
  const signals = sess?.signals && typeof sess.signals === "object"
6070
6284
  ? sess.signals
6071
6285
  : readGrokJsonFile(sess?.signalsPath);
6072
- if (!signals || typeof signals !== "object") continue;
6286
+ const safeSignals = signals && typeof signals === "object" ? signals : {};
6073
6287
 
6074
6288
  const summary = sess?.summary && typeof sess.summary === "object"
6075
6289
  ? sess.summary
6076
6290
  : readGrokJsonFile(sess?.summaryPath) || {};
6077
- const totalTokens = normalizeNonNegativeNumber(signals.contextTokensUsed ?? signals.totalTokens);
6078
- if (totalTokens <= 0) {
6079
- continue;
6080
- }
6081
-
6082
6291
  const previous = sessionSnapshots[sessionId] || {};
6083
6292
  const previousTotal = normalizeNonNegativeNumber(previous.totalTokens);
6084
- const deltaTokens = totalTokens > previousTotal ? totalTokens - previousTotal : 0;
6085
- if (deltaTokens <= 0) continue;
6086
-
6087
- const messageCount = normalizeNonNegativeNumber(
6088
- signals.assistantMessageCount ?? signals.num_chat_messages ?? signals.messageCount,
6089
- );
6090
6293
  const previousMessageCount = normalizeNonNegativeNumber(previous.messageCount);
6091
- const deltaMessageCount =
6092
- messageCount > previousMessageCount ? messageCount - previousMessageCount : 1;
6093
- const model = grokModelFromSignals(signals);
6094
- const lastActive = grokLastActiveFromSignals(signals, summary);
6095
- const hourStartStr = toUtcHalfHourStart(lastActive) || toUtcHalfHourStart(Date.now());
6096
- if (!hourStartStr) continue;
6097
-
6098
- const delta = estimateGrokTokenDelta(deltaTokens, deltaMessageCount);
6099
- const bucket = getHourlyBucket(hourlyState, "grok", model, hourStartStr);
6100
- addTotals(bucket.totals, delta);
6101
- touchedBuckets.add(bucketKey("grok", model, hourStartStr));
6102
-
6103
- eventsAggregated++;
6104
- sessionSnapshots[sessionId] = {
6105
- totalTokens: Math.max(previousTotal, totalTokens),
6106
- messageCount: Math.max(previousMessageCount, messageCount),
6107
- model,
6108
- updatedAt: new Date().toISOString(),
6294
+ const messageCount = grokMessageCountFromSignals(safeSignals);
6295
+ const model = grokModelFromSignals(safeSignals);
6296
+ const lastActive = grokLastActiveFromSignals(safeSignals, summary);
6297
+
6298
+ let highWatermark = previousTotal;
6299
+ let observedTotal = previousTotal;
6300
+ let tokenDeltaForSession = 0;
6301
+ let finalTouchedHourStart = null;
6302
+ let source = previous.source || null;
6303
+ let lastEventId = previous.lastEventId || null;
6304
+ let lastEventTimestamp = previous.lastEventTimestamp || null;
6305
+ const pendingTokenDeltas = [];
6306
+
6307
+ const recordTokenDelta = (deltaTokens, timestamp, deltaSource) => {
6308
+ const hourStartStr = toUtcHalfHourStart(timestamp) || toUtcHalfHourStart(Date.now());
6309
+ if (!hourStartStr) return false;
6310
+ pendingTokenDeltas.push({ deltaTokens, hourStartStr });
6311
+ tokenDeltaForSession += deltaTokens;
6312
+ finalTouchedHourStart = hourStartStr;
6313
+ source = deltaSource;
6314
+ lastEventTimestamp = timestamp || lastEventTimestamp;
6315
+ return true;
6109
6316
  };
6317
+
6318
+ const updates = await readGrokUpdateTokenEvents(grokUpdatesPathForSession(sess), lastActive);
6319
+ for (const event of updates.events) {
6320
+ observedTotal = Math.max(observedTotal, event.totalTokens);
6321
+ lastEventId = event.eventId || lastEventId;
6322
+ lastEventTimestamp = event.timestamp || lastEventTimestamp;
6323
+ if (event.totalTokens <= highWatermark) continue;
6324
+ const deltaTokens = event.totalTokens - highWatermark;
6325
+ highWatermark = event.totalTokens;
6326
+ recordTokenDelta(deltaTokens, event.timestamp || lastActive, "updates");
6327
+ }
6328
+
6329
+ const effectiveSignalTotal = grokEffectiveTotalFromSignals(safeSignals);
6330
+ observedTotal = Math.max(observedTotal, effectiveSignalTotal);
6331
+ if (effectiveSignalTotal > highWatermark) {
6332
+ const deltaTokens = effectiveSignalTotal - highWatermark;
6333
+ highWatermark = effectiveSignalTotal;
6334
+ recordTokenDelta(deltaTokens, lastActive, "signals");
6335
+ }
6336
+
6337
+ const finalTotal = Math.max(previousTotal, highWatermark, observedTotal);
6338
+ const legacyBaselineOnly = previous.legacySeen && previousTotal === 0 && finalTotal > 0;
6339
+ if (!legacyBaselineOnly) {
6340
+ for (const pending of pendingTokenDeltas) {
6341
+ const delta = estimateGrokTokenDelta(pending.deltaTokens, 0, { allowZeroConversationCount: true });
6342
+ const bucket = getHourlyBucket(hourlyState, "grok", model, pending.hourStartStr);
6343
+ addTotals(bucket.totals, delta);
6344
+ touchedBuckets.add(bucketKey("grok", model, pending.hourStartStr));
6345
+ eventsAggregated++;
6346
+ }
6347
+ }
6348
+
6349
+ if (!legacyBaselineOnly && tokenDeltaForSession > 0 && finalTouchedHourStart) {
6350
+ const deltaMessageCount =
6351
+ messageCount > previousMessageCount ? messageCount - previousMessageCount : 1;
6352
+ const bucket = getHourlyBucket(hourlyState, "grok", model, finalTouchedHourStart);
6353
+ addTotals(bucket.totals, { conversation_count: deltaMessageCount });
6354
+ touchedBuckets.add(bucketKey("grok", model, finalTouchedHourStart));
6355
+ }
6356
+
6357
+ if (finalTotal > 0 && (tokenDeltaForSession > 0 || previousTotal > 0 || legacyBaselineOnly)) {
6358
+ sessionSnapshots[sessionId] = {
6359
+ totalTokens: finalTotal,
6360
+ messageCount: Math.max(previousMessageCount, messageCount),
6361
+ model,
6362
+ source: source || previous.source || null,
6363
+ lastEventId,
6364
+ lastEventTimestamp,
6365
+ updatedAt: new Date().toISOString(),
6366
+ };
6367
+ }
6368
+
6369
+ if (onProgress) {
6370
+ onProgress({ index: index + 1, total: sessionList.length, bucketsQueued: touchedBuckets.size });
6371
+ }
6110
6372
  }
6111
6373
 
6112
6374
  const bucketsQueued = queuePath
@@ -6229,6 +6491,7 @@ async function parseAntigravityIncremental({
6229
6491
  const sameFile = prev && prev.inode === inode;
6230
6492
  const lastLine = sameFile ? Number(prev.lastLine || 0) : 0;
6231
6493
  const initialContextTokens = sameFile ? Number(prev.contextTokens || 0) : 0;
6494
+ const initialPrevContext = sameFile ? Number(prev.previousContextTokens || 0) : 0;
6232
6495
  const initialModel = sameFile && typeof prev.currentModel === "string" ? prev.currentModel : null;
6233
6496
 
6234
6497
  const projectContext = projectEnabled
@@ -6247,6 +6510,7 @@ async function parseAntigravityIncremental({
6247
6510
  filePath,
6248
6511
  lastLine,
6249
6512
  initialContextTokens,
6513
+ initialPrevContext,
6250
6514
  initialModel,
6251
6515
  hourlyState,
6252
6516
  touchedBuckets,
@@ -6263,6 +6527,7 @@ async function parseAntigravityIncremental({
6263
6527
  mtimeMs,
6264
6528
  lastLine: result.lastLine,
6265
6529
  contextTokens: result.contextTokens,
6530
+ previousContextTokens: result.previousContextTokens,
6266
6531
  currentModel: result.currentModel,
6267
6532
  updatedAt: new Date().toISOString(),
6268
6533
  };
@@ -6300,6 +6565,7 @@ async function parseAntigravityFile({
6300
6565
  filePath,
6301
6566
  lastLine,
6302
6567
  initialContextTokens,
6568
+ initialPrevContext,
6303
6569
  initialModel,
6304
6570
  hourlyState,
6305
6571
  touchedBuckets,
@@ -6311,7 +6577,13 @@ async function parseAntigravityFile({
6311
6577
  }) {
6312
6578
  const raw = await fs.readFile(filePath, "utf8").catch(() => "");
6313
6579
  if (!raw.trim()) {
6314
- return { lastLine: 0, eventsAggregated: 0, contextTokens: 0, currentModel: null };
6580
+ return {
6581
+ lastLine: 0,
6582
+ eventsAggregated: 0,
6583
+ contextTokens: 0,
6584
+ previousContextTokens: 0,
6585
+ currentModel: null,
6586
+ };
6315
6587
  }
6316
6588
 
6317
6589
  const lines = raw
@@ -6325,11 +6597,16 @@ async function parseAntigravityFile({
6325
6597
  const canResume =
6326
6598
  Number.isFinite(lastLine) && lastLine > 0 && lastLine <= lines.length;
6327
6599
  const cachedTokens = Number.isFinite(initialContextTokens) ? initialContextTokens : 0;
6600
+ const cachedPrev = Number.isFinite(initialPrevContext) ? initialPrevContext : 0;
6328
6601
  const cachedModel = typeof initialModel === "string" ? initialModel : null;
6329
6602
  const resumed = canResume && (cachedTokens > 0 || cachedModel !== null);
6330
6603
  const scanStart = resumed ? lastLine : 0;
6331
6604
  let currentModel = resumed ? cachedModel : null;
6332
6605
  let contextTokens = resumed ? cachedTokens : 0;
6606
+ // Snapshot of contextTokens at the last PLANNER_RESPONSE we billed for. Only
6607
+ // tokens accumulated AFTER that point count as new input on the next planner
6608
+ // call — prevents O(N²) double-counting of the full history every turn.
6609
+ let previousContextTokens = resumed ? cachedPrev : 0;
6333
6610
  let lastCompletedLine = Math.min(Number.isFinite(lastLine) ? lastLine : 0, lines.length);
6334
6611
 
6335
6612
  for (let i = scanStart; i < lines.length; i++) {
@@ -6375,22 +6652,28 @@ async function parseAntigravityFile({
6375
6652
 
6376
6653
  let model = currentModel || "antigravity-unknown";
6377
6654
  let delta = initTotals();
6655
+ let billedPlanner = false;
6378
6656
 
6379
6657
  if (parsed.type === "PLANNER_RESPONSE") {
6380
6658
  const content = typeof parsed.content === "string" ? parsed.content : "";
6381
6659
  const thinking = typeof parsed.thinking === "string" ? parsed.thinking : "";
6382
6660
 
6383
- delta.input_tokens = contextTokens;
6384
- delta.output_tokens =
6661
+ const inputDelta = Math.max(0, contextTokens - previousContextTokens);
6662
+ const outputTokens =
6385
6663
  antigravityValueTokens(content) + antigravityValueTokens(parsed.tool_calls);
6386
- delta.reasoning_output_tokens = antigravityValueTokens(thinking);
6387
- delta.total_tokens =
6388
- delta.input_tokens + delta.output_tokens + delta.reasoning_output_tokens;
6389
- delta.billable_total_tokens = delta.total_tokens;
6664
+ const reasoningTokens = antigravityValueTokens(thinking);
6665
+
6666
+ delta.input_tokens = inputDelta;
6667
+ delta.output_tokens = outputTokens;
6668
+ delta.reasoning_output_tokens = reasoningTokens;
6669
+ // Match the mainstream convention (Codebuddy / Kilocode / OMP / Hermes):
6670
+ // total_tokens = sum of every token column. No cache columns here.
6671
+ delta.total_tokens = inputDelta + outputTokens + reasoningTokens;
6390
6672
  delta.conversation_count = 1;
6673
+ billedPlanner = delta.total_tokens > 0;
6391
6674
  }
6392
6675
 
6393
- if (delta.total_tokens === 0) {
6676
+ if (!billedPlanner) {
6394
6677
  contextTokens += eventContextTokens;
6395
6678
  lastCompletedLine = i + 1;
6396
6679
  continue;
@@ -6412,6 +6695,11 @@ async function parseAntigravityFile({
6412
6695
  projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
6413
6696
  }
6414
6697
  eventsAggregated += 1;
6698
+ // Snapshot the pre-planner context first. The planner's own content+tool_calls
6699
+ // (eventContextTokens, added below) become part of the next turn's history,
6700
+ // so they MUST be billed as input on the next planner — don't fold them into
6701
+ // previousContextTokens or that history vanishes from the totals.
6702
+ previousContextTokens = contextTokens;
6415
6703
  contextTokens += eventContextTokens;
6416
6704
  lastCompletedLine = i + 1;
6417
6705
  }
@@ -6420,6 +6708,7 @@ async function parseAntigravityFile({
6420
6708
  lastLine: lastCompletedLine,
6421
6709
  eventsAggregated,
6422
6710
  contextTokens,
6711
+ previousContextTokens,
6423
6712
  currentModel,
6424
6713
  };
6425
6714
  }
@@ -6505,6 +6794,7 @@ module.exports = {
6505
6794
  readOpencodeDbMessages,
6506
6795
  resolveKiroDbPath,
6507
6796
  resolveKiroJsonlPath,
6797
+ resolveHermesPath,
6508
6798
  resolveHermesDbPath,
6509
6799
  resolveCopilotOtelPaths,
6510
6800
  parseRolloutIncremental,
@@ -6562,7 +6852,7 @@ module.exports = {
6562
6852
  canonicalizeProjectRef,
6563
6853
  deriveProjectKeyFromRef,
6564
6854
 
6565
- // Grok Build (xAI) — SessionEnd hook + passive signals.json reader
6855
+ // Grok Build (xAI) — SessionEnd hook + passive updates.jsonl/signals.json reader
6566
6856
  resolveGrokBuildHome,
6567
6857
  resolveGrokBuildSessions,
6568
6858
  parseGrokBuildIncremental,