@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.
- package/README.md +7 -14
- package/dashboard/dist/assets/{Card-B8V3fjn0.js → Card-CEz9362Q.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-BEzDx9x_.js +60 -0
- package/dashboard/dist/assets/{IpCheckPage-CiTeykAr.js → IpCheckPage-COYl0L0c.js} +1 -1
- package/dashboard/dist/assets/LimitsPage-CxKP9yCf.js +2 -0
- package/dashboard/dist/assets/LocalOnlyNotice-mkvVeaxU.js +1 -0
- package/dashboard/dist/assets/PopoverPopup-CcXnjekJ.js +1 -0
- package/dashboard/dist/assets/Select-DfLcXuzD.js +1 -0
- package/dashboard/dist/assets/SettingsPage-BKATdxHm.js +1 -0
- package/dashboard/dist/assets/SkillsPage-BCS1cw7N.js +1 -0
- package/dashboard/dist/assets/{WrappedPage-TYb3h53L.js → WrappedPage-BZMXgdYx.js} +1 -1
- package/dashboard/dist/assets/{format-RxRKEKOo.js → format-DXnZAXRS.js} +1 -1
- package/dashboard/dist/assets/limitDisplay-Dkfaklpc.js +1 -0
- package/dashboard/dist/assets/main-C7rJ1TVR.css +1 -0
- package/dashboard/dist/assets/main-Dft1sGV7.js +610 -0
- package/dashboard/dist/assets/{mock-data-BSY76wog.js → mock-data-u9UDfqcX.js} +1 -1
- package/dashboard/dist/assets/use-limits-display-prefs-Nj1Nil2m.js +1 -0
- package/dashboard/dist/assets/useCurrency-BxAIQJRl.js +12 -0
- package/dashboard/dist/index.html +2 -18
- package/package.json +2 -4
- package/src/cli.js +1 -1
- package/src/commands/sync.js +4 -0
- package/src/lib/local-api.js +35 -10
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +429 -27
- package/dashboard/dist/assets/DashboardPage-CY-J38sv.js +0 -60
- package/dashboard/dist/assets/FadeIn-CHDoW7U_.js +0 -1
- package/dashboard/dist/assets/LimitsPage-CeL9bJnV.js +0 -2
- package/dashboard/dist/assets/LocalOnlyNotice-Ci694JEz.js +0 -1
- package/dashboard/dist/assets/PopoverPopup-DuOdVhwr.js +0 -1
- package/dashboard/dist/assets/Select-CmEJ1f4r.js +0 -1
- package/dashboard/dist/assets/SelectItemText-DLwQC1NR.js +0 -12
- package/dashboard/dist/assets/SettingsPage-BY-B7Zy8.js +0 -1
- package/dashboard/dist/assets/SkillsPage-Cf3XZg8f.js +0 -1
- package/dashboard/dist/assets/WidgetsPage-DfOgT6--.js +0 -1
- package/dashboard/dist/assets/arrow-up-right-B03PmH0J.js +0 -1
- package/dashboard/dist/assets/download-B8nNpP2e.js +0 -1
- package/dashboard/dist/assets/limitDisplay-DD9iY3bZ.js +0 -1
- package/dashboard/dist/assets/main-BHmaQn5s.js +0 -700
- package/dashboard/dist/assets/main-t7dbBL4x.css +0 -1
- package/dashboard/dist/assets/use-limits-display-prefs-E_33VGNk.js +0 -1
- package/dashboard/dist/assets/use-native-settings-Jtj00HqH.js +0 -1
- package/dashboard/dist/assets/useCurrency-BjqazQG7.js +0 -1
package/src/lib/rollout.js
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
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,
|
|
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({
|
|
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
|
-
|
|
3499
|
-
|
|
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
|
|
3510
|
-
if (!
|
|
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({
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
8056
|
+
activityMs,
|
|
8057
|
+
lastSeenAt: observedNowMs,
|
|
7660
8058
|
};
|
|
7661
8059
|
continue;
|
|
7662
8060
|
}
|
|
7663
8061
|
|
|
7664
|
-
//
|
|
7665
|
-
|
|
7666
|
-
|
|
7667
|
-
|
|
7668
|
-
|
|
7669
|
-
|
|
7670
|
-
|
|
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 (
|
|
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
|
-
|
|
8108
|
+
activityMs,
|
|
8109
|
+
lastSeenAt: observedNowMs,
|
|
7708
8110
|
};
|
|
7709
8111
|
|
|
7710
8112
|
if (cb) {
|