@ipv9/tokentracker-cli 0.39.51 → 0.39.52

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 +7 -14
  2. package/dashboard/dist/assets/{Card-B8V3fjn0.js → Card-CEz9362Q.js} +1 -1
  3. package/dashboard/dist/assets/DashboardPage-BEzDx9x_.js +60 -0
  4. package/dashboard/dist/assets/{IpCheckPage-CiTeykAr.js → IpCheckPage-COYl0L0c.js} +1 -1
  5. package/dashboard/dist/assets/LimitsPage-CxKP9yCf.js +2 -0
  6. package/dashboard/dist/assets/LocalOnlyNotice-mkvVeaxU.js +1 -0
  7. package/dashboard/dist/assets/PopoverPopup-CcXnjekJ.js +1 -0
  8. package/dashboard/dist/assets/Select-DfLcXuzD.js +1 -0
  9. package/dashboard/dist/assets/SettingsPage-BKATdxHm.js +1 -0
  10. package/dashboard/dist/assets/SkillsPage-BCS1cw7N.js +1 -0
  11. package/dashboard/dist/assets/{WrappedPage-TYb3h53L.js → WrappedPage-BZMXgdYx.js} +1 -1
  12. package/dashboard/dist/assets/{format-RxRKEKOo.js → format-DXnZAXRS.js} +1 -1
  13. package/dashboard/dist/assets/limitDisplay-Dkfaklpc.js +1 -0
  14. package/dashboard/dist/assets/main-C7rJ1TVR.css +1 -0
  15. package/dashboard/dist/assets/main-Dft1sGV7.js +610 -0
  16. package/dashboard/dist/assets/{mock-data-BSY76wog.js → mock-data-u9UDfqcX.js} +1 -1
  17. package/dashboard/dist/assets/use-limits-display-prefs-Nj1Nil2m.js +1 -0
  18. package/dashboard/dist/assets/useCurrency-BxAIQJRl.js +12 -0
  19. package/dashboard/dist/index.html +2 -18
  20. package/package.json +2 -4
  21. package/src/cli.js +1 -1
  22. package/src/commands/sync.js +4 -0
  23. package/src/lib/local-api.js +35 -10
  24. package/src/lib/pricing/seed-snapshot.json +1 -1
  25. package/src/lib/rollout.js +429 -27
  26. package/dashboard/dist/assets/DashboardPage-CY-J38sv.js +0 -60
  27. package/dashboard/dist/assets/FadeIn-CHDoW7U_.js +0 -1
  28. package/dashboard/dist/assets/LimitsPage-CeL9bJnV.js +0 -2
  29. package/dashboard/dist/assets/LocalOnlyNotice-Ci694JEz.js +0 -1
  30. package/dashboard/dist/assets/PopoverPopup-DuOdVhwr.js +0 -1
  31. package/dashboard/dist/assets/Select-CmEJ1f4r.js +0 -1
  32. package/dashboard/dist/assets/SelectItemText-DLwQC1NR.js +0 -12
  33. package/dashboard/dist/assets/SettingsPage-BY-B7Zy8.js +0 -1
  34. package/dashboard/dist/assets/SkillsPage-Cf3XZg8f.js +0 -1
  35. package/dashboard/dist/assets/WidgetsPage-DfOgT6--.js +0 -1
  36. package/dashboard/dist/assets/arrow-up-right-B03PmH0J.js +0 -1
  37. package/dashboard/dist/assets/download-B8nNpP2e.js +0 -1
  38. package/dashboard/dist/assets/limitDisplay-DD9iY3bZ.js +0 -1
  39. package/dashboard/dist/assets/main-BHmaQn5s.js +0 -700
  40. package/dashboard/dist/assets/main-t7dbBL4x.css +0 -1
  41. package/dashboard/dist/assets/use-limits-display-prefs-E_33VGNk.js +0 -1
  42. package/dashboard/dist/assets/use-native-settings-Jtj00HqH.js +0 -1
  43. package/dashboard/dist/assets/useCurrency-BjqazQG7.js +0 -1
@@ -4,7 +4,7 @@ const path = require("node:path");
4
4
  const readline = require("node:readline");
5
5
 
6
6
  const crypto = require("node:crypto");
7
- const { ensureDir } = require("./fs");
7
+ const { ensureDir, writeFileAtomic } = require("./fs");
8
8
  const {
9
9
  readSqliteJsonRows,
10
10
  readSqliteJsonRowsWithStatus,
@@ -460,6 +460,7 @@ async function parseOpencodeIncremental({
460
460
  onProgress,
461
461
  source,
462
462
  publicRepoResolver,
463
+ nowMs = Date.now(),
463
464
  }) {
464
465
  await ensureDir(path.dirname(queuePath));
465
466
  let filesProcessed = 0;
@@ -555,6 +556,7 @@ async function parseOpencodeIncremental({
555
556
  projectTouchedBuckets,
556
557
  projectRef,
557
558
  projectKey,
559
+ nowMs: mtimeMs || nowMs,
558
560
  });
559
561
 
560
562
  cursors.files[key] = {
@@ -1178,6 +1180,7 @@ async function parseOpencodeMessageFile({
1178
1180
  projectTouchedBuckets,
1179
1181
  projectRef,
1180
1182
  projectKey,
1183
+ nowMs = Date.now(),
1181
1184
  }) {
1182
1185
  const fallbackKey =
1183
1186
  typeof fallbackMessageKey === "string" && fallbackMessageKey.trim()
@@ -1227,9 +1230,10 @@ async function parseOpencodeMessageFile({
1227
1230
  if (!delta || isAllZeroUsage(delta)) {
1228
1231
  return { messageKey, lastTotals: currentTotals, eventsAggregated: 0, shouldUpdate: true };
1229
1232
  }
1230
- delta.conversation_count = 1;
1233
+ delta.conversation_count = lastTotals ? 0 : 1;
1231
1234
 
1232
- const timestampMs = coerceEpochMs(msg?.time?.completed) || coerceEpochMs(msg?.time?.created);
1235
+ const sourceTimestampMs = coerceEpochMs(msg?.time?.completed) || coerceEpochMs(msg?.time?.created);
1236
+ const timestampMs = lastTotals ? nowMs : sourceTimestampMs;
1233
1237
  if (!timestampMs) {
1234
1238
  return {
1235
1239
  messageKey,
@@ -1437,6 +1441,7 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
1437
1441
  total_tokens: totals.total_tokens,
1438
1442
  billable_total_tokens: totals.billable_total_tokens ?? totals.total_tokens,
1439
1443
  conversation_count: totals.conversation_count,
1444
+ ...queueCostFields(group.source, totals),
1440
1445
  }),
1441
1446
  );
1442
1447
  bucket.queuedKey = key;
@@ -1813,6 +1818,10 @@ function getHourlyBucket(state, source, model, hourStart) {
1813
1818
  let bucket = buckets[key];
1814
1819
  if (!bucket || typeof bucket !== "object") {
1815
1820
  bucket = { totals: initTotals(), queuedKey: null };
1821
+ if (normalizedSource === "hermes") {
1822
+ bucket.totals.actual_cost_usd = 0;
1823
+ bucket.totals.actual_cost_complete = true;
1824
+ }
1816
1825
  buckets[key] = bucket;
1817
1826
  return bucket;
1818
1827
  }
@@ -1820,6 +1829,13 @@ function getHourlyBucket(state, source, model, hourStart) {
1820
1829
  if (!bucket.totals || typeof bucket.totals !== "object") {
1821
1830
  bucket.totals = initTotals();
1822
1831
  }
1832
+ if (normalizedSource === "hermes" && !Object.hasOwn(bucket.totals, "actual_cost_complete")) {
1833
+ // Existing queues predate authoritative Hermes-cost support. Their past
1834
+ // token rows retain estimation rather than being relabelled actual when a
1835
+ // later delta lands in the same bucket.
1836
+ bucket.totals.actual_cost_usd = 0;
1837
+ bucket.totals.actual_cost_complete = false;
1838
+ }
1823
1839
 
1824
1840
  if (bucket.queuedKey != null && typeof bucket.queuedKey !== "string") {
1825
1841
  bucket.queuedKey = null;
@@ -1898,9 +1914,26 @@ function totalsKey(totals) {
1898
1914
  totals.total_tokens || 0,
1899
1915
  totals.billable_total_tokens ?? totals.total_tokens ?? 0,
1900
1916
  totals.conversation_count || 0,
1917
+ totals.actual_cost_complete === true ? "hermes-actual" : "",
1918
+ totals.actual_cost_complete === true ? totals.actual_cost_usd || 0 : "",
1901
1919
  ].join("|");
1902
1920
  }
1903
1921
 
1922
+ function queueCostFields(source, totals) {
1923
+ if (
1924
+ source === "hermes" &&
1925
+ totals?.actual_cost_complete === true &&
1926
+ Number.isFinite(Number(totals.actual_cost_usd)) &&
1927
+ Number(totals.actual_cost_usd) >= 0
1928
+ ) {
1929
+ return {
1930
+ actual_cost_usd: Number(totals.actual_cost_usd),
1931
+ cost_provenance: "hermes-actual",
1932
+ };
1933
+ }
1934
+ return {};
1935
+ }
1936
+
1904
1937
  function toUtcHalfHourStart(ts) {
1905
1938
  const dt = new Date(ts);
1906
1939
  if (!Number.isFinite(dt.getTime())) return null;
@@ -2617,6 +2650,7 @@ async function parseOpencodeDbIncremental({
2617
2650
  source,
2618
2651
  cursorKey,
2619
2652
  publicRepoResolver,
2653
+ nowMs = Date.now(),
2620
2654
  }) {
2621
2655
  await ensureDir(path.dirname(queuePath));
2622
2656
  let messagesProcessed = 0;
@@ -2683,9 +2717,13 @@ async function parseOpencodeDbIncremental({
2683
2717
  }
2684
2718
  continue;
2685
2719
  }
2686
- delta.conversation_count = 1;
2720
+ delta.conversation_count = lastTotals ? 0 : 1;
2687
2721
 
2688
- const timestampMs = coerceEpochMs(msg?.time?.completed) || coerceEpochMs(msg?.time?.created);
2722
+ const sourceTimestampMs = coerceEpochMs(msg?.time?.completed) || coerceEpochMs(msg?.time?.created);
2723
+ const rowUpdatedMs = coerceEpochMs(entry.timeUpdated || entry.time_updated);
2724
+ const timestampMs = lastTotals
2725
+ ? (rowUpdatedMs || nowMs)
2726
+ : sourceTimestampMs;
2689
2727
  if (!timestampMs) {
2690
2728
  messagesProcessed += 1;
2691
2729
  continue;
@@ -3325,6 +3363,23 @@ function readHermesSessions(dbPath, lastCompletedEpoch, unfinishedSessionIds = [
3325
3363
  "first_seen",
3326
3364
  "last_seen",
3327
3365
  ].every((column) => modelUsageColumns.has(column));
3366
+ const hasModelUsageCosts = [
3367
+ "estimated_cost_usd",
3368
+ "actual_cost_usd",
3369
+ "cost_status",
3370
+ "cost_source",
3371
+ ].every((column) => modelUsageColumns.has(column));
3372
+ const modelUsageCostSelect = hasModelUsageCosts
3373
+ ? `SUM(u.estimated_cost_usd) AS estimated_cost_usd,
3374
+ SUM(u.actual_cost_usd) AS actual_cost_usd,
3375
+ CASE WHEN SUM(CASE WHEN LOWER(COALESCE(u.cost_status, '')) = 'actual' THEN 0 ELSE 1 END) = 0
3376
+ THEN 'actual' ELSE NULL END AS cost_status,
3377
+ CASE WHEN SUM(CASE WHEN LOWER(COALESCE(u.cost_status, '')) = 'actual' THEN 0 ELSE 1 END) = 0
3378
+ THEN MAX(u.cost_source) ELSE NULL END AS cost_source,`
3379
+ : `NULL AS estimated_cost_usd,
3380
+ NULL AS actual_cost_usd,
3381
+ NULL AS cost_status,
3382
+ NULL AS cost_source,`;
3328
3383
  const sql = hasModelUsage
3329
3384
  ? `WITH eligible_sessions AS (
3330
3385
  SELECT * FROM sessions WHERE ${eligible}
@@ -3338,6 +3393,7 @@ function readHermesSessions(dbPath, lastCompletedEpoch, unfinishedSessionIds = [
3338
3393
  SUM(u.api_call_count) AS message_count,
3339
3394
  MIN(u.first_seen) AS first_seen,
3340
3395
  MAX(u.last_seen) AS last_seen,
3396
+ ${modelUsageCostSelect}
3341
3397
  1 AS per_model
3342
3398
  FROM eligible_sessions s
3343
3399
  JOIN session_model_usage u ON u.session_id = s.id
@@ -3347,7 +3403,9 @@ function readHermesSessions(dbPath, lastCompletedEpoch, unfinishedSessionIds = [
3347
3403
  SELECT s.id, s.model, s.model AS session_model, s.started_at, s.ended_at,
3348
3404
  s.input_tokens, s.output_tokens, s.cache_read_tokens,
3349
3405
  s.cache_write_tokens, s.reasoning_tokens, s.message_count,
3350
- NULL AS first_seen, NULL AS last_seen, 0 AS per_model
3406
+ NULL AS first_seen, NULL AS last_seen,
3407
+ NULL AS estimated_cost_usd, NULL AS actual_cost_usd,
3408
+ NULL AS cost_status, NULL AS cost_source, 0 AS per_model
3351
3409
  FROM eligible_sessions s
3352
3410
  WHERE NOT EXISTS (
3353
3411
  SELECT 1 FROM session_model_usage u
@@ -3359,7 +3417,9 @@ function readHermesSessions(dbPath, lastCompletedEpoch, unfinishedSessionIds = [
3359
3417
  : `SELECT id, model, model AS session_model, started_at, ended_at,
3360
3418
  input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
3361
3419
  reasoning_tokens, message_count, NULL AS first_seen,
3362
- NULL AS last_seen, 0 AS per_model
3420
+ NULL AS last_seen, NULL AS estimated_cost_usd,
3421
+ NULL AS actual_cost_usd, NULL AS cost_status,
3422
+ NULL AS cost_source, 0 AS per_model
3363
3423
  FROM sessions
3364
3424
  WHERE ${eligible}
3365
3425
  AND (input_tokens > 0 OR output_tokens > 0 OR cache_read_tokens > 0 OR cache_write_tokens > 0 OR reasoning_tokens > 0)
@@ -3377,6 +3437,257 @@ function readHermesSessions(dbPath, lastCompletedEpoch, unfinishedSessionIds = [
3377
3437
  }
3378
3438
  }
3379
3439
 
3440
+ const HERMES_MIXED_MODEL_RECONCILIATION_MIGRATION_KEY = "hermesMixedModelReconciliationV1";
3441
+ const HERMES_TOKEN_TOTAL_FIELDS = [
3442
+ "input_tokens",
3443
+ "cached_input_tokens",
3444
+ "cache_creation_input_tokens",
3445
+ "output_tokens",
3446
+ "reasoning_output_tokens",
3447
+ "total_tokens",
3448
+ "billable_total_tokens",
3449
+ ];
3450
+
3451
+ function hermesRowTotals(row) {
3452
+ const inputTokens = toNonNegativeInt(row.input_tokens);
3453
+ const outputTokens = toNonNegativeInt(row.output_tokens);
3454
+ const cacheRead = toNonNegativeInt(row.cache_read_tokens);
3455
+ const cacheWrite = toNonNegativeInt(row.cache_write_tokens);
3456
+ const reasoning = toNonNegativeInt(row.reasoning_tokens);
3457
+ return {
3458
+ input_tokens: inputTokens,
3459
+ cached_input_tokens: cacheRead,
3460
+ cache_creation_input_tokens: cacheWrite,
3461
+ output_tokens: outputTokens,
3462
+ reasoning_output_tokens: reasoning,
3463
+ total_tokens: inputTokens + outputTokens + cacheRead + cacheWrite + reasoning,
3464
+ billable_total_tokens: inputTokens + outputTokens + cacheRead + cacheWrite + reasoning,
3465
+ conversation_count: toNonNegativeInt(row.message_count),
3466
+ };
3467
+ }
3468
+
3469
+ function sameHermesTokenTotals(left, right) {
3470
+ return HERMES_TOKEN_TOTAL_FIELDS.every(
3471
+ (field) => Number(left?.[field] || 0) === Number(right?.[field] || 0),
3472
+ );
3473
+ }
3474
+
3475
+ function hermesBucketStartForRow(row, updatedAt) {
3476
+ const perModel = Number(row.per_model) === 1;
3477
+ const lastSeen = row.last_seen == null ? null : Number(row.last_seen);
3478
+ const endedAt = row.ended_at == null ? null : Number(row.ended_at);
3479
+ const startedAt = Number(row.started_at);
3480
+ const epochSec = perModel && Number.isFinite(lastSeen) && lastSeen > 0
3481
+ ? lastSeen
3482
+ : endedAt ?? (Number.isFinite(startedAt) ? startedAt : Date.parse(updatedAt) / 1000);
3483
+ if (!epochSec || !Number.isFinite(epochSec)) return null;
3484
+ return toUtcHalfHourStart(new Date(epochSec * 1000).toISOString());
3485
+ }
3486
+
3487
+ function prepareHermesProfileState(rows, previousState, updatedAt) {
3488
+ const state = previousState && typeof previousState === "object" ? { ...previousState } : {};
3489
+ const snapshots = {};
3490
+ const unfinishedSessionIds = new Set();
3491
+ let maxCompletedStartedAt = 0;
3492
+ let oldestUnfinishedStartedAt = Infinity;
3493
+
3494
+ for (const row of rows) {
3495
+ const totals = hermesRowTotals(row);
3496
+ if (totals.total_tokens === 0) continue;
3497
+ const perModel = Number(row.per_model) === 1;
3498
+ const model = normalizeModelInput(row.model) || "hermes-agent";
3499
+ const snapshotKey = perModel ? JSON.stringify([String(row.id || ""), model]) : row.id;
3500
+ snapshots[snapshotKey] = {
3501
+ in: totals.input_tokens,
3502
+ out: totals.output_tokens,
3503
+ cacheRead: totals.cached_input_tokens,
3504
+ cacheWrite: totals.cache_creation_input_tokens,
3505
+ reasoning: totals.reasoning_output_tokens,
3506
+ message_count: totals.conversation_count,
3507
+ };
3508
+ const startedAt = Number(row.started_at);
3509
+ const endedAt = row.ended_at == null ? null : Number(row.ended_at);
3510
+ if (endedAt == null) {
3511
+ if (row.id && Number.isFinite(startedAt)) {
3512
+ unfinishedSessionIds.add(row.id);
3513
+ oldestUnfinishedStartedAt = Math.min(oldestUnfinishedStartedAt, startedAt);
3514
+ }
3515
+ } else if (Number.isFinite(startedAt)) {
3516
+ maxCompletedStartedAt = Math.max(maxCompletedStartedAt, startedAt);
3517
+ }
3518
+ }
3519
+
3520
+ const nextLastCompletedStartedAt = Number.isFinite(oldestUnfinishedStartedAt)
3521
+ ? Math.min(maxCompletedStartedAt, oldestUnfinishedStartedAt)
3522
+ : maxCompletedStartedAt;
3523
+ return {
3524
+ ...state,
3525
+ lastStartedAt: nextLastCompletedStartedAt,
3526
+ lastCompletedStartedAt: nextLastCompletedStartedAt,
3527
+ unfinishedSessionIds: Array.from(unfinishedSessionIds),
3528
+ snapshots,
3529
+ modelUsageVersion: 1,
3530
+ updatedAt,
3531
+ };
3532
+ }
3533
+
3534
+ async function appendHermesReconciliationRows({ queuePath, queueStatePath, canonicalRows, updatedAt }) {
3535
+ if (typeof queueStatePath !== "string" || !queueStatePath) {
3536
+ throw new Error("Hermes reconciliation requires queueStatePath to append corrected rows after the upload cursor");
3537
+ }
3538
+
3539
+ let rawQueue = "";
3540
+ try {
3541
+ rawQueue = await fs.readFile(queuePath, "utf8");
3542
+ } catch (error) {
3543
+ if (error?.code !== "ENOENT") throw error;
3544
+ }
3545
+
3546
+ let state = {};
3547
+ try {
3548
+ const rawState = await fs.readFile(queueStatePath, "utf8");
3549
+ const parsed = JSON.parse(rawState);
3550
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) state = parsed;
3551
+ } catch (error) {
3552
+ if (error?.code !== "ENOENT") throw error;
3553
+ }
3554
+
3555
+ const offset = Number(state.offset);
3556
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > Buffer.byteLength(rawQueue, "utf8")) {
3557
+ throw new Error("Hermes reconciliation requires a valid queue upload offset");
3558
+ }
3559
+
3560
+ const append = `${canonicalRows.join("\n")}\n`;
3561
+ await ensureDir(path.dirname(queuePath));
3562
+ await fs.appendFile(queuePath, append, "utf8");
3563
+ state.updatedAt = updatedAt;
3564
+ state.note = "hermes_mixed_model_reconciliation_v1_appended_corrections";
3565
+ try {
3566
+ await writeFileAtomic(queueStatePath, JSON.stringify(state, null, 2) + "\n");
3567
+ } catch (error) {
3568
+ await fs.truncate(queuePath, Buffer.byteLength(rawQueue, "utf8"));
3569
+ throw error;
3570
+ }
3571
+ }
3572
+
3573
+ // One-time, fail-closed correction for the v0.39.48 adoption gate. That gate
3574
+ // intentionally avoided replaying legacy aggregate snapshots, which preserved
3575
+ // the grand total but left already-counted mixed-model usage on sessions.model.
3576
+ // Rebuild only when the authoritative Hermes model rows have exactly the same
3577
+ // token total as the existing Hermes accumulator; otherwise leave data intact
3578
+ // and let the normal incremental path catch up before retrying next sync.
3579
+ async function reconcileHermesMixedModelUsage({ dbPaths, cursors, queuePath, queueStatePath, hermesState, updatedAt, sqliteOptions }) {
3580
+ if (!cursors || typeof cursors !== "object") return { status: "invalid_cursor" };
3581
+ const migrations = cursors.migrations && typeof cursors.migrations === "object" ? cursors.migrations : {};
3582
+ const previous = migrations[HERMES_MIXED_MODEL_RECONCILIATION_MIGRATION_KEY];
3583
+ if (previous?.status === "applied") return previous;
3584
+
3585
+ const sources = [];
3586
+ if (dbPaths.default) sources.push({ profileName: null, dbPath: dbPaths.default });
3587
+ for (const [profileName, dbPath] of Object.entries(dbPaths.profiles || {})) {
3588
+ sources.push({ profileName, dbPath });
3589
+ }
3590
+ if (sources.length === 0) return { status: "no_source" };
3591
+
3592
+ const rowsBySource = [];
3593
+ for (const source of sources) {
3594
+ const rows = readHermesSessions(source.dbPath, 0, [], sqliteOptions);
3595
+ rowsBySource.push({ ...source, rows });
3596
+ }
3597
+ if (!rowsBySource.some(({ rows }) => rows.some((row) => Number(row.per_model) === 1))) {
3598
+ cursors.migrations ||= {};
3599
+ cursors.migrations[HERMES_MIXED_MODEL_RECONCILIATION_MIGRATION_KEY] = {
3600
+ status: "not_applicable",
3601
+ updatedAt,
3602
+ };
3603
+ return cursors.migrations[HERMES_MIXED_MODEL_RECONCILIATION_MIGRATION_KEY];
3604
+ }
3605
+
3606
+ const hourlyState = normalizeHourlyState(cursors.hourly);
3607
+ const existingTotals = initTotals();
3608
+ for (const [key, bucket] of Object.entries(hourlyState.buckets || {})) {
3609
+ if (parseBucketKey(key).source === "hermes") addTotals(existingTotals, bucket?.totals);
3610
+ }
3611
+
3612
+ const canonicalBuckets = {};
3613
+ const authoritativeTotals = initTotals();
3614
+ for (const { rows } of rowsBySource) {
3615
+ for (const row of rows) {
3616
+ const totals = hermesRowTotals(row);
3617
+ if (totals.total_tokens === 0) continue;
3618
+ const hourStart = hermesBucketStartForRow(row, updatedAt);
3619
+ if (!hourStart) continue;
3620
+ const model = normalizeModelInput(row.model) || "hermes-agent";
3621
+ const key = bucketKey("hermes", model, hourStart);
3622
+ const bucket = canonicalBuckets[key] ||= { totals: initTotals(), queuedKey: null };
3623
+ addTotals(bucket.totals, totals);
3624
+ addTotals(authoritativeTotals, totals);
3625
+ }
3626
+ }
3627
+
3628
+ if (!sameHermesTokenTotals(existingTotals, authoritativeTotals)) {
3629
+ cursors.migrations ||= {};
3630
+ cursors.migrations[HERMES_MIXED_MODEL_RECONCILIATION_MIGRATION_KEY] = {
3631
+ status: "blocked_total_mismatch",
3632
+ updatedAt,
3633
+ existingTotalTokens: existingTotals.total_tokens,
3634
+ authoritativeTotalTokens: authoritativeTotals.total_tokens,
3635
+ };
3636
+ return cursors.migrations[HERMES_MIXED_MODEL_RECONCILIATION_MIGRATION_KEY];
3637
+ }
3638
+
3639
+ for (const key of Object.keys(hourlyState.buckets || {})) {
3640
+ if (parseBucketKey(key).source === "hermes") delete hourlyState.buckets[key];
3641
+ }
3642
+ for (const key of Object.keys(hourlyState.groupQueued || {})) {
3643
+ if (key.startsWith("hermes|")) delete hourlyState.groupQueued[key];
3644
+ }
3645
+ const canonicalRows = [];
3646
+ for (const [key, bucket] of Object.entries(canonicalBuckets)) {
3647
+ const { model, hourStart } = parseBucketKey(key);
3648
+ bucket.queuedKey = totalsKey(bucket.totals);
3649
+ hourlyState.buckets[key] = bucket;
3650
+ canonicalRows.push(JSON.stringify({
3651
+ source: "hermes",
3652
+ model,
3653
+ hour_start: hourStart,
3654
+ ...bucket.totals,
3655
+ }));
3656
+ }
3657
+
3658
+ await appendHermesReconciliationRows({
3659
+ queuePath,
3660
+ queueStatePath,
3661
+ canonicalRows,
3662
+ updatedAt,
3663
+ });
3664
+
3665
+ const nextHermesState = hermesState && typeof hermesState === "object" ? hermesState : {};
3666
+ for (const { profileName, rows } of rowsBySource) {
3667
+ if (profileName == null) {
3668
+ Object.assign(nextHermesState, prepareHermesProfileState(rows, nextHermesState, updatedAt));
3669
+ } else {
3670
+ nextHermesState.profiles = nextHermesState.profiles && typeof nextHermesState.profiles === "object"
3671
+ ? nextHermesState.profiles
3672
+ : {};
3673
+ nextHermesState.profiles[profileName] = prepareHermesProfileState(
3674
+ rows,
3675
+ nextHermesState.profiles[profileName],
3676
+ updatedAt,
3677
+ );
3678
+ }
3679
+ }
3680
+ cursors.hourly = hourlyState;
3681
+ cursors.migrations ||= {};
3682
+ cursors.migrations[HERMES_MIXED_MODEL_RECONCILIATION_MIGRATION_KEY] = {
3683
+ status: "applied",
3684
+ appliedAt: updatedAt,
3685
+ totalTokens: authoritativeTotals.total_tokens,
3686
+ bucketsRebuilt: canonicalRows.length,
3687
+ };
3688
+ return cursors.migrations[HERMES_MIXED_MODEL_RECONCILIATION_MIGRATION_KEY];
3689
+ }
3690
+
3380
3691
  function hasLegacyHermesDefaultState(hermesState) {
3381
3692
  return (
3382
3693
  typeof hermesState.lastStartedAt === "number" ||
@@ -3385,7 +3696,16 @@ function hasLegacyHermesDefaultState(hermesState) {
3385
3696
  );
3386
3697
  }
3387
3698
 
3388
- async function parseHermesIncremental({ hermesPath, dbPath, cursors, queuePath, onProgress, sqliteOptions } = {}) {
3699
+ async function parseHermesIncremental({
3700
+ hermesPath,
3701
+ dbPath,
3702
+ cursors,
3703
+ queuePath,
3704
+ queueStatePath,
3705
+ onProgress,
3706
+ sqliteOptions,
3707
+ reconcileHistorical = false,
3708
+ } = {}) {
3389
3709
  await ensureDir(path.dirname(queuePath));
3390
3710
  const hermesState = cursors.hermes && typeof cursors.hermes === "object" ? cursors.hermes : {};
3391
3711
 
@@ -3398,6 +3718,27 @@ async function parseHermesIncremental({ hermesPath, dbPath, cursors, queuePath,
3398
3718
  const hourlyState = normalizeHourlyState(cursors?.hourly);
3399
3719
  const cb = typeof onProgress === "function" ? onProgress : null;
3400
3720
  const updatedAt = new Date().toISOString();
3721
+ if (reconcileHistorical) {
3722
+ const reconciliation = await reconcileHermesMixedModelUsage({
3723
+ dbPaths,
3724
+ cursors,
3725
+ queuePath,
3726
+ queueStatePath,
3727
+ hermesState,
3728
+ updatedAt,
3729
+ sqliteOptions,
3730
+ });
3731
+ if (reconciliation?.status === "applied") {
3732
+ hermesState.updatedAt = updatedAt;
3733
+ cursors.hermes = hermesState;
3734
+ return {
3735
+ recordsProcessed: 0,
3736
+ eventsAggregated: 0,
3737
+ bucketsQueued: 0,
3738
+ reconciliation,
3739
+ };
3740
+ }
3741
+ }
3401
3742
  let recordsProcessed = 0;
3402
3743
  let eventsAggregated = 0;
3403
3744
  const touchedBuckets = new Set();
@@ -3451,6 +3792,11 @@ async function parseHermesIncremental({ hermesPath, dbPath, cursors, queuePath,
3451
3792
  const perModel = Number(row.per_model) === 1;
3452
3793
  const model = normalizeModelInput(row.model) || "hermes-agent";
3453
3794
  const snapshotKey = perModel ? JSON.stringify([String(row.id || ""), model]) : row.id;
3795
+ const actualCost = Number(row.actual_cost_usd);
3796
+ const hasAuthoritativeCost =
3797
+ String(row.cost_status || "").toLowerCase() === "actual" &&
3798
+ Number.isFinite(actualCost) &&
3799
+ actualCost >= 0;
3454
3800
  const currentSnapshot = {
3455
3801
  in: inputTokens,
3456
3802
  out: outputTokens,
@@ -3458,6 +3804,7 @@ async function parseHermesIncremental({ hermesPath, dbPath, cursors, queuePath,
3458
3804
  cacheWrite,
3459
3805
  reasoning,
3460
3806
  message_count: messageCount,
3807
+ actual_cost_usd: hasAuthoritativeCost ? actualCost : null,
3461
3808
  };
3462
3809
  // Save current snapshot for next sync. Mixed-model sessions are keyed by
3463
3810
  // both session and model so later growth is attributed independently.
@@ -3481,12 +3828,19 @@ async function parseHermesIncremental({ hermesPath, dbPath, cursors, queuePath,
3481
3828
  const prev = adoptingPerModelState && perModel && legacySessionSnapshot
3482
3829
  ? currentSnapshot
3483
3830
  : prevSnapshots[snapshotKey];
3831
+ const previousActualCost = Number(prev?.actual_cost_usd);
3832
+ const hasPreviousActualCost =
3833
+ typeof prev?.actual_cost_usd === "number" &&
3834
+ Number.isFinite(previousActualCost) &&
3835
+ previousActualCost >= 0;
3836
+ const isActualCostTransition = hasAuthoritativeCost && Boolean(prev) && !hasPreviousActualCost;
3484
3837
  let dInput = inputTokens;
3485
3838
  let dOutput = outputTokens;
3486
3839
  let dCacheRead = cacheRead;
3487
3840
  let dCacheWrite = cacheWrite;
3488
3841
  let dReasoning = reasoning;
3489
3842
  let dMessageCount = messageCount;
3843
+ let dActualCost = hasAuthoritativeCost ? actualCost : 0;
3490
3844
  if (prev) {
3491
3845
  dInput = Math.max(0, inputTokens - (prev.in || 0));
3492
3846
  dOutput = Math.max(0, outputTokens - (prev.out || 0));
@@ -3494,9 +3848,13 @@ async function parseHermesIncremental({ hermesPath, dbPath, cursors, queuePath,
3494
3848
  dCacheWrite = Math.max(0, cacheWrite - (prev.cacheWrite || 0));
3495
3849
  dReasoning = Math.max(0, reasoning - (prev.reasoning || 0));
3496
3850
  dMessageCount = Math.max(0, messageCount - (prev.message_count || 0));
3851
+ dActualCost = hasAuthoritativeCost
3852
+ ? (hasPreviousActualCost ? Math.max(0, actualCost - previousActualCost) : 0)
3853
+ : 0;
3497
3854
  }
3498
- // Skip if delta is zero (session unchanged since last sync)
3499
- if (dInput === 0 && dOutput === 0 && dCacheRead === 0 && dCacheWrite === 0 && dReasoning === 0) continue;
3855
+ const hasTokenDelta =
3856
+ dInput > 0 || dOutput > 0 || dCacheRead > 0 || dCacheWrite > 0 || dReasoning > 0;
3857
+ const hasActualCostDelta = hasAuthoritativeCost && hasPreviousActualCost && dActualCost > 0;
3500
3858
 
3501
3859
  // Per-model rows carry the authoritative last API-call timestamp. Older
3502
3860
  // Hermes schemas fall back to the session-level start/end policy.
@@ -3506,8 +3864,18 @@ async function parseHermesIncremental({ hermesPath, dbPath, cursors, queuePath,
3506
3864
  : endedAt ?? (prev ? Date.parse(updatedAt) / 1000 : startedAt);
3507
3865
  if (!epochSec || !Number.isFinite(epochSec)) continue;
3508
3866
  const tsIso = new Date(epochSec * 1000).toISOString();
3509
- const bucketStart = toUtcHalfHourStart(tsIso);
3510
- if (!bucketStart) continue;
3867
+ const observedBucketStart = toUtcHalfHourStart(tsIso);
3868
+ if (!observedBucketStart) continue;
3869
+ currentSnapshot.bucket_start = observedBucketStart;
3870
+ // A status transition establishes a cost baseline but cannot safely
3871
+ // relabel already-ingested token rows. A pure later cost update belongs
3872
+ // with the prior observed bucket; otherwise it would create a zero-token
3873
+ // current bucket that the dashboard cannot attribute to the usage.
3874
+ if (!hasTokenDelta && !hasActualCostDelta) continue;
3875
+ const bucketStart =
3876
+ !hasTokenDelta && hasActualCostDelta && typeof prev?.bucket_start === "string"
3877
+ ? prev.bucket_start
3878
+ : observedBucketStart;
3511
3879
 
3512
3880
  const delta = {
3513
3881
  input_tokens: dInput,
@@ -3521,6 +3889,11 @@ async function parseHermesIncremental({ hermesPath, dbPath, cursors, queuePath,
3521
3889
 
3522
3890
  const bucket = getHourlyBucket(hourlyState, "hermes", model, bucketStart);
3523
3891
  addTotals(bucket.totals, delta);
3892
+ if (hasAuthoritativeCost && !isActualCostTransition && bucket.totals.actual_cost_complete === true) {
3893
+ bucket.totals.actual_cost_usd += dActualCost;
3894
+ } else if (!hasAuthoritativeCost || isActualCostTransition) {
3895
+ bucket.totals.actual_cost_complete = false;
3896
+ }
3524
3897
  touchedBuckets.add(bucketKey("hermes", model, bucketStart));
3525
3898
  eventsAggregated++;
3526
3899
 
@@ -4027,7 +4400,15 @@ function kiroWatermarkFloor(cell, field, fallback) {
4027
4400
  return value;
4028
4401
  }
4029
4402
 
4030
- async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onProgress, env, sqliteOptions } = {}) {
4403
+ async function parseKiroCliIncremental({
4404
+ sessionFiles,
4405
+ cursors,
4406
+ queuePath,
4407
+ onProgress,
4408
+ env,
4409
+ sqliteOptions,
4410
+ nowMs = Date.now(),
4411
+ } = {}) {
4031
4412
  await ensureDir(path.dirname(queuePath));
4032
4413
  const kiroCliState =
4033
4414
  cursors.kiroCli && typeof cursors.kiroCli === "object" ? cursors.kiroCli : {};
@@ -4396,7 +4777,14 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
4396
4777
 
4397
4778
  if (!dInput && !dOutput && !dConv) continue;
4398
4779
 
4399
- const bucket = getHourlyBucket(hourlyState, "kiro", want.model, want.bucketStart);
4780
+ // A mutable request has no authoritative mutation timestamp in Kiro's
4781
+ // SQLite schema. Its initial contribution belongs to request start; later
4782
+ // positive growth is observed now and must not be backdated across midnight.
4783
+ const bucketStart = prior && (dInput > 0 || dOutput > 0)
4784
+ ? toUtcHalfHourStart(new Date(nowMs).toISOString())
4785
+ : want.bucketStart;
4786
+ if (!bucketStart) continue;
4787
+ const bucket = getHourlyBucket(hourlyState, "kiro", want.model, bucketStart);
4400
4788
  addTotals(bucket.totals, {
4401
4789
  input_tokens: dInput,
4402
4790
  cached_input_tokens: 0,
@@ -4406,7 +4794,7 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
4406
4794
  total_tokens: dInput + dOutput,
4407
4795
  conversation_count: dConv,
4408
4796
  });
4409
- touchedBuckets.add(bucketKey("kiro", want.model, want.bucketStart));
4797
+ touchedBuckets.add(bucketKey("kiro", want.model, bucketStart));
4410
4798
  eventsAggregated += 1;
4411
4799
  }
4412
4800
 
@@ -7534,6 +7922,7 @@ async function parseCraftIncremental({
7534
7922
  onProgress,
7535
7923
  env,
7536
7924
  defaultModel,
7925
+ nowMs = Date.now(),
7537
7926
  } = {}) {
7538
7927
  await ensureDir(path.dirname(queuePath));
7539
7928
  const craftState = cursors.craft && typeof cursors.craft === "object" ? cursors.craft : {};
@@ -7644,7 +8033,15 @@ async function parseCraftIncremental({
7644
8033
  const dCacheWrite = Math.max(0, totalCacheWrite - prev.cacheWrite);
7645
8034
  const dTotal = Math.max(0, totalReported - prev.total);
7646
8035
 
7647
- const nowMs = Date.now();
8036
+ const activityCandidates = [header.lastMessageAt, header.lastUsedAt];
8037
+ let activityMs = null;
8038
+ for (const cand of activityCandidates) {
8039
+ if (Number.isFinite(Number(cand)) && Number(cand) > 0) {
8040
+ activityMs = Number(cand);
8041
+ break;
8042
+ }
8043
+ }
8044
+ const observedNowMs = nowMs;
7648
8045
 
7649
8046
  if (dInput === 0 && dOutput === 0 && dCacheRead === 0 && dCacheWrite === 0) {
7650
8047
  // No new usage since last parse — but still update the snapshot in case
@@ -7656,21 +8053,25 @@ async function parseCraftIncremental({
7656
8053
  cacheRead: totalCacheRead,
7657
8054
  cacheWrite: totalCacheWrite,
7658
8055
  total: totalReported,
7659
- lastSeenAt: nowMs,
8056
+ activityMs,
8057
+ lastSeenAt: observedNowMs,
7660
8058
  };
7661
8059
  continue;
7662
8060
  }
7663
8061
 
7664
- // Bucket on lastMessageAt (preferred) or createdAt both ms epoch.
7665
- let tsMs = null;
7666
- const tsCandidates = [header.lastMessageAt, header.lastUsedAt, header.createdAt];
7667
- for (const cand of tsCandidates) {
7668
- if (Number.isFinite(Number(cand)) && Number(cand) > 0) {
7669
- tsMs = Number(cand);
7670
- break;
7671
- }
8062
+ // Initial totals use the source activity timestamp (then createdAt/file
8063
+ // mtime). Cumulative growth must not be pinned to an old createdAt: use a
8064
+ // newer trustworthy activity time when present, otherwise this sync's
8065
+ // observation time.
8066
+ let firstSeenTimestampMs = activityMs;
8067
+ if (firstSeenTimestampMs == null && Number.isFinite(Number(header.createdAt)) && Number(header.createdAt) > 0) {
8068
+ firstSeenTimestampMs = Number(header.createdAt);
7672
8069
  }
7673
- if (tsMs == null) tsMs = stat.mtimeMs;
8070
+ if (firstSeenTimestampMs == null) firstSeenTimestampMs = stat.mtimeMs;
8071
+ const growthHasNewActivity = activityMs != null && activityMs > Number(prev.activityMs || 0);
8072
+ const tsMs = prev.total > 0
8073
+ ? (growthHasNewActivity ? activityMs : observedNowMs)
8074
+ : firstSeenTimestampMs;
7674
8075
  if (!Number.isFinite(tsMs) || tsMs <= 0) continue;
7675
8076
 
7676
8077
  const tsIso = new Date(tsMs).toISOString();
@@ -7704,7 +8105,8 @@ async function parseCraftIncremental({
7704
8105
  cacheRead: totalCacheRead,
7705
8106
  cacheWrite: totalCacheWrite,
7706
8107
  total: totalReported,
7707
- lastSeenAt: nowMs,
8108
+ activityMs,
8109
+ lastSeenAt: observedNowMs,
7708
8110
  };
7709
8111
 
7710
8112
  if (cb) {