@ipv9/tokentracker-cli 0.39.46 → 0.39.48

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 (26) hide show
  1. package/dashboard/dist/assets/{Card-Bk5BMQ2P.js → Card-CQ0G1GlR.js} +1 -1
  2. package/dashboard/dist/assets/{DashboardPage-DPP44kwE.js → DashboardPage-B5aBShIc.js} +1 -1
  3. package/dashboard/dist/assets/{FadeIn-CWvnEI0O.js → FadeIn-BG37Pb1J.js} +1 -1
  4. package/dashboard/dist/assets/{IpCheckPage-BmSn2zKd.js → IpCheckPage-DD2nObWJ.js} +1 -1
  5. package/dashboard/dist/assets/{LimitsPage-CDCccsH_.js → LimitsPage-L55ZcbUp.js} +1 -1
  6. package/dashboard/dist/assets/{LocalOnlyNotice-D19MhLRF.js → LocalOnlyNotice-3-SG-oKl.js} +1 -1
  7. package/dashboard/dist/assets/{PopoverPopup-D78fYtAs.js → PopoverPopup--Imnw4O7.js} +1 -1
  8. package/dashboard/dist/assets/{Select-CKV1Rvl0.js → Select-BNQ73B60.js} +1 -1
  9. package/dashboard/dist/assets/{SelectItemText-5eDkx616.js → SelectItemText-Dlv4NEpN.js} +1 -1
  10. package/dashboard/dist/assets/{SettingsPage-DIwe50pL.js → SettingsPage-BDo6aFgs.js} +1 -1
  11. package/dashboard/dist/assets/{SkillsPage-zUzRrWak.js → SkillsPage-CA2IaWI5.js} +1 -1
  12. package/dashboard/dist/assets/{WidgetsPage-3yOTNqKD.js → WidgetsPage-DTVOLZEy.js} +1 -1
  13. package/dashboard/dist/assets/{WrappedPage-DPAnbLdi.js → WrappedPage-CYwBaHuQ.js} +1 -1
  14. package/dashboard/dist/assets/{arrow-up-right-BGg2lx0L.js → arrow-up-right-3x5AJIbb.js} +1 -1
  15. package/dashboard/dist/assets/{download-BU4HPPvG.js → download-DSegD216.js} +1 -1
  16. package/dashboard/dist/assets/{format-vw28c71d.js → format-yTFTff0D.js} +1 -1
  17. package/dashboard/dist/assets/{limitDisplay-CY5Qlw5s.js → limitDisplay-DxvqWNgp.js} +1 -1
  18. package/dashboard/dist/assets/{main-D9Jo7Pt-.js → main-DBDTVhz8.js} +3 -3
  19. package/dashboard/dist/assets/{mock-data-B0oPPFnn.js → mock-data-oSiCcgVh.js} +1 -1
  20. package/dashboard/dist/assets/{use-limits-display-prefs-BAGMxrq7.js → use-limits-display-prefs-C2F0IlsU.js} +1 -1
  21. package/dashboard/dist/assets/{use-native-settings-C4lSH6qA.js → use-native-settings-DtQFof4g.js} +1 -1
  22. package/dashboard/dist/assets/{useCurrency-Dvz0184_.js → useCurrency-5LxVaA8B.js} +1 -1
  23. package/dashboard/dist/index.html +1 -1
  24. package/package.json +1 -1
  25. package/src/lib/pricing/seed-snapshot.json +1 -1
  26. package/src/lib/rollout.js +138 -24
@@ -3237,21 +3237,48 @@ function isUncPath(p) {
3237
3237
  return typeof p === "string" && (p.startsWith("\\\\") || p.startsWith("//"));
3238
3238
  }
3239
3239
 
3240
+ function sqliteDbChangeFingerprint(dbPath) {
3241
+ const parts = [];
3242
+ for (const suffix of ["", "-wal", "-shm", "-journal"]) {
3243
+ const filePath = dbPath + suffix;
3244
+ const label = suffix || "main";
3245
+ try {
3246
+ const stat = fssync.statSync(filePath, { bigint: true });
3247
+ parts.push(`${label}:${stat.size}:${stat.mtimeNs}`);
3248
+ } catch (error) {
3249
+ if (error && error.code === "ENOENT") {
3250
+ parts.push(`${label}:missing`);
3251
+ continue;
3252
+ }
3253
+ // A partial fingerprint must never authorize the no-change fast path.
3254
+ // Retry the read instead of treating repeated stat failures as stable.
3255
+ return null;
3256
+ }
3257
+ }
3258
+ return parts.join("|");
3259
+ }
3260
+
3240
3261
  function snapshotSqliteDb(dbPath) {
3241
3262
  const tmpRoot = fssync.mkdtempSync(
3242
3263
  path.join(require("node:os").tmpdir(), "tokentracker-hermes-snap-"),
3243
3264
  );
3244
3265
  const target = path.join(tmpRoot, path.basename(dbPath));
3245
3266
  fssync.copyFileSync(dbPath, target);
3246
- // Best-effort copy of SQLite sidecars; missing -wal/-shm/-journal is fine.
3267
+ // Copy SQLite sidecars as one logical snapshot. A missing sidecar is normal,
3268
+ // but any other copy failure makes the snapshot incomplete and must prevent
3269
+ // callers from advancing a change fingerprint based on stale main-DB data.
3270
+ let sidecarsComplete = true;
3247
3271
  for (const suffix of ["-wal", "-shm", "-journal"]) {
3248
3272
  const src = dbPath + suffix;
3249
3273
  try {
3250
- if (fssync.existsSync(src)) fssync.copyFileSync(src, target + suffix);
3251
- } catch (_e) { }
3274
+ fssync.copyFileSync(src, target + suffix);
3275
+ } catch (error) {
3276
+ if (!error || error.code !== "ENOENT") sidecarsComplete = false;
3277
+ }
3252
3278
  }
3253
3279
  return {
3254
3280
  path: target,
3281
+ sidecarsComplete,
3255
3282
  cleanup() {
3256
3283
  try { fssync.rmSync(tmpRoot, { recursive: true, force: true }); } catch (_e) { }
3257
3284
  },
@@ -3267,12 +3294,6 @@ function readHermesSessions(dbPath, lastCompletedEpoch, unfinishedSessionIds = [
3267
3294
  const forceIncludeSql = forceIds.length > 0
3268
3295
  ? ` OR id IN (${forceIds.map(sqliteStringLiteral).join(",")})`
3269
3296
  : "";
3270
- // Fetch sessions that started at/after the cursor, sessions that are still
3271
- // in-progress (ended_at IS NULL), OR sessions that were previously observed
3272
- // unfinished. Hermes updates token counts in real-time, including a final
3273
- // delta when an active session later gets ended_at set.
3274
- 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`;
3275
-
3276
3297
  let snapshot = null;
3277
3298
  let effectiveDbPath = dbPath;
3278
3299
  if (isUncPath(dbPath)) {
@@ -3284,6 +3305,65 @@ function readHermesSessions(dbPath, lastCompletedEpoch, unfinishedSessionIds = [
3284
3305
  // the non-locked case (e.g. permissions, transient I/O).
3285
3306
  }
3286
3307
  }
3308
+ const eligible = `(started_at >= ${since} OR ended_at IS NULL${forceIncludeSql})`;
3309
+ const modelUsageColumns = new Set(
3310
+ readSqliteJsonRows(
3311
+ effectiveDbPath,
3312
+ "PRAGMA table_info(session_model_usage)",
3313
+ { label: "Hermes", maxBuffer: 1024 * 1024, timeout: 5_000, ...sqliteOptions },
3314
+ ).map((column) => column?.name).filter(Boolean),
3315
+ );
3316
+ const hasModelUsage = [
3317
+ "session_id",
3318
+ "model",
3319
+ "api_call_count",
3320
+ "input_tokens",
3321
+ "output_tokens",
3322
+ "cache_read_tokens",
3323
+ "cache_write_tokens",
3324
+ "reasoning_tokens",
3325
+ "first_seen",
3326
+ "last_seen",
3327
+ ].every((column) => modelUsageColumns.has(column));
3328
+ const sql = hasModelUsage
3329
+ ? `WITH eligible_sessions AS (
3330
+ SELECT * FROM sessions WHERE ${eligible}
3331
+ )
3332
+ SELECT s.id, TRIM(u.model) AS model, s.model AS session_model, s.started_at, s.ended_at,
3333
+ SUM(u.input_tokens) AS input_tokens,
3334
+ SUM(u.output_tokens) AS output_tokens,
3335
+ SUM(u.cache_read_tokens) AS cache_read_tokens,
3336
+ SUM(u.cache_write_tokens) AS cache_write_tokens,
3337
+ SUM(u.reasoning_tokens) AS reasoning_tokens,
3338
+ SUM(u.api_call_count) AS message_count,
3339
+ MIN(u.first_seen) AS first_seen,
3340
+ MAX(u.last_seen) AS last_seen,
3341
+ 1 AS per_model
3342
+ FROM eligible_sessions s
3343
+ JOIN session_model_usage u ON u.session_id = s.id
3344
+ GROUP BY s.id, TRIM(u.model)
3345
+ HAVING SUM(u.input_tokens) > 0 OR SUM(u.output_tokens) > 0 OR SUM(u.cache_read_tokens) > 0 OR SUM(u.cache_write_tokens) > 0 OR SUM(u.reasoning_tokens) > 0
3346
+ UNION ALL
3347
+ SELECT s.id, s.model, s.model AS session_model, s.started_at, s.ended_at,
3348
+ s.input_tokens, s.output_tokens, s.cache_read_tokens,
3349
+ s.cache_write_tokens, s.reasoning_tokens, s.message_count,
3350
+ NULL AS first_seen, NULL AS last_seen, 0 AS per_model
3351
+ FROM eligible_sessions s
3352
+ WHERE NOT EXISTS (
3353
+ SELECT 1 FROM session_model_usage u
3354
+ WHERE u.session_id = s.id
3355
+ AND (u.input_tokens > 0 OR u.output_tokens > 0 OR u.cache_read_tokens > 0 OR u.cache_write_tokens > 0 OR u.reasoning_tokens > 0)
3356
+ )
3357
+ AND (s.input_tokens > 0 OR s.output_tokens > 0 OR s.cache_read_tokens > 0 OR s.cache_write_tokens > 0 OR s.reasoning_tokens > 0)
3358
+ ORDER BY 4 ASC`
3359
+ : `SELECT id, model, model AS session_model, started_at, ended_at,
3360
+ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
3361
+ reasoning_tokens, message_count, NULL AS first_seen,
3362
+ NULL AS last_seen, 0 AS per_model
3363
+ FROM sessions
3364
+ WHERE ${eligible}
3365
+ AND (input_tokens > 0 OR output_tokens > 0 OR cache_read_tokens > 0 OR cache_write_tokens > 0 OR reasoning_tokens > 0)
3366
+ ORDER BY started_at ASC`;
3287
3367
 
3288
3368
  try {
3289
3369
  return readSqliteJsonRows(effectiveDbPath, sql, {
@@ -3341,6 +3421,11 @@ async function parseHermesIncremental({ hermesPath, dbPath, cursors, queuePath,
3341
3421
  // Per-session snapshot from the previous sync: { [sessionId]: { in, out, cacheRead, cacheWrite, reasoning } }
3342
3422
  const prevSnapshots = (dbState.snapshots && typeof dbState.snapshots === "object")
3343
3423
  ? dbState.snapshots : {};
3424
+ const hasPerModelRows = rows.some((row) => Number(row.per_model) === 1);
3425
+ const adoptingPerModelState =
3426
+ hasPerModelRows &&
3427
+ dbState.modelUsageVersion !== 1 &&
3428
+ Object.keys(prevSnapshots).length > 0;
3344
3429
 
3345
3430
  // Only advance past sessions that have fully ended. Active sessions
3346
3431
  // (ended_at IS NULL) must be re-read every sync because Hermes updates
@@ -3361,10 +3446,22 @@ async function parseHermesIncremental({ hermesPath, dbPath, cursors, queuePath,
3361
3446
  const cacheWrite = toNonNegativeInt(row.cache_write_tokens);
3362
3447
  const reasoning = toNonNegativeInt(row.reasoning_tokens);
3363
3448
  const messageCount = toNonNegativeInt(row.message_count);
3364
- if (inputTokens === 0 && outputTokens === 0 && cacheRead === 0 && reasoning === 0) continue;
3449
+ if (inputTokens === 0 && outputTokens === 0 && cacheRead === 0 && cacheWrite === 0 && reasoning === 0) continue;
3365
3450
 
3366
- // Save current snapshot for next sync
3367
- nextSnapshots[row.id] = { in: inputTokens, out: outputTokens, cacheRead, cacheWrite, reasoning, message_count: messageCount };
3451
+ const perModel = Number(row.per_model) === 1;
3452
+ const model = normalizeModelInput(row.model) || "hermes-agent";
3453
+ const snapshotKey = perModel ? JSON.stringify([String(row.id || ""), model]) : row.id;
3454
+ const currentSnapshot = {
3455
+ in: inputTokens,
3456
+ out: outputTokens,
3457
+ cacheRead,
3458
+ cacheWrite,
3459
+ reasoning,
3460
+ message_count: messageCount,
3461
+ };
3462
+ // Save current snapshot for next sync. Mixed-model sessions are keyed by
3463
+ // both session and model so later growth is attributed independently.
3464
+ nextSnapshots[snapshotKey] = currentSnapshot;
3368
3465
 
3369
3466
  const startedAt = Number(row.started_at);
3370
3467
  const endedAt = row.ended_at == null ? null : Number(row.ended_at);
@@ -3380,7 +3477,10 @@ async function parseHermesIncremental({ hermesPath, dbPath, cursors, queuePath,
3380
3477
  // Compute delta from previous snapshot (if any) so that we only count
3381
3478
  // new usage since the last sync. First time we see a session the
3382
3479
  // previous snapshot is absent, so the full amount is the delta.
3383
- const prev = prevSnapshots[row.id];
3480
+ const legacySessionSnapshot = prevSnapshots[row.id];
3481
+ const prev = adoptingPerModelState && perModel && legacySessionSnapshot
3482
+ ? currentSnapshot
3483
+ : prevSnapshots[snapshotKey];
3384
3484
  let dInput = inputTokens;
3385
3485
  let dOutput = outputTokens;
3386
3486
  let dCacheRead = cacheRead;
@@ -3398,18 +3498,17 @@ async function parseHermesIncremental({ hermesPath, dbPath, cursors, queuePath,
3398
3498
  // Skip if delta is zero (session unchanged since last sync)
3399
3499
  if (dInput === 0 && dOutput === 0 && dCacheRead === 0 && dCacheWrite === 0 && dReasoning === 0) continue;
3400
3500
 
3401
- // A first observation has only the session start as a usable timestamp.
3402
- // Attribute later active-session deltas to this sync so cross-day usage
3403
- // does not keep growing the day on which the session originally started.
3404
- // Once Hermes records completion, ended_at is authoritative for the final delta.
3405
- const epochSec = endedAt ?? (prev ? Date.parse(updatedAt) / 1000 : startedAt);
3501
+ // Per-model rows carry the authoritative last API-call timestamp. Older
3502
+ // Hermes schemas fall back to the session-level start/end policy.
3503
+ const lastSeen = row.last_seen == null ? null : Number(row.last_seen);
3504
+ const epochSec = perModel && Number.isFinite(lastSeen) && lastSeen > 0
3505
+ ? lastSeen
3506
+ : endedAt ?? (prev ? Date.parse(updatedAt) / 1000 : startedAt);
3406
3507
  if (!epochSec || !Number.isFinite(epochSec)) continue;
3407
3508
  const tsIso = new Date(epochSec * 1000).toISOString();
3408
3509
  const bucketStart = toUtcHalfHourStart(tsIso);
3409
3510
  if (!bucketStart) continue;
3410
3511
 
3411
- const model = normalizeModelInput(row.model) || "hermes-agent";
3412
-
3413
3512
  const delta = {
3414
3513
  input_tokens: dInput,
3415
3514
  cached_input_tokens: dCacheRead,
@@ -3445,6 +3544,7 @@ async function parseHermesIncremental({ hermesPath, dbPath, cursors, queuePath,
3445
3544
  lastCompletedStartedAt: nextLastCompletedStartedAt,
3446
3545
  unfinishedSessionIds: Array.from(nextUnfinishedSessionIds),
3447
3546
  snapshots: nextSnapshots,
3547
+ ...(hasPerModelRows ? { modelUsageVersion: 1 } : {}),
3448
3548
  updatedAt,
3449
3549
  });
3450
3550
  }
@@ -6128,7 +6228,8 @@ async function parseGooseIncremental({
6128
6228
  ? { ...gooseState.sessionTotals }
6129
6229
  : {};
6130
6230
 
6131
- const cursorDbMtime = Number.isFinite(gooseState.lastDbMtimeMs) ? gooseState.lastDbMtimeMs : 0;
6231
+ const cursorDbFingerprint =
6232
+ typeof gooseState.lastDbFingerprint === "string" ? gooseState.lastDbFingerprint : null;
6132
6233
  let currentMtime = 0;
6133
6234
  try {
6134
6235
  currentMtime = fssync.statSync(resolvedDb).mtimeMs;
@@ -6139,9 +6240,12 @@ async function parseGooseIncremental({
6139
6240
  }
6140
6241
  throw e;
6141
6242
  }
6142
- // mtime short-circuit: skip the full sessions table scan when the DB
6143
- // hasn't been touched since the last sync.
6144
- if (currentMtime > 0 && currentMtime === cursorDbMtime) {
6243
+ const currentDbFingerprint = sqliteDbChangeFingerprint(resolvedDb);
6244
+ // Goose commonly commits active-session writes only to SQLite's WAL. Skip
6245
+ // the table scan only when the main DB and every sidecar are unchanged.
6246
+ // Legacy cursors without a fingerprint intentionally perform one read to
6247
+ // establish the sidecar-aware baseline.
6248
+ if (cursorDbFingerprint && currentDbFingerprint === cursorDbFingerprint) {
6145
6249
  cursors.goose = { ...gooseState, sessionTotals, updatedAt: observedAt };
6146
6250
  return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
6147
6251
  }
@@ -6151,6 +6255,15 @@ async function parseGooseIncremental({
6151
6255
  const snap = snapshotSqliteDb(resolvedDb);
6152
6256
  let rows = [];
6153
6257
  try {
6258
+ if (!snap.sidecarsComplete) {
6259
+ cursors.goose = {
6260
+ ...gooseState,
6261
+ sessionTotals,
6262
+ lastDbFingerprint: null,
6263
+ updatedAt: observedAt,
6264
+ };
6265
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
6266
+ }
6154
6267
  rows = readGooseSessionsFromSqlite(snap.path, sqliteOptions);
6155
6268
  } finally {
6156
6269
  snap.cleanup();
@@ -6271,6 +6384,7 @@ async function parseGooseIncremental({
6271
6384
  ...gooseState,
6272
6385
  sessionTotals,
6273
6386
  lastDbMtimeMs: currentMtime,
6387
+ lastDbFingerprint: currentDbFingerprint,
6274
6388
  updatedAt: observedAt,
6275
6389
  };
6276
6390