tokentracker-cli 0.93.7 → 0.94.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.
- package/README.de.md +1 -1
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/dashboard/dist/assets/{AchievementBadge-DqvblIQY.js → AchievementBadge-DPqwk2zI.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-C-xm27pB.js → AchievementsPage-CELHoMQt.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-D8Oe2FGJ.js → AchievementsSection-DdAC0VCb.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-BJOcOnHo.js → BadgeDetailModal-Dd4I1SpM.js} +1 -1
- package/dashboard/dist/assets/{BotAnimated-6fB046ld.js → BotAnimated-DL2-4mnm.js} +1 -1
- package/dashboard/dist/assets/{Card-BKbWtBaL.js → Card-B_MpSRse.js} +1 -1
- package/dashboard/dist/assets/{ClawdAnimated-DbpeAldp.js → ClawdAnimated-BLZZ4rG6.js} +2 -2
- package/dashboard/dist/assets/{CommandPalette-Jte7TBon.js → CommandPalette-B-vfdIbH.js} +1 -1
- package/dashboard/dist/assets/{CommunityStatsModal-DDm7tCXO.js → CommunityStatsModal-aAico565.js} +1 -1
- package/dashboard/dist/assets/{ConfirmModal-CrIjcrpZ.js → ConfirmModal-DLnRwUXC.js} +1 -1
- package/dashboard/dist/assets/{Controls-BWJtMkIy.js → Controls-FZ8wEN5w.js} +1 -1
- package/dashboard/dist/assets/{DashboardPage-Bcq4EwG5.js → DashboardPage-BByRBsbt.js} +1 -1
- package/dashboard/dist/assets/{DevicePage-C9NcPZGX.js → DevicePage-D2IzPc5o.js} +1 -1
- package/dashboard/dist/assets/{DialogClose-CY-LguUG.js → DialogClose-BaqQaUUk.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-BUrh0Jod.js +1 -0
- package/dashboard/dist/assets/{DialogTitle-BkAmJrQ_.js → DialogTitle-DrbKjtuS.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-U-bxcM-y.js → FadeIn-GddKDE8i.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-4HUCxVhg.js → HeaderGithubStar-DUu-Z5mE.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-BXcXOCs8.js → HoverTooltip-BmDkY35A.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-Cppkc-gE.js → IpCheckPage-CGJyqWk2.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-D3KXfYKE.js → LandingPage-Bd91ALUj.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardAvatar-CdsHg6L0.js → LeaderboardAvatar-BGGUwXFd.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardPage-niat0TZW.js → LeaderboardPage-BMEqHwif.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardProfileModal-DgjhhEbB.js → LeaderboardProfileModal-BSweJqm2.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfilePage-CZej8gpc.js → LeaderboardProfilePage-CRrI3RFz.js} +1 -1
- package/dashboard/dist/assets/LimitsPage-XIRDv46K.js +5 -0
- package/dashboard/dist/assets/{LocalOnlyNotice-DQswDMSD.js → LocalOnlyNotice-CCD-lGWG.js} +1 -1
- package/dashboard/dist/assets/{LoginCard-glPyVqYh.js → LoginCard-lDuqSv4f.js} +1 -1
- package/dashboard/dist/assets/{LoginModal-slsdEXC7.js → LoginModal-DStt5VXN.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-DX5QT917.js → LoginPage-dmVPSL72.js} +1 -1
- package/dashboard/dist/assets/{PetPage-B8U3CJwt.js → PetPage-ybcZRctF.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-BVPcQAio.js → PopoverPopup-Dar_UjxF.js} +1 -1
- package/dashboard/dist/assets/{ProviderIcon-CljIacVj.js → ProviderIcon-Ct9tILpz.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-DTBmQk2y.js → ResetPasswordPage-B4yLh5zx.js} +1 -1
- package/dashboard/dist/assets/{SegmentedControl-C6npelCT.js → SegmentedControl-BOE1CEzq.js} +1 -1
- package/dashboard/dist/assets/{Select-D1elkRBG.js → Select-D-bSh7Zz.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-3N-hvYuD.js → SelectItemText-DMYjXXq4.js} +1 -1
- package/dashboard/dist/assets/{ServiceStatusPage-C5ZK9X2o.js → ServiceStatusPage-CaleS-6_.js} +1 -1
- package/dashboard/dist/assets/SessionsPage-DOaY6_E9.js +2 -0
- package/dashboard/dist/assets/SettingsPage-iJH0Yyah.js +2 -0
- package/dashboard/dist/assets/{SkillsPage-Cwkevv29.js → SkillsPage-BkzJOBLk.js} +1 -1
- package/dashboard/dist/assets/{TokenGalaxy-a1-E6_jp.js → TokenGalaxy-CuXKoMPg.js} +1 -1
- package/dashboard/dist/assets/{ToolbarRootContext-Cm5cQb9Z.js → ToolbarRootContext-Dx5GB7F5.js} +1 -1
- package/dashboard/dist/assets/{TrendMonitor-DoNDyarF.js → TrendMonitor-BuKmsznP.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-QMTmKnRl.js → WidgetsPage-DUaIyGtu.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-C9rJcY2o.js → WrappedPage-BzqlcAUC.js} +1 -1
- package/dashboard/dist/assets/__vite-browser-external-BHz2N7Ih.css +1 -0
- package/dashboard/dist/assets/{__vite-browser-external-CsbVaBbw.js → __vite-browser-external-CVIGknXl.js} +3 -3
- package/dashboard/dist/assets/{agent-logos-B39DKy3X.js → agent-logos-BdWWxza6.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-Cw5mMBik.js → arrow-up-right-CANSzmm-.js} +1 -1
- package/dashboard/dist/assets/{check-DrQztnq1.js → check-BnJpzXRZ.js} +1 -1
- package/dashboard/dist/assets/{chevron-right-CEelysET.js → chevron-right-DJm0mbG0.js} +1 -1
- package/dashboard/dist/assets/{composite-MhgOkYfv.js → composite-Ccj6KQmh.js} +1 -1
- package/dashboard/dist/assets/{copy-Bouz-qQR.js → copy-C2Yc8Evu.js} +1 -1
- package/dashboard/dist/assets/{download-BugS2cl5.js → download-B52JTk6Y.js} +1 -1
- package/dashboard/dist/assets/{external-link-Ceqto-f4.js → external-link-DayTwZx-.js} +1 -1
- package/dashboard/dist/assets/{flame-B67y0kLz.js → flame-B8GEX2yT.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-CR8njNJs.js → format-tokens-mhkbohkb.js} +1 -1
- package/dashboard/dist/assets/{icons-DFj5ctTx.js → icons-o8bPXqRP.js} +1 -1
- package/dashboard/dist/assets/{index-CrymIhtC.js → index-Bp0ZHDtz.js} +1 -1
- package/dashboard/dist/assets/{index-Bx6EP36K.js → index-CK_CduLm.js} +1 -1
- package/dashboard/dist/assets/{index-ZGfRrxuq.js → index-MBVWUKpA.js} +1 -1
- package/dashboard/dist/assets/{info-C6XoktLa.js → info-D94QM_vX.js} +1 -1
- package/dashboard/dist/assets/{limits-providers-B9L6hIZT.js → limits-providers-BLzMyCiy.js} +1 -1
- package/dashboard/dist/assets/{link-2-DKG2j43e.js → link-2-CqBmFaDI.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-DB2txq05.js → loader-circle-SaxUpDR6.js} +1 -1
- package/dashboard/dist/assets/{maximize-2-3-dz0brp.js → maximize-2-BoXqARks.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-D0KjKi1o.js → modifiers.esm-Conh_-BD.js} +1 -1
- package/dashboard/dist/assets/{monitor-smartphone-Ds8H_QW9.js → monitor-smartphone-D6qLR9qd.js} +1 -1
- package/dashboard/dist/assets/{plus-BDsmAe4Z.js → plus-D51mjIc2.js} +1 -1
- package/dashboard/dist/assets/{provider-display-DRz643iD.js → provider-display-DpHPjd_s.js} +1 -1
- package/dashboard/dist/assets/{react-D3Afmy9b.js → react-D_AcI466.js} +1 -1
- package/dashboard/dist/assets/{search-CO2Xf-Kg.js → search-CwLcNdvZ.js} +1 -1
- package/dashboard/dist/assets/{skills-api-C1BZuUIq.js → skills-api-Dqi7Mjgo.js} +1 -1
- package/dashboard/dist/assets/{sortable.esm-DutNEc4F.js → sortable.esm-BPDlUxPb.js} +1 -1
- package/dashboard/dist/assets/{store-a-oUpY-g.js → store-ikeRIRGd.js} +1 -1
- package/dashboard/dist/assets/{terminal-DAePk67T.js → terminal-BfeX3L5T.js} +1 -1
- package/dashboard/dist/assets/{trash-2-bnFmtRSG.js → trash-2-5_Qlp1k7.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-PJBeXyek.js → use-community-stats-D90Fy2_j.js} +1 -1
- package/dashboard/dist/assets/use-limits-display-prefs-D4BYFaa-.js +1 -0
- package/dashboard/dist/assets/{use-ordered-list-BaVvCzZ9.js → use-ordered-list-DJxLNdXY.js} +1 -1
- package/dashboard/dist/assets/use-reduced-motion-yE3T3cat.js +1 -0
- package/dashboard/dist/assets/{use-session-efficiency-pref-COTG-zJs.js → use-session-efficiency-pref-CAX_GqXc.js} +1 -1
- package/dashboard/dist/assets/{use-transform-BQEPyChL.js → use-transform-CeW6Z5R0.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-LR2gHJJn.js → use-usage-limits-5HP8k9a3.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-CeqKrzFz.js → useCurrency-BarGeFyK.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-D0IMlZlO.js → useTokenFormat-DUJ9iT9H.js} +1 -1
- package/dashboard/dist/assets/{zap-BBn6G5_E.js → zap--m0IWoI3.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dashboard/dist/ip-check.html +2 -2
- package/dashboard/dist/leaderboard.html +2 -2
- package/dashboard/dist/share.html +2 -2
- package/package.json +1 -1
- package/src/commands/status.js +3 -1
- package/src/commands/sync.js +8 -3
- package/src/lib/codex-rollout-parser.js +35 -4
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +758 -195
- package/src/lib/session-analytics.js +205 -10
- package/src/lib/telemetry.js +2 -2
- package/src/lib/usage-limits.js +20 -10
- package/dashboard/dist/assets/DialogDescription-DPyhpdeR.js +0 -1
- package/dashboard/dist/assets/LimitsPage-BXzzX87I.js +0 -5
- package/dashboard/dist/assets/SessionsPage-CJ0JvE8-.js +0 -2
- package/dashboard/dist/assets/SettingsPage-C32uYzqG.js +0 -2
- package/dashboard/dist/assets/__vite-browser-external-D95WhsZe.css +0 -1
- package/dashboard/dist/assets/use-limits-display-prefs-Blvn-7CB.js +0 -1
- package/dashboard/dist/assets/use-reduced-motion-f51jgK_S.js +0 -1
package/src/lib/rollout.js
CHANGED
|
@@ -3285,6 +3285,7 @@ function normalizeOpencodeState(raw) {
|
|
|
3285
3285
|
const messages = state.messages && typeof state.messages === "object" ? state.messages : {};
|
|
3286
3286
|
return {
|
|
3287
3287
|
messages,
|
|
3288
|
+
dbCursor: state.dbCursor && typeof state.dbCursor === "object" ? state.dbCursor : null,
|
|
3288
3289
|
updatedAt: typeof state.updatedAt === "string" ? state.updatedAt : null,
|
|
3289
3290
|
};
|
|
3290
3291
|
}
|
|
@@ -3362,13 +3363,20 @@ function deriveOpencodeMessageFingerprint({ msg, totals, source }) {
|
|
|
3362
3363
|
// Rebuilt per parse run. Pre-#426 entries are fingerprinted as they are read;
|
|
3363
3364
|
// the first claims ownership and later cross-session matches are retracted from
|
|
3364
3365
|
// persisted buckets once. Tombstoned copies never claim ownership themselves.
|
|
3365
|
-
function buildOpencodeFingerprintIndex(messageIndex) {
|
|
3366
|
+
function buildOpencodeFingerprintIndex(messageIndex, wantedFingerprints = null) {
|
|
3366
3367
|
const byFingerprint = new Map();
|
|
3367
3368
|
if (!messageIndex || typeof messageIndex !== "object") return byFingerprint;
|
|
3368
|
-
for (const
|
|
3369
|
+
for (const key in messageIndex) {
|
|
3370
|
+
const entry = messageIndex[key];
|
|
3369
3371
|
if (entry?.dedupedForkCopy === true) continue;
|
|
3370
3372
|
const fingerprint = entry && typeof entry.fingerprint === "string" ? entry.fingerprint : null;
|
|
3371
|
-
if (
|
|
3373
|
+
if (
|
|
3374
|
+
fingerprint &&
|
|
3375
|
+
(!wantedFingerprints || wantedFingerprints.has(fingerprint)) &&
|
|
3376
|
+
!byFingerprint.has(fingerprint)
|
|
3377
|
+
) {
|
|
3378
|
+
byFingerprint.set(fingerprint, key);
|
|
3379
|
+
}
|
|
3372
3380
|
}
|
|
3373
3381
|
return byFingerprint;
|
|
3374
3382
|
}
|
|
@@ -3393,6 +3401,52 @@ function isOpencodeForkCopy(fingerprintIndex, fingerprint, messageKey) {
|
|
|
3393
3401
|
return ownerSession !== session;
|
|
3394
3402
|
}
|
|
3395
3403
|
|
|
3404
|
+
function normalizeOpencodeAttribution(raw) {
|
|
3405
|
+
if (typeof raw === "string") {
|
|
3406
|
+
const [bucketStart = "", model = "", projectKey = "", projectRef = ""] = raw.split("\t");
|
|
3407
|
+
if (!bucketStart || !model) return null;
|
|
3408
|
+
return {
|
|
3409
|
+
bucketStart,
|
|
3410
|
+
model,
|
|
3411
|
+
projectKey: projectKey || null,
|
|
3412
|
+
projectRef: projectRef || null,
|
|
3413
|
+
};
|
|
3414
|
+
}
|
|
3415
|
+
if (!raw || typeof raw !== "object") return null;
|
|
3416
|
+
const bucketStart = typeof raw.bucketStart === "string" ? raw.bucketStart : "";
|
|
3417
|
+
const model = typeof raw.model === "string" ? raw.model : "";
|
|
3418
|
+
if (!bucketStart || !model) return null;
|
|
3419
|
+
return {
|
|
3420
|
+
bucketStart,
|
|
3421
|
+
model,
|
|
3422
|
+
projectKey: typeof raw.projectKey === "string" ? raw.projectKey : null,
|
|
3423
|
+
projectRef: typeof raw.projectRef === "string" ? raw.projectRef : null,
|
|
3424
|
+
};
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3427
|
+
function encodeOpencodeAttribution(raw) {
|
|
3428
|
+
const attribution = normalizeOpencodeAttribution(raw);
|
|
3429
|
+
if (!attribution) return null;
|
|
3430
|
+
return [
|
|
3431
|
+
attribution.bucketStart,
|
|
3432
|
+
attribution.model,
|
|
3433
|
+
attribution.projectKey || "",
|
|
3434
|
+
attribution.projectRef || "",
|
|
3435
|
+
].join("\t");
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
function sameOpencodeAttribution(a, b) {
|
|
3439
|
+
const left = normalizeOpencodeAttribution(a);
|
|
3440
|
+
const right = normalizeOpencodeAttribution(b);
|
|
3441
|
+
if (!left || !right) return left === right;
|
|
3442
|
+
return (
|
|
3443
|
+
left.bucketStart === right.bucketStart &&
|
|
3444
|
+
left.model === right.model &&
|
|
3445
|
+
left.projectKey === right.projectKey &&
|
|
3446
|
+
left.projectRef === right.projectRef
|
|
3447
|
+
);
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3396
3450
|
// Persist a message's snapshot + fingerprint and claim the fingerprint for it.
|
|
3397
3451
|
// Writes only when something actually changed so a steady-state sync leaves the
|
|
3398
3452
|
// cursor untouched. A falsy `fingerprint` means "unknown" and preserves whatever
|
|
@@ -3404,6 +3458,7 @@ function recordOpencodeMessage({
|
|
|
3404
3458
|
messageKey,
|
|
3405
3459
|
totals,
|
|
3406
3460
|
fingerprint,
|
|
3461
|
+
attribution,
|
|
3407
3462
|
dedupedForkCopy = false,
|
|
3408
3463
|
}) {
|
|
3409
3464
|
if (!messageIndex || !messageKey) return;
|
|
@@ -3411,10 +3466,13 @@ function recordOpencodeMessage({
|
|
|
3411
3466
|
const prevTotals = prev && typeof prev.lastTotals === "object" ? prev.lastTotals : null;
|
|
3412
3467
|
const prevFingerprint = prev && typeof prev.fingerprint === "string" ? prev.fingerprint : null;
|
|
3413
3468
|
const nextFingerprint = fingerprint || prevFingerprint;
|
|
3469
|
+
const prevAttribution = normalizeOpencodeAttribution(prev?.attribution);
|
|
3470
|
+
const nextAttribution = normalizeOpencodeAttribution(attribution) || prevAttribution;
|
|
3414
3471
|
const prevDeduped = prev?.dedupedForkCopy === true;
|
|
3415
3472
|
if (
|
|
3416
3473
|
sameGeminiTotals(totals, prevTotals) &&
|
|
3417
3474
|
prevFingerprint === nextFingerprint &&
|
|
3475
|
+
sameOpencodeAttribution(prevAttribution, nextAttribution) &&
|
|
3418
3476
|
prevDeduped === Boolean(dedupedForkCopy)
|
|
3419
3477
|
) return;
|
|
3420
3478
|
|
|
@@ -3425,6 +3483,7 @@ function recordOpencodeMessage({
|
|
|
3425
3483
|
}
|
|
3426
3484
|
const entry = { lastTotals: totals, updatedAt: new Date().toISOString() };
|
|
3427
3485
|
if (nextFingerprint) entry.fingerprint = nextFingerprint;
|
|
3486
|
+
if (nextAttribution) entry.attribution = encodeOpencodeAttribution(nextAttribution);
|
|
3428
3487
|
if (dedupedForkCopy) entry.dedupedForkCopy = true;
|
|
3429
3488
|
messageIndex[messageKey] = entry;
|
|
3430
3489
|
if (
|
|
@@ -3437,41 +3496,79 @@ function recordOpencodeMessage({
|
|
|
3437
3496
|
}
|
|
3438
3497
|
}
|
|
3439
3498
|
|
|
3440
|
-
function
|
|
3441
|
-
|
|
3499
|
+
function subtractCountedOpencodeMessage({
|
|
3500
|
+
attribution,
|
|
3442
3501
|
totals,
|
|
3443
3502
|
source,
|
|
3444
3503
|
hourlyState,
|
|
3445
3504
|
touchedBuckets,
|
|
3446
3505
|
projectState,
|
|
3447
3506
|
projectTouchedBuckets,
|
|
3448
|
-
projectRef,
|
|
3449
|
-
projectKey,
|
|
3450
3507
|
}) {
|
|
3451
|
-
const
|
|
3452
|
-
if (!
|
|
3453
|
-
const bucketStart = toUtcHalfHourStart(new Date(timestampMs).toISOString());
|
|
3454
|
-
if (!bucketStart) return false;
|
|
3455
|
-
const { modelId: repairModelId } = normalizeOpencodeModelFields(msg);
|
|
3456
|
-
const model = repairModelId || DEFAULT_MODEL;
|
|
3508
|
+
const countedAt = normalizeOpencodeAttribution(attribution);
|
|
3509
|
+
if (!countedAt) return false;
|
|
3457
3510
|
const counted = { ...totals, conversation_count: 1 };
|
|
3458
|
-
const bucket = getHourlyBucket(
|
|
3511
|
+
const bucket = getHourlyBucket(
|
|
3512
|
+
hourlyState,
|
|
3513
|
+
source,
|
|
3514
|
+
countedAt.model,
|
|
3515
|
+
countedAt.bucketStart,
|
|
3516
|
+
);
|
|
3459
3517
|
subtractTotals(bucket.totals, counted);
|
|
3460
|
-
touchedBuckets.add(bucketKey(source, model, bucketStart));
|
|
3461
|
-
if (projectKey && projectState && projectTouchedBuckets) {
|
|
3518
|
+
touchedBuckets.add(bucketKey(source, countedAt.model, countedAt.bucketStart));
|
|
3519
|
+
if (countedAt.projectKey && projectState && projectTouchedBuckets) {
|
|
3462
3520
|
const projectBucket = getProjectBucket(
|
|
3463
3521
|
projectState,
|
|
3464
|
-
projectKey,
|
|
3522
|
+
countedAt.projectKey,
|
|
3465
3523
|
source,
|
|
3466
|
-
bucketStart,
|
|
3467
|
-
projectRef,
|
|
3524
|
+
countedAt.bucketStart,
|
|
3525
|
+
countedAt.projectRef,
|
|
3468
3526
|
);
|
|
3469
3527
|
subtractTotals(projectBucket.totals, counted);
|
|
3470
|
-
projectTouchedBuckets.add(
|
|
3528
|
+
projectTouchedBuckets.add(
|
|
3529
|
+
projectBucketKey(countedAt.projectKey, source, countedAt.bucketStart),
|
|
3530
|
+
);
|
|
3471
3531
|
}
|
|
3472
3532
|
return true;
|
|
3473
3533
|
}
|
|
3474
3534
|
|
|
3535
|
+
function repairCountedOpencodeForkCopy({
|
|
3536
|
+
msg,
|
|
3537
|
+
attribution,
|
|
3538
|
+
totals,
|
|
3539
|
+
source,
|
|
3540
|
+
hourlyState,
|
|
3541
|
+
touchedBuckets,
|
|
3542
|
+
projectState,
|
|
3543
|
+
projectTouchedBuckets,
|
|
3544
|
+
projectRef,
|
|
3545
|
+
projectKey,
|
|
3546
|
+
}) {
|
|
3547
|
+
let countedAt = normalizeOpencodeAttribution(attribution);
|
|
3548
|
+
if (!countedAt) {
|
|
3549
|
+
const timestampMs = coerceEpochMs(msg?.time?.completed) || coerceEpochMs(msg?.time?.created);
|
|
3550
|
+
if (!timestampMs) return false;
|
|
3551
|
+
const bucketStart = toUtcHalfHourStart(new Date(timestampMs).toISOString());
|
|
3552
|
+
if (!bucketStart) return false;
|
|
3553
|
+
const { modelId: repairModelId } = normalizeOpencodeModelFields(msg);
|
|
3554
|
+
countedAt = {
|
|
3555
|
+
bucketStart,
|
|
3556
|
+
model: repairModelId || DEFAULT_MODEL,
|
|
3557
|
+
projectKey,
|
|
3558
|
+
projectRef,
|
|
3559
|
+
};
|
|
3560
|
+
}
|
|
3561
|
+
return subtractCountedOpencodeMessage({
|
|
3562
|
+
attribution: countedAt,
|
|
3563
|
+
totals,
|
|
3564
|
+
source,
|
|
3565
|
+
hourlyState,
|
|
3566
|
+
touchedBuckets,
|
|
3567
|
+
projectState,
|
|
3568
|
+
projectTouchedBuckets,
|
|
3569
|
+
});
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3475
3572
|
function getHourlyBucket(state, source, model, hourStart) {
|
|
3476
3573
|
const buckets = state.buckets;
|
|
3477
3574
|
const normalizedSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
|
|
@@ -4185,10 +4282,6 @@ function diffGeminiTotals(current, previous) {
|
|
|
4185
4282
|
const totalReset = (current.total_tokens || 0) < (previous.total_tokens || 0);
|
|
4186
4283
|
if (totalReset) return current;
|
|
4187
4284
|
|
|
4188
|
-
// Must include cache_creation_input_tokens in both the equality check and
|
|
4189
|
-
// the delta — OpenCode routes through this diff and its cache.write number
|
|
4190
|
-
// would otherwise be permanently reported as zero. Gemini itself always
|
|
4191
|
-
// emits cache_creation=0 so the extra field is a no-op for Gemini.
|
|
4192
4285
|
const delta = {
|
|
4193
4286
|
input_tokens: Math.max(0, (current.input_tokens || 0) - (previous.input_tokens || 0)),
|
|
4194
4287
|
cached_input_tokens: Math.max(
|
|
@@ -4210,6 +4303,29 @@ function diffGeminiTotals(current, previous) {
|
|
|
4210
4303
|
return isAllZeroUsage(delta) ? null : delta;
|
|
4211
4304
|
}
|
|
4212
4305
|
|
|
4306
|
+
// OpenCode rows are authoritative snapshots of one message and can be
|
|
4307
|
+
// corrected downward or move tokens between cache/output columns. Signed
|
|
4308
|
+
// deltas replace the prior contribution instead of treating a correction as a
|
|
4309
|
+
// fresh cumulative reset. Gemini keeps its provider-specific reset behavior.
|
|
4310
|
+
function diffOpencodeTotals(current, previous) {
|
|
4311
|
+
if (!current || typeof current !== "object") return null;
|
|
4312
|
+
if (!previous || typeof previous !== "object") return current;
|
|
4313
|
+
if (sameGeminiTotals(current, previous)) return null;
|
|
4314
|
+
|
|
4315
|
+
const delta = {};
|
|
4316
|
+
for (const key of [
|
|
4317
|
+
"input_tokens",
|
|
4318
|
+
"cached_input_tokens",
|
|
4319
|
+
"cache_creation_input_tokens",
|
|
4320
|
+
"output_tokens",
|
|
4321
|
+
"reasoning_output_tokens",
|
|
4322
|
+
"total_tokens",
|
|
4323
|
+
]) {
|
|
4324
|
+
delta[key] = Number(current[key] || 0) - Number(previous[key] || 0);
|
|
4325
|
+
}
|
|
4326
|
+
return isAllZeroUsage(delta) ? null : delta;
|
|
4327
|
+
}
|
|
4328
|
+
|
|
4213
4329
|
function extractTokenCount(obj) {
|
|
4214
4330
|
const payload = obj?.payload;
|
|
4215
4331
|
if (!payload) return null;
|
|
@@ -4391,15 +4507,73 @@ async function walkOpencodeMessages(dir, out) {
|
|
|
4391
4507
|
// both "is there v2 data?" and "which session table exists?" in one round-trip.
|
|
4392
4508
|
// ---------------------------------------------------------------------------
|
|
4393
4509
|
|
|
4394
|
-
const
|
|
4395
|
-
|
|
4396
|
-
|
|
4510
|
+
const OPENCODE_DB_CURSOR_VERSION = 1;
|
|
4511
|
+
|
|
4512
|
+
function normalizeOpencodeDbWatermark(raw) {
|
|
4513
|
+
if (!raw || typeof raw !== "object") return null;
|
|
4514
|
+
const maxRowId = Math.max(0, Math.floor(Number(raw.maxRowId) || 0));
|
|
4515
|
+
const maxUpdatedAt = Math.max(0, Math.floor(Number(raw.maxUpdatedAt) || 0));
|
|
4516
|
+
const anchor = typeof raw.anchor === "string" && raw.anchor ? raw.anchor : null;
|
|
4517
|
+
return maxRowId || maxUpdatedAt ? { maxRowId, maxUpdatedAt, anchor } : null;
|
|
4518
|
+
}
|
|
4519
|
+
|
|
4520
|
+
function opencodeDbIdentity(dbPath) {
|
|
4521
|
+
try {
|
|
4522
|
+
const stat = fssync.statSync(dbPath);
|
|
4523
|
+
const resolved = path.resolve(dbPath);
|
|
4524
|
+
return {
|
|
4525
|
+
pathHash: crypto.createHash("sha256").update(resolved).digest("hex"),
|
|
4526
|
+
dev: String(stat.dev || 0),
|
|
4527
|
+
ino: String(stat.ino || 0),
|
|
4528
|
+
};
|
|
4529
|
+
} catch (_e) {
|
|
4530
|
+
return null;
|
|
4531
|
+
}
|
|
4532
|
+
}
|
|
4533
|
+
|
|
4534
|
+
function sameOpencodeDbIdentity(a, b) {
|
|
4535
|
+
return Boolean(
|
|
4536
|
+
a &&
|
|
4537
|
+
b &&
|
|
4538
|
+
a.pathHash === b.pathHash &&
|
|
4539
|
+
a.dev === b.dev &&
|
|
4540
|
+
a.ino === b.ino,
|
|
4541
|
+
);
|
|
4542
|
+
}
|
|
4543
|
+
|
|
4544
|
+
function opencodeDbRowAnchor(row) {
|
|
4545
|
+
if (!row || typeof row !== "object") return null;
|
|
4546
|
+
const rowId = Math.max(0, Math.floor(Number(row.row_id) || 0));
|
|
4547
|
+
const id = typeof row.id === "string" ? row.id : "";
|
|
4548
|
+
const created = Math.max(0, Math.floor(Number(row.time_created) || 0));
|
|
4549
|
+
if (!rowId || !id) return null;
|
|
4550
|
+
return crypto.createHash("sha256").update(`${rowId}\0${id}\0${created}`).digest("base64url");
|
|
4551
|
+
}
|
|
4552
|
+
|
|
4553
|
+
function opencodeDbIncrementalPredicate(alias, watermark) {
|
|
4554
|
+
const cursor = normalizeOpencodeDbWatermark(watermark);
|
|
4555
|
+
if (!cursor) return "";
|
|
4556
|
+
const clauses = [];
|
|
4557
|
+
if (cursor.maxRowId) clauses.push(`${alias}rowid > ${cursor.maxRowId}`);
|
|
4558
|
+
// Re-read the boundary timestamp so simultaneous updates cannot fall through
|
|
4559
|
+
// a strict greater-than watermark. The duplicate is removed by messageIndex.
|
|
4560
|
+
if (cursor.maxUpdatedAt) clauses.push(`${alias}time_updated >= ${cursor.maxUpdatedAt}`);
|
|
4561
|
+
return clauses.length > 0 ? ` AND (${clauses.join(" OR ")})` : "";
|
|
4562
|
+
}
|
|
4563
|
+
|
|
4564
|
+
function buildV1Sql(watermark = null) {
|
|
4565
|
+
return (
|
|
4566
|
+
`SELECT rowid AS row_id, id, session_id, time_updated, data FROM message ` +
|
|
4567
|
+
`WHERE json_extract(data, '$.role') = 'assistant'` +
|
|
4568
|
+
`${opencodeDbIncrementalPredicate("", watermark)} ORDER BY time_created ASC`
|
|
4569
|
+
);
|
|
4570
|
+
}
|
|
4397
4571
|
|
|
4398
4572
|
// Build the v2 query. When a session table exists the LEFT JOIN restores the
|
|
4399
4573
|
// project directory for downstream attribution; when it does not (type B
|
|
4400
4574
|
// without session_v2, or an exotic fork) the join and directory column are
|
|
4401
4575
|
// omitted and tokens are counted as-is.
|
|
4402
|
-
function buildV2Sql(sessionTable) {
|
|
4576
|
+
function buildV2Sql(sessionTable, watermark = null) {
|
|
4403
4577
|
const joinClause = sessionTable
|
|
4404
4578
|
? `LEFT JOIN ${sessionTable} s ON s.id = sm.session_id `
|
|
4405
4579
|
: "";
|
|
@@ -4407,10 +4581,11 @@ function buildV2Sql(sessionTable) {
|
|
|
4407
4581
|
? `s.directory AS directory, `
|
|
4408
4582
|
: "";
|
|
4409
4583
|
return (
|
|
4410
|
-
`SELECT sm.id AS id, sm.session_id AS session_id, sm.time_updated AS time_updated, ` +
|
|
4584
|
+
`SELECT sm.rowid AS row_id, sm.id AS id, sm.session_id AS session_id, sm.time_updated AS time_updated, ` +
|
|
4411
4585
|
`${directorySelect}sm.data AS data ` +
|
|
4412
4586
|
`FROM session_message sm ${joinClause}` +
|
|
4413
|
-
`WHERE sm.type = 'assistant'
|
|
4587
|
+
`WHERE sm.type = 'assistant'` +
|
|
4588
|
+
`${opencodeDbIncrementalPredicate("sm.", watermark)} ORDER BY sm.time_created ASC`
|
|
4414
4589
|
);
|
|
4415
4590
|
}
|
|
4416
4591
|
|
|
@@ -4447,8 +4622,13 @@ function opencodeMessageProvider(data) {
|
|
|
4447
4622
|
return data?.providerID || data?.model?.providerID || "";
|
|
4448
4623
|
}
|
|
4449
4624
|
|
|
4450
|
-
function
|
|
4451
|
-
if (!dbPath || !fssync.existsSync(dbPath)) return [];
|
|
4625
|
+
function readOpencodeDbMessagesIncremental(dbPath, previousCursor = null, sqliteOptions = {}) {
|
|
4626
|
+
if (!dbPath || !fssync.existsSync(dbPath)) return { messages: [], cursor: null };
|
|
4627
|
+
|
|
4628
|
+
const identity = opencodeDbIdentity(dbPath);
|
|
4629
|
+
const canResume =
|
|
4630
|
+
previousCursor?.version === OPENCODE_DB_CURSOR_VERSION &&
|
|
4631
|
+
sameOpencodeDbIdentity(previousCursor.identity, identity);
|
|
4452
4632
|
|
|
4453
4633
|
let snapshot = null;
|
|
4454
4634
|
let effectiveDbPath = dbPath;
|
|
@@ -4483,7 +4663,9 @@ function readOpencodeDbMessages(dbPath, sqliteOptions = {}) {
|
|
|
4483
4663
|
const hasTokens =
|
|
4484
4664
|
toNonNegativeInt(tokens.input) > 0 ||
|
|
4485
4665
|
toNonNegativeInt(tokens.output) > 0 ||
|
|
4486
|
-
toNonNegativeInt(tokens.reasoning) > 0
|
|
4666
|
+
toNonNegativeInt(tokens.reasoning) > 0 ||
|
|
4667
|
+
toNonNegativeInt(tokens.cache?.read) > 0 ||
|
|
4668
|
+
toNonNegativeInt(tokens.cache?.write) > 0;
|
|
4487
4669
|
if (!hasTokens) continue;
|
|
4488
4670
|
if (isV2 && typeof row.directory === "string" && row.directory.trim()) {
|
|
4489
4671
|
data.path = { ...(typeof data.path === "object" && data.path ? data.path : {}), cwd: row.directory };
|
|
@@ -4512,6 +4694,74 @@ function readOpencodeDbMessages(dbPath, sqliteOptions = {}) {
|
|
|
4512
4694
|
}
|
|
4513
4695
|
};
|
|
4514
4696
|
|
|
4697
|
+
const readMaxRowId = (table) => {
|
|
4698
|
+
const rows = readGeneration(`SELECT MAX(rowid) AS max_row_id FROM ${table}`);
|
|
4699
|
+
if (!rows) return null;
|
|
4700
|
+
return Math.max(0, Math.floor(Number(rows[0]?.max_row_id) || 0));
|
|
4701
|
+
};
|
|
4702
|
+
|
|
4703
|
+
const readRowAnchor = (table, rowId) => {
|
|
4704
|
+
if (!rowId) return null;
|
|
4705
|
+
const rows = readGeneration(
|
|
4706
|
+
`SELECT rowid AS row_id, id, time_created FROM ${table} WHERE rowid = ${rowId}`,
|
|
4707
|
+
);
|
|
4708
|
+
return rows ? opencodeDbRowAnchor(rows[0]) : null;
|
|
4709
|
+
};
|
|
4710
|
+
|
|
4711
|
+
const readCursorGeneration = ({ table, isV2, sql }) => {
|
|
4712
|
+
const previous = canResume
|
|
4713
|
+
? normalizeOpencodeDbWatermark(previousCursor?.[isV2 ? "v2" : "v1"])
|
|
4714
|
+
: null;
|
|
4715
|
+
let forceReplay = false;
|
|
4716
|
+
let lastRows = [];
|
|
4717
|
+
|
|
4718
|
+
// Each query uses a separate SQLite reader. Verify the same immutable head
|
|
4719
|
+
// row before and after the data query so an in-place database replacement
|
|
4720
|
+
// cannot make us persist a cursor from a generation we did not read.
|
|
4721
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
4722
|
+
const currentMaxRowId = readMaxRowId(table);
|
|
4723
|
+
if (currentMaxRowId === null) return null;
|
|
4724
|
+
const currentAnchor = readRowAnchor(table, currentMaxRowId);
|
|
4725
|
+
const previousAnchor = previous
|
|
4726
|
+
? readRowAnchor(table, previous.maxRowId)
|
|
4727
|
+
: null;
|
|
4728
|
+
const watermark =
|
|
4729
|
+
!forceReplay &&
|
|
4730
|
+
previous?.anchor &&
|
|
4731
|
+
currentMaxRowId >= previous.maxRowId &&
|
|
4732
|
+
previousAnchor === previous.anchor
|
|
4733
|
+
? previous
|
|
4734
|
+
: null;
|
|
4735
|
+
const rows = readGeneration(sql(watermark));
|
|
4736
|
+
if (!rows) return null;
|
|
4737
|
+
lastRows = rows;
|
|
4738
|
+
|
|
4739
|
+
if (readRowAnchor(table, currentMaxRowId) !== currentAnchor) {
|
|
4740
|
+
forceReplay = true;
|
|
4741
|
+
continue;
|
|
4742
|
+
}
|
|
4743
|
+
|
|
4744
|
+
let maxUpdatedAt = watermark?.maxUpdatedAt || 0;
|
|
4745
|
+
for (const row of rows) {
|
|
4746
|
+
maxUpdatedAt = Math.max(maxUpdatedAt, Math.floor(Number(row?.time_updated) || 0));
|
|
4747
|
+
}
|
|
4748
|
+
return {
|
|
4749
|
+
rows,
|
|
4750
|
+
// Advance only to the head whose anchor bracketed this query. Rows
|
|
4751
|
+
// appended concurrently may be returned now and harmlessly reread on
|
|
4752
|
+
// the next sync; advancing to them would require an unverified anchor.
|
|
4753
|
+
cursor: {
|
|
4754
|
+
maxRowId: currentMaxRowId,
|
|
4755
|
+
maxUpdatedAt,
|
|
4756
|
+
anchor: currentAnchor,
|
|
4757
|
+
},
|
|
4758
|
+
};
|
|
4759
|
+
}
|
|
4760
|
+
|
|
4761
|
+
// Keep parsed usage but refuse to advance while the database is unstable.
|
|
4762
|
+
return { rows: lastRows, cursor: null };
|
|
4763
|
+
};
|
|
4764
|
+
|
|
4515
4765
|
try {
|
|
4516
4766
|
// Combined probe drives the orchestration: v1 always runs (it is the
|
|
4517
4767
|
// baseline every database carries or degrades to), v2 runs only when the
|
|
@@ -4519,21 +4769,48 @@ function readOpencodeDbMessages(dbPath, sqliteOptions = {}) {
|
|
|
4519
4769
|
// against a non-existent session table on type-A databases.
|
|
4520
4770
|
const probe = detectOpencodeMessageLayout(effectiveDbPath, sqliteOptions);
|
|
4521
4771
|
const out = [];
|
|
4772
|
+
const nextCursor = {
|
|
4773
|
+
version: OPENCODE_DB_CURSOR_VERSION,
|
|
4774
|
+
identity,
|
|
4775
|
+
v1: null,
|
|
4776
|
+
v2: null,
|
|
4777
|
+
};
|
|
4522
4778
|
|
|
4523
|
-
const
|
|
4524
|
-
|
|
4779
|
+
const v1 = readCursorGeneration({
|
|
4780
|
+
table: "message",
|
|
4781
|
+
isV2: false,
|
|
4782
|
+
sql: buildV1Sql,
|
|
4783
|
+
});
|
|
4784
|
+
if (v1) {
|
|
4785
|
+
appendRows(v1.rows, false, out);
|
|
4786
|
+
nextCursor.v1 = v1.cursor;
|
|
4787
|
+
}
|
|
4525
4788
|
|
|
4526
4789
|
if (probe?.hasRows) {
|
|
4527
|
-
const
|
|
4528
|
-
|
|
4790
|
+
const v2 = readCursorGeneration({
|
|
4791
|
+
table: "session_message",
|
|
4792
|
+
isV2: true,
|
|
4793
|
+
sql: (watermark) => buildV2Sql(probe.sessionTable, watermark),
|
|
4794
|
+
});
|
|
4795
|
+
if (v2) {
|
|
4796
|
+
appendRows(v2.rows, true, out);
|
|
4797
|
+
nextCursor.v2 = v2.cursor;
|
|
4798
|
+
}
|
|
4529
4799
|
}
|
|
4530
4800
|
|
|
4531
|
-
return
|
|
4801
|
+
return {
|
|
4802
|
+
messages: out,
|
|
4803
|
+
cursor: nextCursor.v1 || nextCursor.v2 ? nextCursor : null,
|
|
4804
|
+
};
|
|
4532
4805
|
} finally {
|
|
4533
4806
|
if (snapshot) snapshot.cleanup();
|
|
4534
4807
|
}
|
|
4535
4808
|
}
|
|
4536
4809
|
|
|
4810
|
+
function readOpencodeDbMessages(dbPath, sqliteOptions = {}) {
|
|
4811
|
+
return readOpencodeDbMessagesIncremental(dbPath, null, sqliteOptions).messages;
|
|
4812
|
+
}
|
|
4813
|
+
|
|
4537
4814
|
// mimocode mirrors the user's Claude Code + claude-mem history into its own
|
|
4538
4815
|
// `message` table — via an explicit `claude_import` AND a live observer /
|
|
4539
4816
|
// session sync — so the overwhelming majority of rows are anthropic-endpoint
|
|
@@ -4791,6 +5068,7 @@ function readZcodeDbMessages(dbPath, sqliteOptions = {}) {
|
|
|
4791
5068
|
async function parseOpencodeDbIncremental({
|
|
4792
5069
|
dbMessages,
|
|
4793
5070
|
dbPath,
|
|
5071
|
+
dbCursor,
|
|
4794
5072
|
cursors,
|
|
4795
5073
|
queuePath,
|
|
4796
5074
|
projectQueuePath,
|
|
@@ -4815,9 +5093,20 @@ async function parseOpencodeDbIncremental({
|
|
|
4815
5093
|
const cursorNamespace = typeof cursorKey === "string" && cursorKey.length > 0 ? cursorKey : "opencode";
|
|
4816
5094
|
const opencodeState = normalizeOpencodeState(cursors?.[cursorNamespace]);
|
|
4817
5095
|
const messageIndex = opencodeState.messages;
|
|
4818
|
-
const fingerprintIndex = buildOpencodeFingerprintIndex(messageIndex);
|
|
4819
5096
|
const touchedBuckets = new Set();
|
|
4820
5097
|
const defaultSource = normalizeSourceInput(source) || "opencode";
|
|
5098
|
+
const candidateFingerprints = new Set();
|
|
5099
|
+
for (const entry of messages) {
|
|
5100
|
+
const totals = normalizeOpencodeTokens(entry?.data?.tokens);
|
|
5101
|
+
const fingerprint = totals
|
|
5102
|
+
? deriveOpencodeMessageFingerprint({ msg: entry.data, totals, source: defaultSource })
|
|
5103
|
+
: null;
|
|
5104
|
+
if (fingerprint) candidateFingerprints.add(fingerprint);
|
|
5105
|
+
}
|
|
5106
|
+
// A hook-triggered sync normally carries one changed row. Restrict the
|
|
5107
|
+
// historical fingerprint map to fingerprints that can actually match this
|
|
5108
|
+
// batch instead of duplicating the whole long-lived message index in memory.
|
|
5109
|
+
const fingerprintIndex = buildOpencodeFingerprintIndex(messageIndex, candidateFingerprints);
|
|
4821
5110
|
|
|
4822
5111
|
for (let idx = 0; idx < messages.length; idx++) {
|
|
4823
5112
|
const entry = messages[idx];
|
|
@@ -4867,6 +5156,7 @@ async function parseOpencodeDbIncremental({
|
|
|
4867
5156
|
if (lastTotals && prev?.dedupedForkCopy !== true) {
|
|
4868
5157
|
repairCountedOpencodeForkCopy({
|
|
4869
5158
|
msg,
|
|
5159
|
+
attribution: prev?.attribution,
|
|
4870
5160
|
totals: lastTotals,
|
|
4871
5161
|
source: defaultSource,
|
|
4872
5162
|
hourlyState,
|
|
@@ -4890,17 +5180,58 @@ async function parseOpencodeDbIncremental({
|
|
|
4890
5180
|
}
|
|
4891
5181
|
|
|
4892
5182
|
const effectiveLastTotals = prev?.dedupedForkCopy === true ? null : lastTotals;
|
|
4893
|
-
const delta =
|
|
4894
|
-
|
|
5183
|
+
const delta = diffOpencodeTotals(currentTotals, effectiveLastTotals);
|
|
5184
|
+
const timestampMs = coerceEpochMs(msg?.time?.completed) || coerceEpochMs(msg?.time?.created);
|
|
5185
|
+
if (!timestampMs) {
|
|
5186
|
+
messagesProcessed += 1;
|
|
5187
|
+
continue;
|
|
5188
|
+
}
|
|
5189
|
+
|
|
5190
|
+
const tsIso = new Date(timestampMs).toISOString();
|
|
5191
|
+
const bucketStart = toUtcHalfHourStart(tsIso);
|
|
5192
|
+
if (!bucketStart) {
|
|
5193
|
+
messagesProcessed += 1;
|
|
5194
|
+
continue;
|
|
5195
|
+
}
|
|
5196
|
+
|
|
5197
|
+
const { modelId: dbModelId } = normalizeOpencodeModelFields(msg);
|
|
5198
|
+
const model = dbModelId || DEFAULT_MODEL;
|
|
5199
|
+
const projectContext = projectEnabled
|
|
5200
|
+
? await resolveProjectContextForDb({
|
|
5201
|
+
msg,
|
|
5202
|
+
dbPath,
|
|
5203
|
+
projectMetaCache,
|
|
5204
|
+
publicRepoCache,
|
|
5205
|
+
publicRepoResolver,
|
|
5206
|
+
projectState,
|
|
5207
|
+
})
|
|
5208
|
+
: null;
|
|
5209
|
+
const attribution = {
|
|
5210
|
+
bucketStart,
|
|
5211
|
+
model,
|
|
5212
|
+
projectKey: projectContext?.projectKey || null,
|
|
5213
|
+
projectRef: projectContext?.projectRef || null,
|
|
5214
|
+
};
|
|
5215
|
+
|
|
5216
|
+
const previousAttribution = effectiveLastTotals
|
|
5217
|
+
? normalizeOpencodeAttribution(prev?.attribution)
|
|
5218
|
+
: null;
|
|
5219
|
+
const moved = Boolean(
|
|
5220
|
+
effectiveLastTotals &&
|
|
5221
|
+
previousAttribution &&
|
|
5222
|
+
!sameOpencodeAttribution(previousAttribution, attribution),
|
|
5223
|
+
);
|
|
5224
|
+
if ((!delta || isAllZeroUsage(delta)) && !moved) {
|
|
4895
5225
|
// Refresh the index even without a delta: normalization may have changed,
|
|
4896
|
-
// and pre-#426 entries need their fingerprint backfilled
|
|
4897
|
-
//
|
|
5226
|
+
// and pre-#426 entries need their fingerprint backfilled. Preserve an
|
|
5227
|
+
// existing contribution location without bloating every legacy cursor.
|
|
4898
5228
|
recordOpencodeMessage({
|
|
4899
5229
|
messageIndex,
|
|
4900
5230
|
fingerprintIndex,
|
|
4901
5231
|
messageKey,
|
|
4902
5232
|
totals: currentTotals,
|
|
4903
5233
|
fingerprint,
|
|
5234
|
+
attribution: prev?.attribution ? attribution : null,
|
|
4904
5235
|
dedupedForkCopy: false,
|
|
4905
5236
|
});
|
|
4906
5237
|
messagesProcessed += 1;
|
|
@@ -4915,49 +5246,37 @@ async function parseOpencodeDbIncremental({
|
|
|
4915
5246
|
}
|
|
4916
5247
|
continue;
|
|
4917
5248
|
}
|
|
4918
|
-
delta.conversation_count = 1;
|
|
4919
|
-
|
|
4920
|
-
const timestampMs = coerceEpochMs(msg?.time?.completed) || coerceEpochMs(msg?.time?.created);
|
|
4921
|
-
if (!timestampMs) {
|
|
4922
|
-
messagesProcessed += 1;
|
|
4923
|
-
continue;
|
|
4924
|
-
}
|
|
4925
5249
|
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
5250
|
+
if (moved) {
|
|
5251
|
+
subtractCountedOpencodeMessage({
|
|
5252
|
+
attribution: previousAttribution,
|
|
5253
|
+
totals: effectiveLastTotals,
|
|
5254
|
+
source: defaultSource,
|
|
5255
|
+
hourlyState,
|
|
5256
|
+
touchedBuckets,
|
|
5257
|
+
projectState,
|
|
5258
|
+
projectTouchedBuckets,
|
|
5259
|
+
});
|
|
4931
5260
|
}
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
5261
|
+
const contribution = moved
|
|
5262
|
+
? { ...currentTotals, conversation_count: 1 }
|
|
5263
|
+
: { ...delta, conversation_count: effectiveLastTotals ? 0 : 1 };
|
|
4935
5264
|
const bucket = getHourlyBucket(hourlyState, defaultSource, model, bucketStart);
|
|
4936
|
-
addTotals(bucket.totals,
|
|
5265
|
+
addTotals(bucket.totals, contribution);
|
|
4937
5266
|
touchedBuckets.add(bucketKey(defaultSource, model, bucketStart));
|
|
4938
5267
|
|
|
4939
|
-
if (
|
|
4940
|
-
const
|
|
4941
|
-
msg,
|
|
4942
|
-
dbPath,
|
|
4943
|
-
projectMetaCache,
|
|
4944
|
-
publicRepoCache,
|
|
4945
|
-
publicRepoResolver,
|
|
5268
|
+
if (attribution.projectKey && projectState && projectTouchedBuckets) {
|
|
5269
|
+
const projectBucket = getProjectBucket(
|
|
4946
5270
|
projectState,
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
projectRef,
|
|
4957
|
-
);
|
|
4958
|
-
addTotals(projectBucket.totals, delta);
|
|
4959
|
-
projectTouchedBuckets.add(projectBucketKey(projectKey, defaultSource, bucketStart));
|
|
4960
|
-
}
|
|
5271
|
+
attribution.projectKey,
|
|
5272
|
+
defaultSource,
|
|
5273
|
+
bucketStart,
|
|
5274
|
+
attribution.projectRef,
|
|
5275
|
+
);
|
|
5276
|
+
addTotals(projectBucket.totals, contribution);
|
|
5277
|
+
projectTouchedBuckets.add(
|
|
5278
|
+
projectBucketKey(attribution.projectKey, defaultSource, bucketStart),
|
|
5279
|
+
);
|
|
4961
5280
|
}
|
|
4962
5281
|
|
|
4963
5282
|
recordOpencodeMessage({
|
|
@@ -4966,10 +5285,11 @@ async function parseOpencodeDbIncremental({
|
|
|
4966
5285
|
messageKey,
|
|
4967
5286
|
totals: currentTotals,
|
|
4968
5287
|
fingerprint,
|
|
5288
|
+
attribution,
|
|
4969
5289
|
dedupedForkCopy: false,
|
|
4970
5290
|
});
|
|
4971
5291
|
messagesProcessed += 1;
|
|
4972
|
-
eventsAggregated += 1;
|
|
5292
|
+
if (delta && !isAllZeroUsage(delta)) eventsAggregated += 1;
|
|
4973
5293
|
|
|
4974
5294
|
if (cb) {
|
|
4975
5295
|
cb({
|
|
@@ -4988,6 +5308,7 @@ async function parseOpencodeDbIncremental({
|
|
|
4988
5308
|
: 0;
|
|
4989
5309
|
hourlyState.updatedAt = new Date().toISOString();
|
|
4990
5310
|
cursors.hourly = hourlyState;
|
|
5311
|
+
if (dbCursor && typeof dbCursor === "object") opencodeState.dbCursor = dbCursor;
|
|
4991
5312
|
opencodeState.updatedAt = new Date().toISOString();
|
|
4992
5313
|
cursors[cursorNamespace] = opencodeState;
|
|
4993
5314
|
if (projectState) {
|
|
@@ -13542,7 +13863,7 @@ async function parseCraftIncremental({
|
|
|
13542
13863
|
// COPILOT_OTEL_ENABLED=true
|
|
13543
13864
|
// COPILOT_OTEL_EXPORTER_TYPE=file
|
|
13544
13865
|
// COPILOT_OTEL_FILE_EXPORTER_PATH=$HOME/.copilot/otel/copilot-otel-...jsonl
|
|
13545
|
-
// We scan
|
|
13866
|
+
// We scan both known default directories plus the env-overridden path.
|
|
13546
13867
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
13547
13868
|
|
|
13548
13869
|
function resolveCopilotOtelPaths(env = process.env) {
|
|
@@ -13557,12 +13878,19 @@ function resolveCopilotOtelPaths(env = process.env) {
|
|
|
13557
13878
|
} catch (_e) {}
|
|
13558
13879
|
};
|
|
13559
13880
|
if (process.platform !== "win32" || wsl.shouldProbeNative(env)) {
|
|
13560
|
-
|
|
13881
|
+
for (const dir of [
|
|
13882
|
+
path.join(home, ".copilot", "otel"),
|
|
13883
|
+
path.join(home, ".copilot-otel"),
|
|
13884
|
+
]) {
|
|
13885
|
+
scanDir(dir);
|
|
13886
|
+
}
|
|
13561
13887
|
}
|
|
13562
13888
|
if (process.platform === "win32") {
|
|
13563
13889
|
if (wsl.shouldProbeWsl(env)) {
|
|
13564
|
-
const
|
|
13565
|
-
|
|
13890
|
+
for (const providerDir of [".copilot/otel", ".copilot-otel"]) {
|
|
13891
|
+
const wslDir = wsl.discoverWslHome(providerDir, { env });
|
|
13892
|
+
if (wslDir) scanDir(wslDir);
|
|
13893
|
+
}
|
|
13566
13894
|
}
|
|
13567
13895
|
}
|
|
13568
13896
|
const explicit = env.COPILOT_OTEL_FILE_EXPORTER_PATH;
|
|
@@ -13604,7 +13932,7 @@ function pickCopilotModel(attrs) {
|
|
|
13604
13932
|
return null;
|
|
13605
13933
|
}
|
|
13606
13934
|
|
|
13607
|
-
const COPILOT_PARSER_VERSION =
|
|
13935
|
+
const COPILOT_PARSER_VERSION = 3;
|
|
13608
13936
|
const COPILOT_USAGE_CLAIM_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
13609
13937
|
const COPILOT_USAGE_CLAIM_MAX_ENTRIES = 10_000;
|
|
13610
13938
|
|
|
@@ -13661,14 +13989,168 @@ function incrementMapCount(map, key, amount = 1) {
|
|
|
13661
13989
|
map.set(key, (map.get(key) || 0) + amount);
|
|
13662
13990
|
}
|
|
13663
13991
|
|
|
13664
|
-
function
|
|
13992
|
+
function getCopilotResponseId(attrs = {}) {
|
|
13993
|
+
const responseId = attrs["gen_ai.response.id"];
|
|
13994
|
+
return typeof responseId === "string" && responseId.trim() ? responseId.trim() : "";
|
|
13995
|
+
}
|
|
13996
|
+
|
|
13997
|
+
// v2 preferred spanContext for every OTEL envelope. Chat-extension LogRecords
|
|
13998
|
+
// can share that nested context across several model requests, so keep the old
|
|
13999
|
+
// key only for the one envelope that owns top-level traceId/spanId: CLI spans.
|
|
14000
|
+
function getCopilotLegacyDedupKey(record, attrs = record?.attributes || {}) {
|
|
13665
14001
|
const traceId = record?.traceId || record?.spanContext?.traceId || "";
|
|
13666
14002
|
const spanId = record?.spanId || record?.spanContext?.spanId || "";
|
|
13667
|
-
const responseId =
|
|
13668
|
-
typeof attrs["gen_ai.response.id"] === "string" ? attrs["gen_ai.response.id"] : "";
|
|
14003
|
+
const responseId = getCopilotResponseId(attrs);
|
|
13669
14004
|
return traceId && spanId ? `${traceId}:${spanId}` : responseId ? `resp:${responseId}` : null;
|
|
13670
14005
|
}
|
|
13671
14006
|
|
|
14007
|
+
function getCopilotDedupKey(record, attrs = record?.attributes || {}) {
|
|
14008
|
+
const responseId = getCopilotResponseId(attrs);
|
|
14009
|
+
if (!isCopilotV1ChatSpan(record)) {
|
|
14010
|
+
return responseId ? `resp:${responseId}` : null;
|
|
14011
|
+
}
|
|
14012
|
+
|
|
14013
|
+
const traceId = record?.traceId || "";
|
|
14014
|
+
const spanId = record?.spanId || "";
|
|
14015
|
+
return traceId && spanId
|
|
14016
|
+
? `${traceId}:${spanId}`
|
|
14017
|
+
: responseId
|
|
14018
|
+
? `resp:${responseId}`
|
|
14019
|
+
: null;
|
|
14020
|
+
}
|
|
14021
|
+
|
|
14022
|
+
function copilotOtelAggregateKey(model, bucketStart) {
|
|
14023
|
+
return JSON.stringify([model, bucketStart]);
|
|
14024
|
+
}
|
|
14025
|
+
|
|
14026
|
+
function addCopilotOtelAggregate(aggregates, model, bucketStart, delta) {
|
|
14027
|
+
const key = copilotOtelAggregateKey(model, bucketStart);
|
|
14028
|
+
let totals = aggregates.get(key);
|
|
14029
|
+
if (!totals) {
|
|
14030
|
+
totals = initTotals();
|
|
14031
|
+
aggregates.set(key, totals);
|
|
14032
|
+
}
|
|
14033
|
+
addTotals(totals, delta);
|
|
14034
|
+
}
|
|
14035
|
+
|
|
14036
|
+
function copilotTotalsCover(existing, required) {
|
|
14037
|
+
if (!existing || typeof existing !== "object") return false;
|
|
14038
|
+
for (const field of [
|
|
14039
|
+
"input_tokens",
|
|
14040
|
+
"cached_input_tokens",
|
|
14041
|
+
"cache_creation_input_tokens",
|
|
14042
|
+
"output_tokens",
|
|
14043
|
+
"reasoning_output_tokens",
|
|
14044
|
+
"total_tokens",
|
|
14045
|
+
"billable_total_tokens",
|
|
14046
|
+
"conversation_count",
|
|
14047
|
+
]) {
|
|
14048
|
+
const actual = Number(existing[field] || 0);
|
|
14049
|
+
const needed = Number(required?.[field] || 0);
|
|
14050
|
+
if (!Number.isFinite(actual) || !Number.isFinite(needed) || actual < needed) {
|
|
14051
|
+
return false;
|
|
14052
|
+
}
|
|
14053
|
+
}
|
|
14054
|
+
return true;
|
|
14055
|
+
}
|
|
14056
|
+
|
|
14057
|
+
function extractCopilotOtelUsage(record) {
|
|
14058
|
+
if (!isCopilotChatSpan(record)) return null;
|
|
14059
|
+
|
|
14060
|
+
const attrs = record.attributes || {};
|
|
14061
|
+
const inputRaw = toNonNegativeInt(attrs["gen_ai.usage.input_tokens"]);
|
|
14062
|
+
const output = toNonNegativeInt(attrs["gen_ai.usage.output_tokens"]);
|
|
14063
|
+
const cacheRead = toNonNegativeInt(
|
|
14064
|
+
attrs["gen_ai.usage.cache_read.input_tokens"] ??
|
|
14065
|
+
attrs["gen_ai.usage.cache_read_input_tokens"] ??
|
|
14066
|
+
attrs["gen_ai.usage.cached_input_tokens"],
|
|
14067
|
+
);
|
|
14068
|
+
// Copilot CLI: cache_write.input_tokens; Copilot Chat extension: cache_creation.input_tokens
|
|
14069
|
+
const cacheWrite = toNonNegativeInt(
|
|
14070
|
+
attrs["gen_ai.usage.cache_write.input_tokens"] ??
|
|
14071
|
+
attrs["gen_ai.usage.cache_creation.input_tokens"] ??
|
|
14072
|
+
attrs["gen_ai.usage.cache_write_input_tokens"] ??
|
|
14073
|
+
attrs["gen_ai.usage.cache_creation_input_tokens"],
|
|
14074
|
+
);
|
|
14075
|
+
// Copilot CLI: reasoning.output_tokens; Copilot Chat extension: reasoning_tokens
|
|
14076
|
+
const reasoning = toNonNegativeInt(
|
|
14077
|
+
attrs["gen_ai.usage.reasoning.output_tokens"] ??
|
|
14078
|
+
attrs["gen_ai.usage.reasoning_tokens"] ??
|
|
14079
|
+
attrs["gen_ai.usage.reasoning_output_tokens"],
|
|
14080
|
+
);
|
|
14081
|
+
const reasoningClamped = Math.min(reasoning, output);
|
|
14082
|
+
const outputWithoutReasoning = Math.max(0, output - reasoningClamped);
|
|
14083
|
+
const cliSpan = isCopilotV1ChatSpan(record);
|
|
14084
|
+
// CLI input includes both cache reads and writes. Chat-extension LogRecords
|
|
14085
|
+
// expose cache creation separately, so preserve their existing input-minus-read semantics.
|
|
14086
|
+
const cacheReadClamped = Math.min(cacheRead, inputRaw);
|
|
14087
|
+
const cacheWriteClamped = Math.min(
|
|
14088
|
+
cacheWrite,
|
|
14089
|
+
Math.max(0, inputRaw - cacheReadClamped),
|
|
14090
|
+
);
|
|
14091
|
+
const cacheWriteForAccounting = cliSpan ? cacheWriteClamped : cacheWrite;
|
|
14092
|
+
const input = Math.max(
|
|
14093
|
+
0,
|
|
14094
|
+
inputRaw - cacheReadClamped - (cliSpan ? cacheWriteClamped : 0),
|
|
14095
|
+
);
|
|
14096
|
+
const totalInteresting =
|
|
14097
|
+
input +
|
|
14098
|
+
outputWithoutReasoning +
|
|
14099
|
+
cacheReadClamped +
|
|
14100
|
+
cacheWriteForAccounting +
|
|
14101
|
+
reasoningClamped;
|
|
14102
|
+
if (totalInteresting === 0) return null;
|
|
14103
|
+
|
|
14104
|
+
// CLI Span uses endTime/startTime; Chat extension LogRecord uses hrTime/hrTimeObserved.
|
|
14105
|
+
const tsMs =
|
|
14106
|
+
copilotOtelTimeToMs(record.endTime) ||
|
|
14107
|
+
copilotOtelTimeToMs(record.startTime) ||
|
|
14108
|
+
copilotOtelTimeToMs(record.hrTime) ||
|
|
14109
|
+
copilotOtelTimeToMs(record.hrTimeObserved);
|
|
14110
|
+
if (!tsMs) return null;
|
|
14111
|
+
const bucketStart = toUtcHalfHourStart(new Date(tsMs).toISOString());
|
|
14112
|
+
if (!bucketStart) return null;
|
|
14113
|
+
|
|
14114
|
+
const model =
|
|
14115
|
+
normalizeCopilotAppModel(pickCopilotModel(attrs)) ||
|
|
14116
|
+
COPILOT_APP_DEFAULT_MODEL;
|
|
14117
|
+
const cliSessionId =
|
|
14118
|
+
typeof attrs["gen_ai.conversation.id"] === "string"
|
|
14119
|
+
? attrs["gen_ai.conversation.id"].trim()
|
|
14120
|
+
: "";
|
|
14121
|
+
const matchBase = {
|
|
14122
|
+
sessionId: cliSessionId,
|
|
14123
|
+
model,
|
|
14124
|
+
output: outputWithoutReasoning,
|
|
14125
|
+
cacheRead: cacheReadClamped,
|
|
14126
|
+
cacheWrite: cacheWriteClamped,
|
|
14127
|
+
reasoning: reasoningClamped,
|
|
14128
|
+
tsMs,
|
|
14129
|
+
};
|
|
14130
|
+
return {
|
|
14131
|
+
bucketStart,
|
|
14132
|
+
cliSpan,
|
|
14133
|
+
cliSessionId,
|
|
14134
|
+
delta: {
|
|
14135
|
+
input_tokens: input,
|
|
14136
|
+
cached_input_tokens: cacheReadClamped,
|
|
14137
|
+
cache_creation_input_tokens: cacheWriteForAccounting,
|
|
14138
|
+
output_tokens: outputWithoutReasoning,
|
|
14139
|
+
reasoning_output_tokens: reasoningClamped,
|
|
14140
|
+
total_tokens:
|
|
14141
|
+
input +
|
|
14142
|
+
outputWithoutReasoning +
|
|
14143
|
+
cacheReadClamped +
|
|
14144
|
+
cacheWriteForAccounting +
|
|
14145
|
+
reasoningClamped,
|
|
14146
|
+
conversation_count: 1,
|
|
14147
|
+
},
|
|
14148
|
+
matchBase,
|
|
14149
|
+
model,
|
|
14150
|
+
tsMs,
|
|
14151
|
+
};
|
|
14152
|
+
}
|
|
14153
|
+
|
|
13672
14154
|
function copilotUsageMatchKey({
|
|
13673
14155
|
sessionId,
|
|
13674
14156
|
model,
|
|
@@ -13728,6 +14210,141 @@ function createCopilotStoreUsageMatcher(events) {
|
|
|
13728
14210
|
};
|
|
13729
14211
|
}
|
|
13730
14212
|
|
|
14213
|
+
// v2 may already have advanced the file cursor while collapsing several Chat
|
|
14214
|
+
// extension LogRecords that shared one nested spanContext. Recompute only that
|
|
14215
|
+
// envelope's historical contribution and apply the delta to the existing
|
|
14216
|
+
// Copilot buckets. CLI spans are deliberately left alone: their v2 key was the
|
|
14217
|
+
// correct top-level traceId:spanId key, and session-store adoption can coexist
|
|
14218
|
+
// with the OTEL parser.
|
|
14219
|
+
async function migrateCopilotChatLogRecordDedup({
|
|
14220
|
+
files,
|
|
14221
|
+
fileOffsets,
|
|
14222
|
+
hourlyState,
|
|
14223
|
+
touchedBuckets,
|
|
14224
|
+
seenIds,
|
|
14225
|
+
} = {}) {
|
|
14226
|
+
const trackedFiles = Object.keys(fileOffsets || {}).filter(
|
|
14227
|
+
(filePath) => Number(fileOffsets[filePath]?.size) > 0,
|
|
14228
|
+
);
|
|
14229
|
+
if (trackedFiles.length === 0) {
|
|
14230
|
+
return { applied: true, changed: false };
|
|
14231
|
+
}
|
|
14232
|
+
|
|
14233
|
+
const availableFiles = new Set(Array.isArray(files) ? files : []);
|
|
14234
|
+
if (trackedFiles.some((filePath) => !availableFiles.has(filePath))) {
|
|
14235
|
+
return { applied: false, reason: "a previously parsed OTEL file is unavailable" };
|
|
14236
|
+
}
|
|
14237
|
+
|
|
14238
|
+
const oldSeen = new Set();
|
|
14239
|
+
const newSeen = new Set();
|
|
14240
|
+
const oldTotals = new Map();
|
|
14241
|
+
const newTotals = new Map();
|
|
14242
|
+
|
|
14243
|
+
for (const filePath of files) {
|
|
14244
|
+
if (!Object.prototype.hasOwnProperty.call(fileOffsets, filePath)) continue;
|
|
14245
|
+
const prevEntry = fileOffsets[filePath] || {};
|
|
14246
|
+
const prevSize = Number(prevEntry.size) || 0;
|
|
14247
|
+
if (prevSize <= 0) continue;
|
|
14248
|
+
|
|
14249
|
+
let stat;
|
|
14250
|
+
try {
|
|
14251
|
+
stat = fssync.statSync(filePath);
|
|
14252
|
+
} catch (_e) {
|
|
14253
|
+
return { applied: false, reason: "a previously parsed OTEL file is unreadable" };
|
|
14254
|
+
}
|
|
14255
|
+
if (
|
|
14256
|
+
stat.size < prevSize ||
|
|
14257
|
+
(typeof prevEntry.ino === "number" && stat.ino !== prevEntry.ino)
|
|
14258
|
+
) {
|
|
14259
|
+
return { applied: false, reason: "a previously parsed OTEL file changed during migration" };
|
|
14260
|
+
}
|
|
14261
|
+
|
|
14262
|
+
let stream;
|
|
14263
|
+
try {
|
|
14264
|
+
stream = fssync.createReadStream(filePath, {
|
|
14265
|
+
encoding: "utf8",
|
|
14266
|
+
start: 0,
|
|
14267
|
+
end: prevSize - 1,
|
|
14268
|
+
});
|
|
14269
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
14270
|
+
try {
|
|
14271
|
+
for await (const line of rl) {
|
|
14272
|
+
if (!line || !line.trim()) continue;
|
|
14273
|
+
let record;
|
|
14274
|
+
try {
|
|
14275
|
+
record = JSON.parse(line);
|
|
14276
|
+
} catch (_e) {
|
|
14277
|
+
continue;
|
|
14278
|
+
}
|
|
14279
|
+
const usage = extractCopilotOtelUsage(record);
|
|
14280
|
+
if (!usage || usage.cliSpan) continue;
|
|
14281
|
+
|
|
14282
|
+
const oldKey = getCopilotLegacyDedupKey(record, record.attributes || {});
|
|
14283
|
+
const oldDuplicate = oldKey && oldSeen.has(oldKey);
|
|
14284
|
+
if (oldKey) oldSeen.add(oldKey);
|
|
14285
|
+
if (!oldDuplicate) {
|
|
14286
|
+
addCopilotOtelAggregate(
|
|
14287
|
+
oldTotals,
|
|
14288
|
+
usage.model,
|
|
14289
|
+
usage.bucketStart,
|
|
14290
|
+
usage.delta,
|
|
14291
|
+
);
|
|
14292
|
+
}
|
|
14293
|
+
|
|
14294
|
+
const newKey = getCopilotDedupKey(record, record.attributes || {});
|
|
14295
|
+
const newDuplicate = newKey && newSeen.has(newKey);
|
|
14296
|
+
if (newKey) newSeen.add(newKey);
|
|
14297
|
+
if (!newDuplicate) {
|
|
14298
|
+
addCopilotOtelAggregate(
|
|
14299
|
+
newTotals,
|
|
14300
|
+
usage.model,
|
|
14301
|
+
usage.bucketStart,
|
|
14302
|
+
usage.delta,
|
|
14303
|
+
);
|
|
14304
|
+
}
|
|
14305
|
+
}
|
|
14306
|
+
} finally {
|
|
14307
|
+
rl.close();
|
|
14308
|
+
}
|
|
14309
|
+
} catch (_e) {
|
|
14310
|
+
return { applied: false, reason: "an OTEL file could not be scanned" };
|
|
14311
|
+
} finally {
|
|
14312
|
+
stream?.destroy();
|
|
14313
|
+
}
|
|
14314
|
+
}
|
|
14315
|
+
|
|
14316
|
+
const keys = new Set([...oldTotals.keys(), ...newTotals.keys()]);
|
|
14317
|
+
for (const key of keys) {
|
|
14318
|
+
const [model, bucketStart] = JSON.parse(key);
|
|
14319
|
+
const oldUsage = oldTotals.get(key) || initTotals();
|
|
14320
|
+
if (!copilotTotalsCover(
|
|
14321
|
+
hourlyState.buckets[bucketKey("copilot", model, bucketStart)]?.totals,
|
|
14322
|
+
oldUsage,
|
|
14323
|
+
)) {
|
|
14324
|
+
return {
|
|
14325
|
+
applied: false,
|
|
14326
|
+
reason: "existing Copilot buckets do not cover the old OTEL contribution",
|
|
14327
|
+
};
|
|
14328
|
+
}
|
|
14329
|
+
}
|
|
14330
|
+
|
|
14331
|
+
let changed = false;
|
|
14332
|
+
for (const key of keys) {
|
|
14333
|
+
const [model, bucketStart] = JSON.parse(key);
|
|
14334
|
+
const oldUsage = oldTotals.get(key) || initTotals();
|
|
14335
|
+
const newUsage = newTotals.get(key) || initTotals();
|
|
14336
|
+
if (totalsKey(oldUsage) === totalsKey(newUsage)) continue;
|
|
14337
|
+
const bucket = getHourlyBucket(hourlyState, "copilot", model, bucketStart);
|
|
14338
|
+
subtractTotals(bucket.totals, oldUsage);
|
|
14339
|
+
addTotals(bucket.totals, newUsage);
|
|
14340
|
+
touchedBuckets.add(bucketKey("copilot", model, bucketStart));
|
|
14341
|
+
changed = true;
|
|
14342
|
+
}
|
|
14343
|
+
|
|
14344
|
+
for (const id of newSeen) seenIds.add(id);
|
|
14345
|
+
return { applied: true, changed };
|
|
14346
|
+
}
|
|
14347
|
+
|
|
13731
14348
|
// Migration helper: stream the bytes v1 already saw (0 -> prevSize), classify
|
|
13732
14349
|
// whether the file contains old CLI spans v1 processed, and whether it also
|
|
13733
14350
|
// contains v2-only chat records v1 skipped. Mixed files must be replayed, but
|
|
@@ -13841,12 +14458,18 @@ async function parseCopilotIncremental({
|
|
|
13841
14458
|
seenIds.size > 0 || Object.keys(fileOffsetsRaw).length > 0;
|
|
13842
14459
|
let usageClaimsComplete =
|
|
13843
14460
|
copilotState.usageClaimsComplete === true || !hadPriorUsageHistory;
|
|
14461
|
+
const files = Array.isArray(otelPaths) && otelPaths.length > 0
|
|
14462
|
+
? otelPaths
|
|
14463
|
+
: resolveCopilotOtelPaths(env || process.env);
|
|
14464
|
+
const hourlyState = normalizeHourlyState(cursors?.hourly);
|
|
14465
|
+
const touchedBuckets = new Set();
|
|
13844
14466
|
const migrationSkipLineHashes = new Map();
|
|
14467
|
+
let cursorVersion = COPILOT_PARSER_VERSION;
|
|
13845
14468
|
// One-shot v1->v2 migration:
|
|
13846
14469
|
// - pure v2-only files: clear offset and re-read all skipped Chat records
|
|
13847
14470
|
// - pure v1 CLI files: preserve offset to avoid replaying history beyond seenIds
|
|
13848
14471
|
// - mixed files: clear offset, but skip old v1 CLI lines by hash during replay
|
|
13849
|
-
if (priorVersion <
|
|
14472
|
+
if (priorVersion < 2) {
|
|
13850
14473
|
for (const filePath of Object.keys(fileOffsets)) {
|
|
13851
14474
|
const prevSize = Number(fileOffsets[filePath]?.size) || 0;
|
|
13852
14475
|
const scan = await scanCopilotV1MigrationFile(filePath, prevSize);
|
|
@@ -13859,13 +14482,33 @@ async function parseCopilotIncremental({
|
|
|
13859
14482
|
}
|
|
13860
14483
|
}
|
|
13861
14484
|
|
|
13862
|
-
|
|
13863
|
-
|
|
13864
|
-
|
|
14485
|
+
// v2 used spanContext as the fallback key for Chat-extension LogRecords.
|
|
14486
|
+
// Those records can share one context across multiple model requests, so
|
|
14487
|
+
// repair the already-counted file prefix before switching to response.id.
|
|
14488
|
+
// If the prefix cannot be verified, leave the cursor at v2 and retry on the
|
|
14489
|
+
// next sync rather than replaying it with a different deduplication scheme.
|
|
14490
|
+
if (priorVersion === 2) {
|
|
14491
|
+
const migration = await migrateCopilotChatLogRecordDedup({
|
|
14492
|
+
files,
|
|
14493
|
+
fileOffsets,
|
|
14494
|
+
hourlyState,
|
|
14495
|
+
touchedBuckets,
|
|
14496
|
+
seenIds,
|
|
14497
|
+
});
|
|
14498
|
+
if (!migration.applied) {
|
|
14499
|
+
return {
|
|
14500
|
+
recordsProcessed: 0,
|
|
14501
|
+
eventsAggregated: 0,
|
|
14502
|
+
bucketsQueued: 0,
|
|
14503
|
+
usageClaims: recentOtelUsageEvents,
|
|
14504
|
+
};
|
|
14505
|
+
}
|
|
14506
|
+
}
|
|
14507
|
+
|
|
13865
14508
|
if (files.length === 0) {
|
|
13866
14509
|
cursors.copilot = {
|
|
13867
14510
|
...copilotState,
|
|
13868
|
-
version:
|
|
14511
|
+
version: cursorVersion,
|
|
13869
14512
|
seenIds: Array.from(seenIds),
|
|
13870
14513
|
fileOffsets,
|
|
13871
14514
|
recentUsageEvents: recentOtelUsageEvents,
|
|
@@ -13879,9 +14522,6 @@ async function parseCopilotIncremental({
|
|
|
13879
14522
|
usageClaims: recentOtelUsageEvents,
|
|
13880
14523
|
};
|
|
13881
14524
|
}
|
|
13882
|
-
|
|
13883
|
-
const hourlyState = normalizeHourlyState(cursors?.hourly);
|
|
13884
|
-
const touchedBuckets = new Set();
|
|
13885
14525
|
const cb = typeof onProgress === "function" ? onProgress : null;
|
|
13886
14526
|
let recordsProcessed = 0;
|
|
13887
14527
|
let eventsAggregated = 0;
|
|
@@ -13941,117 +14581,39 @@ async function parseCopilotIncremental({
|
|
|
13941
14581
|
// gen_ai.response.id is per-LLM-call unique.
|
|
13942
14582
|
const dedupKey = getCopilotDedupKey(record, attrs);
|
|
13943
14583
|
if (dedupKey && seenIds.has(dedupKey)) continue;
|
|
13944
|
-
|
|
14584
|
+
const cliSpan = isCopilotV1ChatSpan(record);
|
|
14585
|
+
if (skipCliSpans && cliSpan) {
|
|
13945
14586
|
if (dedupKey) seenIds.add(dedupKey);
|
|
13946
14587
|
continue;
|
|
13947
14588
|
}
|
|
13948
14589
|
|
|
13949
|
-
const
|
|
13950
|
-
|
|
13951
|
-
|
|
13952
|
-
attrs["gen_ai.usage.cache_read.input_tokens"] ??
|
|
13953
|
-
attrs["gen_ai.usage.cache_read_input_tokens"] ??
|
|
13954
|
-
attrs["gen_ai.usage.cached_input_tokens"],
|
|
13955
|
-
);
|
|
13956
|
-
// Copilot CLI: cache_write.input_tokens; Copilot Chat extension: cache_creation.input_tokens
|
|
13957
|
-
const cacheWrite = toNonNegativeInt(
|
|
13958
|
-
attrs["gen_ai.usage.cache_write.input_tokens"] ??
|
|
13959
|
-
attrs["gen_ai.usage.cache_creation.input_tokens"] ??
|
|
13960
|
-
attrs["gen_ai.usage.cache_write_input_tokens"] ??
|
|
13961
|
-
attrs["gen_ai.usage.cache_creation_input_tokens"],
|
|
13962
|
-
);
|
|
13963
|
-
// Copilot CLI: reasoning.output_tokens; Copilot Chat extension: reasoning_tokens
|
|
13964
|
-
const reasoning = toNonNegativeInt(
|
|
13965
|
-
attrs["gen_ai.usage.reasoning.output_tokens"] ??
|
|
13966
|
-
attrs["gen_ai.usage.reasoning_tokens"] ??
|
|
13967
|
-
attrs["gen_ai.usage.reasoning_output_tokens"],
|
|
13968
|
-
);
|
|
13969
|
-
const reasoningClamped = Math.min(reasoning, output);
|
|
13970
|
-
const outputWithoutReasoning = Math.max(0, output - reasoningClamped);
|
|
13971
|
-
const cliSpan = isCopilotV1ChatSpan(record);
|
|
13972
|
-
// CLI input includes both cache reads and writes. Chat-extension
|
|
13973
|
-
// LogRecords expose cache creation separately, so preserve their existing
|
|
13974
|
-
// input-minus-read semantics.
|
|
13975
|
-
const cacheReadClamped = Math.min(cacheRead, inputRaw);
|
|
13976
|
-
const cacheWriteClamped = Math.min(
|
|
13977
|
-
cacheWrite,
|
|
13978
|
-
Math.max(0, inputRaw - cacheReadClamped),
|
|
13979
|
-
);
|
|
13980
|
-
const cacheWriteForAccounting = cliSpan ? cacheWriteClamped : cacheWrite;
|
|
13981
|
-
const input = Math.max(
|
|
13982
|
-
0,
|
|
13983
|
-
inputRaw - cacheReadClamped - (cliSpan ? cacheWriteClamped : 0),
|
|
13984
|
-
);
|
|
13985
|
-
const totalInteresting =
|
|
13986
|
-
input +
|
|
13987
|
-
outputWithoutReasoning +
|
|
13988
|
-
cacheReadClamped +
|
|
13989
|
-
cacheWriteForAccounting +
|
|
13990
|
-
reasoningClamped;
|
|
13991
|
-
if (totalInteresting === 0) continue;
|
|
13992
|
-
|
|
13993
|
-
// CLI Span uses endTime/startTime; Chat extension LogRecord uses hrTime/hrTimeObserved.
|
|
13994
|
-
const tsMs =
|
|
13995
|
-
copilotOtelTimeToMs(record.endTime) ||
|
|
13996
|
-
copilotOtelTimeToMs(record.startTime) ||
|
|
13997
|
-
copilotOtelTimeToMs(record.hrTime) ||
|
|
13998
|
-
copilotOtelTimeToMs(record.hrTimeObserved);
|
|
13999
|
-
if (!tsMs) continue;
|
|
14000
|
-
const tsIso = new Date(tsMs).toISOString();
|
|
14001
|
-
const bucketStart = toUtcHalfHourStart(tsIso);
|
|
14002
|
-
if (!bucketStart) continue;
|
|
14003
|
-
|
|
14004
|
-
const model =
|
|
14005
|
-
normalizeCopilotAppModel(pickCopilotModel(attrs)) ||
|
|
14006
|
-
COPILOT_APP_DEFAULT_MODEL;
|
|
14007
|
-
const cliSessionId =
|
|
14008
|
-
typeof attrs["gen_ai.conversation.id"] === "string"
|
|
14009
|
-
? attrs["gen_ai.conversation.id"].trim()
|
|
14010
|
-
: "";
|
|
14011
|
-
if (cliSpan && !cliSessionId) usageClaimsComplete = false;
|
|
14012
|
-
const matchBase = {
|
|
14013
|
-
sessionId: cliSessionId,
|
|
14014
|
-
model,
|
|
14015
|
-
output: outputWithoutReasoning,
|
|
14016
|
-
cacheRead: cacheReadClamped,
|
|
14017
|
-
cacheWrite: cacheWriteClamped,
|
|
14018
|
-
reasoning: reasoningClamped,
|
|
14019
|
-
tsMs,
|
|
14020
|
-
};
|
|
14590
|
+
const usage = extractCopilotOtelUsage(record);
|
|
14591
|
+
if (!usage) continue;
|
|
14592
|
+
if (usage.cliSpan && !usage.cliSessionId) usageClaimsComplete = false;
|
|
14021
14593
|
const matchedStoreUsage =
|
|
14022
|
-
|
|
14594
|
+
usage.cliSpan &&
|
|
14023
14595
|
storeUsageMatcher.consume({
|
|
14024
|
-
...matchBase,
|
|
14025
|
-
input,
|
|
14596
|
+
...usage.matchBase,
|
|
14597
|
+
input: usage.delta.input_tokens,
|
|
14026
14598
|
});
|
|
14027
14599
|
if (matchedStoreUsage) {
|
|
14028
14600
|
if (dedupKey) seenIds.add(dedupKey);
|
|
14029
14601
|
continue;
|
|
14030
14602
|
}
|
|
14031
14603
|
|
|
14032
|
-
const
|
|
14033
|
-
|
|
14034
|
-
|
|
14035
|
-
|
|
14036
|
-
|
|
14037
|
-
|
|
14038
|
-
|
|
14039
|
-
|
|
14040
|
-
outputWithoutReasoning +
|
|
14041
|
-
cacheReadClamped +
|
|
14042
|
-
cacheWriteForAccounting +
|
|
14043
|
-
reasoningClamped,
|
|
14044
|
-
conversation_count: 1,
|
|
14045
|
-
};
|
|
14046
|
-
|
|
14047
|
-
const bucket = getHourlyBucket(hourlyState, "copilot", model, bucketStart);
|
|
14048
|
-
addTotals(bucket.totals, delta);
|
|
14049
|
-
touchedBuckets.add(bucketKey("copilot", model, bucketStart));
|
|
14604
|
+
const bucket = getHourlyBucket(
|
|
14605
|
+
hourlyState,
|
|
14606
|
+
"copilot",
|
|
14607
|
+
usage.model,
|
|
14608
|
+
usage.bucketStart,
|
|
14609
|
+
);
|
|
14610
|
+
addTotals(bucket.totals, usage.delta);
|
|
14611
|
+
touchedBuckets.add(bucketKey("copilot", usage.model, usage.bucketStart));
|
|
14050
14612
|
eventsAggregated++;
|
|
14051
|
-
if (cliSpan && cliSessionId) {
|
|
14613
|
+
if (usage.cliSpan && usage.cliSessionId) {
|
|
14052
14614
|
recentOtelUsageEvents.push({
|
|
14053
|
-
...matchBase,
|
|
14054
|
-
input,
|
|
14615
|
+
...usage.matchBase,
|
|
14616
|
+
input: usage.delta.input_tokens,
|
|
14055
14617
|
firstSeenAtMs: claimNowMs,
|
|
14056
14618
|
});
|
|
14057
14619
|
}
|
|
@@ -14093,7 +14655,7 @@ async function parseCopilotIncremental({
|
|
|
14093
14655
|
cursors.hourly = hourlyState;
|
|
14094
14656
|
cursors.copilot = {
|
|
14095
14657
|
...copilotState,
|
|
14096
|
-
version:
|
|
14658
|
+
version: cursorVersion,
|
|
14097
14659
|
seenIds: cappedSeen,
|
|
14098
14660
|
fileOffsets,
|
|
14099
14661
|
recentUsageEvents: retainedRecentOtelUsageEvents,
|
|
@@ -18281,6 +18843,7 @@ module.exports = {
|
|
|
18281
18843
|
listGeminiSessionFiles,
|
|
18282
18844
|
listOpencodeMessageFiles,
|
|
18283
18845
|
readOpencodeDbMessages,
|
|
18846
|
+
readOpencodeDbMessagesIncremental,
|
|
18284
18847
|
readMimoDbMessages,
|
|
18285
18848
|
readZcodeDbMessages,
|
|
18286
18849
|
hasZcodeNativeUsageSchema,
|