tokentracker-cli 1.5.1 → 1.5.2
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/dashboard/dist/assets/{AchievementBadge-mB7HZSGE.js → AchievementBadge-B5kmt0VD.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-DLzeq_eo.js → AchievementsPage-C0FlkOo0.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-DrXQyDns.js → AchievementsSection-CRcp80Pp.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-WrgKRybi.js → BadgeDetailModal-DAFjGbKW.js} +1 -1
- package/dashboard/dist/assets/{BotAnimated-DFRaixVK.js → BotAnimated-CO0bbimB.js} +1 -1
- package/dashboard/dist/assets/{Card-bFq2tlO8.js → Card-Xhr5exz8.js} +1 -1
- package/dashboard/dist/assets/{CloudCheckoutPage-gwhGxavy.js → CloudCheckoutPage-DsQ7FmsI.js} +1 -1
- package/dashboard/dist/assets/{CloudPage-BD4iBYeZ.js → CloudPage-DkSexu5V.js} +2 -2
- package/dashboard/dist/assets/{CommandPalette-C_KlPl0U.js → CommandPalette-Bm0huQdj.js} +1 -1
- package/dashboard/dist/assets/{CommunityStatsModal-Cl1Bgv8M.js → CommunityStatsModal-BsL8GN8Q.js} +1 -1
- package/dashboard/dist/assets/{ConfirmModal-NWoYevYC.js → ConfirmModal-_WPp292g.js} +1 -1
- package/dashboard/dist/assets/{Controls-D2-D2cfA.js → Controls-ktzn68KM.js} +1 -1
- package/dashboard/dist/assets/{DashboardPage-vITMnPXx.js → DashboardPage-CdNXi3Im.js} +1 -1
- package/dashboard/dist/assets/{DateRangePopover-8EMVXuH6.js → DateRangePopover-DWr4zIzF.js} +1 -1
- package/dashboard/dist/assets/{DevicePage-DbP8d6bM.js → DevicePage-CBa8Mk47.js} +1 -1
- package/dashboard/dist/assets/{DialogClose-CEstgLa1.js → DialogClose-CZ5jzgtb.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-kCZ4gN09.js +1 -0
- package/dashboard/dist/assets/{DialogTitle-DP5TLLbz.js → DialogTitle-Dr0N6q15.js} +1 -1
- package/dashboard/dist/assets/{ElectricLogo-Uvi3yMum.js → ElectricLogo-sYqgrO6-.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-Dpzihg6f.js → FadeIn-Qq-hcrZW.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-DllqemKr.js → HeaderGithubStar-B77zSv7i.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-Dc-TAH6l.js → HoverTooltip-lRDaUmoW.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-jd1D5ZKX.js → IpCheckPage-B6AvHZjA.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-CB1ZVw-a.js → LandingPage-DgzOjf3x.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardPage-Bt2cRRmE.js → LeaderboardPage-BtjjtDMC.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardProBadge-BVlyVOFf.js → LeaderboardProBadge-B2wNTUTU.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardProfileModal-CwQFRJg_.js → LeaderboardProfileModal-DJJckwHh.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfilePage-CeqcTbUG.js → LeaderboardProfilePage-DJdxByl2.js} +1 -1
- package/dashboard/dist/assets/LimitsPage-gSBHSBi_.js +6 -0
- package/dashboard/dist/assets/{LocalOnlyNotice-B2_HoCVD.js → LocalOnlyNotice-B2lVqf_H.js} +1 -1
- package/dashboard/dist/assets/{LoginCard-nw9JOc2d.js → LoginCard-i0gg1yby.js} +1 -1
- package/dashboard/dist/assets/{LoginModal-BHNLTK1D.js → LoginModal-B-loLNZM.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-LI2GZKhM.js → LoginPage-D_swI75j.js} +1 -1
- package/dashboard/dist/assets/{PetPage-CK6sPeI0.js → PetPage-DDpKvpjG.js} +2 -2
- package/dashboard/dist/assets/{PopoverPopup-BsXchEVa.js → PopoverPopup-Gja4MyEK.js} +1 -1
- package/dashboard/dist/assets/{ProviderIcon-vgCEy0dh.js → ProviderIcon-DOPj7tAZ.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-B4CPj2vX.js → ResetPasswordPage-BVADLk89.js} +1 -1
- package/dashboard/dist/assets/{Select-BkAy6P2r.js → Select-D-38tN-E.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-C0RNqDrD.js → SelectItemText-jN61ukxk.js} +1 -1
- package/dashboard/dist/assets/{SelfHostPage-B-_w-Z-a.js → SelfHostPage-k7AkGpl1.js} +1 -1
- package/dashboard/dist/assets/ServiceStatusPage-D_Fa_pYz.js +1 -0
- package/dashboard/dist/assets/{SessionsPage-CmWXBqD2.js → SessionsPage-BymccEEz.js} +1 -1
- package/dashboard/dist/assets/{SettingsPage-CA70zP-t.js → SettingsPage-DfnMCAuA.js} +3 -3
- package/dashboard/dist/assets/{SkillsPage-CNamhfzU.js → SkillsPage-YyIHIaHN.js} +1 -1
- package/dashboard/dist/assets/{TokenGalaxy-CXrVBSk6.js → TokenGalaxy-BLcvZuQ9.js} +1 -1
- package/dashboard/dist/assets/{TooltipPopup-CR4MqElX.js → TooltipPopup-3E8kyVzz.js} +1 -1
- package/dashboard/dist/assets/{TrendMonitor-DQfcrEve.js → TrendMonitor-ByKAdXSF.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-BBXxtZbP.js → WidgetsPage-BgEVIIzL.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-C8i2KlMF.js → WrappedPage-HjbX8QaA.js} +1 -1
- package/dashboard/dist/assets/__vite-browser-external-D_cMeuqI.js +45 -0
- package/dashboard/dist/assets/{agent-logos-CxQb8vir.js → agent-logos-BVF2siPQ.js} +1 -1
- package/dashboard/dist/assets/{arrow-left-D6zkcLVt.js → arrow-left-CYO24PGP.js} +1 -1
- package/dashboard/dist/assets/{arrow-right-BGb-XVn_.js → arrow-right-Xg9PZYhw.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-XTStagDJ.js → arrow-up-right-B7ah6Qv-.js} +1 -1
- package/dashboard/dist/assets/{calendar-days-Cz3NGZiS.js → calendar-days-BV_iG938.js} +1 -1
- package/dashboard/dist/assets/{check-1z_vmHPD.js → check--EZH8RTp.js} +1 -1
- package/dashboard/dist/assets/{chevron-down-CkSBY3bE.js → chevron-down-BzGTlk0-.js} +1 -1
- package/dashboard/dist/assets/{cloud-checkout-pD5C4r_S.js → cloud-checkout-BgyxPHEh.js} +1 -1
- package/dashboard/dist/assets/{composite-BtDzx3a2.js → composite-C0KrmLEK.js} +1 -1
- package/dashboard/dist/assets/{copy-Ctv_wxuD.js → copy-BtucSK2U.js} +1 -1
- package/dashboard/dist/assets/{download-DKbKMGSR.js → download-CWHq03Y2.js} +1 -1
- package/dashboard/dist/assets/{external-link-g9cU017t.js → external-link-DCTXF5VZ.js} +1 -1
- package/dashboard/dist/assets/{flame-DZq3kXUt.js → flame-BE5TsqVF.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-CXQfx0PC.js → format-tokens-ThNIMWnZ.js} +1 -1
- package/dashboard/dist/assets/{gift-CKlKjGP6.js → gift-DgaN9s4-.js} +1 -1
- package/dashboard/dist/assets/{icons-xPGMnBjl.js → icons-B4r5EiDC.js} +1 -1
- package/dashboard/dist/assets/{index-fVbP5wOv.js → index-CKhcCLNC.js} +1 -1
- package/dashboard/dist/assets/{index-BwOjMtB2.js → index-DFF9xo6f.js} +1 -1
- package/dashboard/dist/assets/{index-CDBOKcDh.js → index-DqCtgU0G.js} +1 -1
- package/dashboard/dist/assets/{index-Dl0oFLNr.js → index-fNKH4U7j.js} +1 -1
- package/dashboard/dist/assets/{info-D9xNIuGh.js → info-B4YzRR4y.js} +1 -1
- package/dashboard/dist/assets/{link-2-Bk5wYt2n.js → link-2-DzbUbaBw.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-CcN3pHWb.js → loader-circle-B9c7D-7v.js} +1 -1
- package/dashboard/dist/assets/{local-usage-transfer-CYcy1ici.js → local-usage-transfer-btuk_b0Y.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-DbpEzkJe.js → modifiers.esm-BjrU-Xz7.js} +1 -1
- package/dashboard/dist/assets/{monitor-smartphone-WkWv1qIS.js → monitor-smartphone-Bwn4tb4q.js} +1 -1
- package/dashboard/dist/assets/{play-2yZGWMes.js → play-DIFOwGIQ.js} +1 -1
- package/dashboard/dist/assets/{plus-mOF8tG1F.js → plus-BpBqnwVg.js} +1 -1
- package/dashboard/dist/assets/{provider-display-7gaOvgMp.js → provider-display-DSrs4u7T.js} +1 -1
- package/dashboard/dist/assets/{react-CdPiPDhk.js → react-BgG7_MY0.js} +1 -1
- package/dashboard/dist/assets/{safePolygon-C7FJLAlh.js → safePolygon-BUGWIDOM.js} +1 -1
- package/dashboard/dist/assets/{search-C63nCrjo.js → search-aHrmBmtZ.js} +1 -1
- package/dashboard/dist/assets/{server-BaG1rnuv.js → server-DOEvwlEU.js} +1 -1
- package/dashboard/dist/assets/{shield-check-D_4-Cm6L.js → shield-check-BPTnLhsT.js} +1 -1
- package/dashboard/dist/assets/{skills-api-DfbzwpDv.js → skills-api-C052Mwez.js} +1 -1
- package/dashboard/dist/assets/{sortable.esm-D-v8FRX5.js → sortable.esm-DaZKhjhu.js} +1 -1
- package/dashboard/dist/assets/{store-Dv_FYrXK.js → store-Cq30Pofw.js} +1 -1
- package/dashboard/dist/assets/{terminal-FQi6MaYt.js → terminal-CoSDSkE7.js} +1 -1
- package/dashboard/dist/assets/{trash-TbK96dEz.js → trash-BAmt-kPj.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-DGoWVZuk.js → use-community-stats-Bxq0kBhm.js} +1 -1
- package/dashboard/dist/assets/{use-latest-request-guard-Dg6uSaYt.js → use-latest-request-guard-DmIQDlvk.js} +1 -1
- package/dashboard/dist/assets/use-limits-display-prefs-CyAn1fme.js +1 -0
- package/dashboard/dist/assets/{use-native-settings-DIk0AF3b.js → use-native-settings-s_CIC8Bb.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list-CUqV3Zgl.js → use-ordered-list-BHhykAbt.js} +1 -1
- package/dashboard/dist/assets/use-reduced-motion-BmFyFE3S.js +1 -0
- package/dashboard/dist/assets/{use-transform-D_-w22c8.js → use-transform-CVeMFlS6.js} +1 -1
- package/dashboard/dist/assets/use-usage-limits-DRZMI20u.js +1 -0
- package/dashboard/dist/assets/{useCurrency-B_AuLCsL.js → useCurrency-DilqL2Iv.js} +1 -1
- package/dashboard/dist/assets/{usePositioner-DfxvIif8.js → usePositioner-BcnySw8a.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-bbO13V4s.js → useTokenFormat-CvKW46WP.js} +1 -1
- package/dashboard/dist/assets/{zap-k07-EXTK.js → zap-D7VRNTwk.js} +1 -1
- package/dashboard/dist/index.html +5 -1
- package/dashboard/dist/ip-check.html +1 -1
- package/dashboard/dist/leaderboard.html +1 -1
- package/dashboard/dist/share.html +1 -1
- package/package.json +1 -1
- package/src/lib/codex-rollout-parser.js +43 -6
- package/src/lib/codex-usage-record.js +21 -0
- package/src/lib/pricing/curated-overrides.json +94 -0
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/qoder-limits.js +78 -21
- package/src/lib/rollout.js +68 -14
- package/src/lib/session-analytics.js +10 -3
- package/src/lib/usage-limits.js +6 -4
- package/src/lib/workbuddy-limits.js +101 -9
- package/src/lib/zcode-limits.js +350 -43
- package/dashboard/dist/assets/DialogDescription-0wcygxtH.js +0 -1
- package/dashboard/dist/assets/LimitsPage-Bp4ZORmR.js +0 -6
- package/dashboard/dist/assets/ServiceStatusPage-5YevsGGD.js +0 -1
- package/dashboard/dist/assets/__vite-browser-external-BEEzY7mq.js +0 -45
- package/dashboard/dist/assets/use-limits-display-prefs-Djbdp7fi.js +0 -1
- package/dashboard/dist/assets/use-reduced-motion-DMCok-vZ.js +0 -1
- package/dashboard/dist/assets/use-usage-limits-h7aGsIQr.js +0 -1
package/src/lib/qoder-limits.js
CHANGED
|
@@ -105,7 +105,10 @@ function normalizeQuotaSummary(summary) {
|
|
|
105
105
|
if (total === 0 && (used !== 0 || remaining !== 0)) {
|
|
106
106
|
throw new Error("Qoder zero total quota must have zero usage and remaining.");
|
|
107
107
|
}
|
|
108
|
-
|
|
108
|
+
// An empty 0/0 quota (no active plan) renders as 0%, matching Qoder's own
|
|
109
|
+
// Credits UI and the desktop/CLI paths; a server-provided percentage still
|
|
110
|
+
// wins when present.
|
|
111
|
+
const usagePercentage = providedPercentage ?? (total > 0 ? (used / total) * 100 : 0);
|
|
109
112
|
return {
|
|
110
113
|
used,
|
|
111
114
|
total,
|
|
@@ -131,7 +134,7 @@ function normalizeQoderUsageResponse(response) {
|
|
|
131
134
|
const usedPercent = shared
|
|
132
135
|
? total > 0
|
|
133
136
|
? (used / total) * 100
|
|
134
|
-
:
|
|
137
|
+
: 0
|
|
135
138
|
: base.usagePercentage;
|
|
136
139
|
|
|
137
140
|
return {
|
|
@@ -279,16 +282,17 @@ function normalizeQoderRpcUsage(response) {
|
|
|
279
282
|
const used = quota ? finiteNumber(quota.used, "userQuota.used") : 0;
|
|
280
283
|
const total = quota ? finiteNumber(quota.total, "userQuota.total") : 0;
|
|
281
284
|
const remaining = quota ? finiteNumber(quota.remaining, "userQuota.remaining") : 0;
|
|
282
|
-
|
|
285
|
+
// totalUsagePercentage aggregates the plan and add-on buckets and reaches
|
|
286
|
+
// this function on mixed scales (the OpenAPI returns fractions, the desktop
|
|
287
|
+
// IPC returns percents), so the primary window always derives its percent
|
|
288
|
+
// from its own quota numbers.
|
|
283
289
|
const usedPercent = total === 0
|
|
284
290
|
? 0
|
|
285
291
|
: addOnWindow
|
|
286
292
|
? (used / total) * 100
|
|
287
293
|
: response.isQuotaExceeded === true
|
|
288
294
|
? 100
|
|
289
|
-
:
|
|
290
|
-
? reportedPercent
|
|
291
|
-
: (used / total) * 100;
|
|
295
|
+
: (used / total) * 100;
|
|
292
296
|
const normalizedExpiry = normalizeResetAt(response.expiresAt);
|
|
293
297
|
const expiryMs = normalizedExpiry ? Date.parse(normalizedExpiry) : NaN;
|
|
294
298
|
return {
|
|
@@ -727,6 +731,12 @@ function parseQoderAccountUsageLog(text, { fallbackObservedAtMs = null } = {}) {
|
|
|
727
731
|
return {
|
|
728
732
|
limits,
|
|
729
733
|
observedAtMs: Number.isFinite(lineObservedAtMs) ? lineObservedAtMs : fallbackObservedAtMs,
|
|
734
|
+
// The summary's own timestamp, or null when the line carries no parseable
|
|
735
|
+
// one and observedAtMs fell back to the file mtime. Callers that order
|
|
736
|
+
// this snapshot against other sources must use it, not observedAtMs: an
|
|
737
|
+
// mtime bump from an unrelated append would otherwise masquerade as a
|
|
738
|
+
// fresh observation.
|
|
739
|
+
lineObservedAtMs: Number.isFinite(lineObservedAtMs) ? lineObservedAtMs : null,
|
|
730
740
|
};
|
|
731
741
|
}
|
|
732
742
|
return null;
|
|
@@ -772,7 +782,7 @@ function parseQoderQuotaLog(text) {
|
|
|
772
782
|
// one; pruning to the current candidate set keeps the map bounded.
|
|
773
783
|
const qoderNoSummaryScans = new Map();
|
|
774
784
|
|
|
775
|
-
function
|
|
785
|
+
function readQoderLocalQuotaWithAge({
|
|
776
786
|
home = os.homedir(),
|
|
777
787
|
platform = process.platform,
|
|
778
788
|
env = process.env,
|
|
@@ -780,17 +790,23 @@ function readQoderLocalQuota({
|
|
|
780
790
|
envPrefix = "QODER",
|
|
781
791
|
nowMs = Date.now(),
|
|
782
792
|
maxAgeMs = QODER_LOG_MAX_AGE_MS,
|
|
793
|
+
olderThanMs = -Infinity,
|
|
783
794
|
} = {}) {
|
|
784
795
|
const candidates = [];
|
|
785
796
|
for (const logRoot of qoderLogRoots({ home, platform, env, appDir, envPrefix })) {
|
|
786
797
|
for (const entry of listRecentQoderLogs(logRoot)) {
|
|
787
798
|
if (nowMs - entry.mtimeMs > maxAgeMs) continue;
|
|
799
|
+
// A file last written at or before the cache snapshot cannot hold an
|
|
800
|
+
// observation newer than it (a line's time never exceeds the file
|
|
801
|
+
// mtime), so scanning it could never win the timestamp comparison.
|
|
802
|
+
if (entry.mtimeMs <= olderThanMs) continue;
|
|
788
803
|
candidates.push(entry);
|
|
789
804
|
}
|
|
790
805
|
}
|
|
791
806
|
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
792
807
|
const scanned = candidates.slice(0, QODER_LOG_MAX_FILES);
|
|
793
808
|
const seenPaths = new Set();
|
|
809
|
+
let best = null;
|
|
794
810
|
for (const { full, mtimeMs, size } of scanned) {
|
|
795
811
|
seenPaths.add(full);
|
|
796
812
|
const signature = `${size}:${mtimeMs}`;
|
|
@@ -804,7 +820,7 @@ function readQoderLocalQuota({
|
|
|
804
820
|
return accountUsage;
|
|
805
821
|
}
|
|
806
822
|
const legacy = parseQoderQuotaLog(text);
|
|
807
|
-
return legacy ? { limits: legacy, observedAtMs: mtimeMs } : null;
|
|
823
|
+
return legacy ? { limits: legacy, observedAtMs: mtimeMs, lineObservedAtMs: null } : null;
|
|
808
824
|
});
|
|
809
825
|
if (parsed === undefined) continue; // Scan failed (I/O); retry on the next call.
|
|
810
826
|
if (!parsed) {
|
|
@@ -813,12 +829,27 @@ function readQoderLocalQuota({
|
|
|
813
829
|
}
|
|
814
830
|
const observedAtMs = parsed.observedAtMs ?? mtimeMs;
|
|
815
831
|
if (nowMs - observedAtMs > maxAgeMs) continue;
|
|
816
|
-
|
|
832
|
+
// A summary that cannot beat the cache cutoff is not eligible: keep
|
|
833
|
+
// scanning, a deeper file may still hold a newer line that can.
|
|
834
|
+
if ((parsed.lineObservedAtMs ?? 0) <= olderThanMs) continue;
|
|
835
|
+
// A file's mtime order need not match its summary-line order: an append
|
|
836
|
+
// without a fresh summary leaves an old line in a freshly touched file.
|
|
837
|
+
// Keep the newest eligible line across the whole scan instead of the
|
|
838
|
+
// first hit — advancing the cache cutoff afterwards must not lock out a
|
|
839
|
+
// summary that was newer all along.
|
|
840
|
+
const lineObservedAtMs = parsed.lineObservedAtMs ?? null;
|
|
841
|
+
if (!best || (lineObservedAtMs ?? 0) > (best.lineObservedAtMs ?? 0)) {
|
|
842
|
+
best = { limits: parsed.limits, lineObservedAtMs };
|
|
843
|
+
}
|
|
817
844
|
}
|
|
818
845
|
for (const cachedPath of qoderNoSummaryScans.keys()) {
|
|
819
846
|
if (!seenPaths.has(cachedPath)) qoderNoSummaryScans.delete(cachedPath);
|
|
820
847
|
}
|
|
821
|
-
return
|
|
848
|
+
return best;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function readQoderLocalQuota(options = {}) {
|
|
852
|
+
return readQoderLocalQuotaWithAge(options)?.limits ?? null;
|
|
822
853
|
}
|
|
823
854
|
|
|
824
855
|
function siteFromEnv(value) {
|
|
@@ -899,9 +930,34 @@ async function fetchQoderLimits({
|
|
|
899
930
|
}
|
|
900
931
|
}
|
|
901
932
|
|
|
902
|
-
//
|
|
903
|
-
// the
|
|
933
|
+
// A 0.4.x desktop app refreshes its main.log summary whenever the account
|
|
934
|
+
// page reloads, so the newest line can be newer than the last-good cache.
|
|
935
|
+
// Compare the summary's own timestamp against the cache instead of always
|
|
936
|
+
// trusting the cache: only a parseable line timestamp may win, and files
|
|
937
|
+
// last written before the cache are skipped without a scan. Without a valid
|
|
938
|
+
// cache there is nothing to compare against, so the summary defers to the
|
|
939
|
+
// live manual-cookie session below and only serves as its fallback.
|
|
904
940
|
const cachedLimits = readQoderLimitsCache({ home, nowMs, namespace: cacheNamespace });
|
|
941
|
+
const cachedAtMs = Date.parse(cachedLimits?.cached_at || "");
|
|
942
|
+
const cacheFloorMs = Number.isFinite(cachedAtMs) ? cachedAtMs : -Infinity;
|
|
943
|
+
const localScan = readQoderLocalQuotaWithAge({
|
|
944
|
+
home,
|
|
945
|
+
platform,
|
|
946
|
+
env,
|
|
947
|
+
appDir,
|
|
948
|
+
envPrefix,
|
|
949
|
+
nowMs,
|
|
950
|
+
olderThanMs: cacheFloorMs,
|
|
951
|
+
});
|
|
952
|
+
if (localScan && Number.isFinite(cacheFloorMs) && (localScan.lineObservedAtMs ?? 0) > cacheFloorMs) {
|
|
953
|
+
const local = localScan.limits;
|
|
954
|
+
if (activityWindow) {
|
|
955
|
+
local.secondary_window = activityWindow;
|
|
956
|
+
local.source += activitySource === "disk-cache" ? "+cached-activity" : "+activity";
|
|
957
|
+
}
|
|
958
|
+
writeQoderLimitsCache(local, { home, nowMs, namespace: cacheNamespace });
|
|
959
|
+
return local;
|
|
960
|
+
}
|
|
905
961
|
if (cachedLimits) {
|
|
906
962
|
if (activityWindow) cachedLimits.secondary_window = activityWindow;
|
|
907
963
|
return cachedLimits;
|
|
@@ -930,15 +986,15 @@ async function fetchQoderLimits({
|
|
|
930
986
|
// Qoder 1.18 can return an all-zero active plan with usage_percentage=0
|
|
931
987
|
// while the desktop client separately marks the account quota-exceeded.
|
|
932
988
|
// Preserve that status as metadata, but match Qoder's Credits UI by
|
|
933
|
-
// rendering an empty 0/0 bucket as 0%, not a full red 100% bar.
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
if (
|
|
938
|
-
result.quota_exceeded =
|
|
989
|
+
// rendering an empty 0/0 bucket as 0%, not a full red 100% bar. With no
|
|
990
|
+
// valid cache the log scan above only ran as a companion — it lost its
|
|
991
|
+
// spot ahead of this session — so its result is still available here.
|
|
992
|
+
const zeroPlanLocal = result.primary_window?.limit_credits === 0 ? localScan : null;
|
|
993
|
+
if (zeroPlanLocal) {
|
|
994
|
+
result.quota_exceeded = zeroPlanLocal.limits.quota_exceeded === true;
|
|
939
995
|
result.primary_window.reset_at = null;
|
|
940
996
|
result.source = "provider-api+local-log";
|
|
941
|
-
result.plan_label =
|
|
997
|
+
result.plan_label = zeroPlanLocal.limits.plan_label || null;
|
|
942
998
|
}
|
|
943
999
|
if (activityWindow) {
|
|
944
1000
|
result.secondary_window = activityWindow;
|
|
@@ -956,8 +1012,8 @@ async function fetchQoderLimits({
|
|
|
956
1012
|
}
|
|
957
1013
|
}
|
|
958
1014
|
|
|
959
|
-
|
|
960
|
-
|
|
1015
|
+
if (localScan) {
|
|
1016
|
+
const local = localScan.limits;
|
|
961
1017
|
if (activityWindow) {
|
|
962
1018
|
local.secondary_window = activityWindow;
|
|
963
1019
|
local.source += activitySource === "disk-cache" ? "+cached-activity" : "+activity";
|
|
@@ -965,6 +1021,7 @@ async function fetchQoderLimits({
|
|
|
965
1021
|
writeQoderLimitsCache(local, { home, nowMs, namespace: cacheNamespace });
|
|
966
1022
|
return local;
|
|
967
1023
|
}
|
|
1024
|
+
|
|
968
1025
|
if (activityWindow) {
|
|
969
1026
|
return {
|
|
970
1027
|
configured: true,
|
package/src/lib/rollout.js
CHANGED
|
@@ -35,6 +35,7 @@ const {
|
|
|
35
35
|
createCompactionResponseIds,
|
|
36
36
|
createUsageRecordState,
|
|
37
37
|
extractTokenUsageRecord,
|
|
38
|
+
isCodexTotalOnlyResetSentinel,
|
|
38
39
|
isCodexTurnEndEvent,
|
|
39
40
|
isRecordOnly,
|
|
40
41
|
noteLineAfterUsageRecord,
|
|
@@ -3606,6 +3607,67 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
|
|
|
3606
3607
|
|
|
3607
3608
|
const toAppend = [];
|
|
3608
3609
|
for (const group of groupedBuckets.values()) {
|
|
3610
|
+
// A local reconstruction may opt into observed-only attribution. Preserve
|
|
3611
|
+
// every model bucket, including unknown, instead of guessing from the
|
|
3612
|
+
// busiest model in the same half-hour. The policy lives in cursor state
|
|
3613
|
+
// so hooks, desktop refreshes, and restarted CLI processes agree.
|
|
3614
|
+
if (hourlyState.modelAttributionPolicy?.[group.source] === "observed") {
|
|
3615
|
+
// A group last aligned under the default policy must not keep the stale
|
|
3616
|
+
// dominant attribution downstream. Mirror the default path's alignment
|
|
3617
|
+
// correction — one zero row for the old model — then observed mode
|
|
3618
|
+
// re-emits every bucket, unknown included, and the alignment state is
|
|
3619
|
+
// dropped so the correction fires at most once per group.
|
|
3620
|
+
const alignedModelBucket = group.buckets.get(DEFAULT_MODEL) || null;
|
|
3621
|
+
const staleAlignedModel = (() => {
|
|
3622
|
+
if (!alignedModelBucket?.alignedModel) return null;
|
|
3623
|
+
const normalized = normalizeModelInput(alignedModelBucket.alignedModel);
|
|
3624
|
+
return normalized && normalized !== DEFAULT_MODEL && !group.buckets.has(normalized)
|
|
3625
|
+
? normalized
|
|
3626
|
+
: null;
|
|
3627
|
+
})();
|
|
3628
|
+
if (staleAlignedModel) {
|
|
3629
|
+
const zeroTotals = initTotals();
|
|
3630
|
+
toAppend.push(JSON.stringify({
|
|
3631
|
+
source: group.source,
|
|
3632
|
+
model: staleAlignedModel,
|
|
3633
|
+
hour_start: group.hourStart,
|
|
3634
|
+
input_tokens: zeroTotals.input_tokens,
|
|
3635
|
+
cached_input_tokens: zeroTotals.cached_input_tokens,
|
|
3636
|
+
cache_creation_input_tokens: zeroTotals.cache_creation_input_tokens,
|
|
3637
|
+
output_tokens: zeroTotals.output_tokens,
|
|
3638
|
+
reasoning_output_tokens: zeroTotals.reasoning_output_tokens,
|
|
3639
|
+
total_tokens: zeroTotals.total_tokens,
|
|
3640
|
+
billable_total_tokens: zeroTotals.billable_total_tokens,
|
|
3641
|
+
total_cost_usd: zeroTotals.total_cost_usd,
|
|
3642
|
+
conversation_count: zeroTotals.conversation_count,
|
|
3643
|
+
}));
|
|
3644
|
+
}
|
|
3645
|
+
for (const bucket of group.buckets.values()) bucket.alignedModel = null;
|
|
3646
|
+
for (const [model, bucket] of group.buckets.entries()) {
|
|
3647
|
+
const totals = bucket.totals;
|
|
3648
|
+
const usagePrecision = bucket.usage_precision || null;
|
|
3649
|
+
const key = usagePrecision ? `${totalsKey(totals)}|${usagePrecision}` : totalsKey(totals);
|
|
3650
|
+
if (bucket.queuedKey === key) continue;
|
|
3651
|
+
toAppend.push(JSON.stringify({
|
|
3652
|
+
source: group.source,
|
|
3653
|
+
model,
|
|
3654
|
+
hour_start: group.hourStart,
|
|
3655
|
+
input_tokens: totals.input_tokens,
|
|
3656
|
+
cached_input_tokens: totals.cached_input_tokens,
|
|
3657
|
+
cache_creation_input_tokens: totals.cache_creation_input_tokens,
|
|
3658
|
+
output_tokens: totals.output_tokens,
|
|
3659
|
+
reasoning_output_tokens: totals.reasoning_output_tokens,
|
|
3660
|
+
total_tokens: totals.total_tokens,
|
|
3661
|
+
billable_total_tokens: totals.billable_total_tokens ?? totals.total_tokens,
|
|
3662
|
+
total_cost_usd: totals.total_cost_usd || 0,
|
|
3663
|
+
usage_precision: usagePrecision || undefined,
|
|
3664
|
+
...prioritySubsetOf(totals),
|
|
3665
|
+
conversation_count: totals.conversation_count,
|
|
3666
|
+
}));
|
|
3667
|
+
bucket.queuedKey = key;
|
|
3668
|
+
}
|
|
3669
|
+
continue;
|
|
3670
|
+
}
|
|
3609
3671
|
const unknownBucket = group.buckets.get(DEFAULT_MODEL) || null;
|
|
3610
3672
|
const dominantModel = pickDominantModel(group.buckets);
|
|
3611
3673
|
let alignedModel = null;
|
|
@@ -3977,6 +4039,10 @@ function normalizeHourlyState(raw) {
|
|
|
3977
4039
|
const rawBuckets = state.buckets && typeof state.buckets === "object" ? state.buckets : {};
|
|
3978
4040
|
const buckets = {};
|
|
3979
4041
|
const groupQueued = {};
|
|
4042
|
+
const modelAttributionPolicy = Object.fromEntries(
|
|
4043
|
+
Object.entries(state.modelAttributionPolicy || {})
|
|
4044
|
+
.filter(([, policy]) => policy === "observed"),
|
|
4045
|
+
);
|
|
3980
4046
|
|
|
3981
4047
|
if (!Number.isFinite(version) || version < 2) {
|
|
3982
4048
|
for (const [key, value] of Object.entries(rawBuckets)) {
|
|
@@ -3994,6 +4060,7 @@ function normalizeHourlyState(raw) {
|
|
|
3994
4060
|
version: 3,
|
|
3995
4061
|
buckets,
|
|
3996
4062
|
groupQueued,
|
|
4063
|
+
...(Object.keys(modelAttributionPolicy).length > 0 ? { modelAttributionPolicy } : {}),
|
|
3997
4064
|
updatedAt: typeof state.updatedAt === "string" ? state.updatedAt : null,
|
|
3998
4065
|
};
|
|
3999
4066
|
}
|
|
@@ -4013,6 +4080,7 @@ function normalizeHourlyState(raw) {
|
|
|
4013
4080
|
version: 3,
|
|
4014
4081
|
buckets,
|
|
4015
4082
|
groupQueued: version >= 3 ? existingGroupQueued : {},
|
|
4083
|
+
...(Object.keys(modelAttributionPolicy).length > 0 ? { modelAttributionPolicy } : {}),
|
|
4016
4084
|
updatedAt: typeof state.updatedAt === "string" ? state.updatedAt : null,
|
|
4017
4085
|
};
|
|
4018
4086
|
}
|
|
@@ -5246,20 +5314,6 @@ function normalizeUsage(u) {
|
|
|
5246
5314
|
return out;
|
|
5247
5315
|
}
|
|
5248
5316
|
|
|
5249
|
-
function isCodexTotalOnlyResetSentinel(lastUsage, totalUsage) {
|
|
5250
|
-
const componentTotal = (usage) => [
|
|
5251
|
-
usage?.input_tokens,
|
|
5252
|
-
usage?.cached_input_tokens,
|
|
5253
|
-
usage?.cache_creation_input_tokens ?? usage?.cache_write_input_tokens,
|
|
5254
|
-
usage?.output_tokens,
|
|
5255
|
-
usage?.reasoning_output_tokens,
|
|
5256
|
-
].reduce((sum, value) => sum + Math.max(0, Number(value) || 0), 0);
|
|
5257
|
-
return Number(lastUsage?.total_tokens || 0) > 0
|
|
5258
|
-
&& Number(totalUsage?.total_tokens || 0) === 0
|
|
5259
|
-
&& componentTotal(lastUsage) === 0
|
|
5260
|
-
&& componentTotal(totalUsage) === 0;
|
|
5261
|
-
}
|
|
5262
|
-
|
|
5263
5317
|
// Stable dedup key for one Claude jsonl entry. Anthropic's official protocol
|
|
5264
5318
|
// guarantees `message.id` is globally unique per response, so msgId alone is a
|
|
5265
5319
|
// valid dedup key. Older code required both msgId AND requestId, which short-
|
|
@@ -93,6 +93,8 @@ const {
|
|
|
93
93
|
// date-filtered session views and cached sidecars keep the 6x premium.
|
|
94
94
|
// v27 adds oh-my-pi (omp) sessions from ~/.omp/agent/sessions/**/*.jsonl.
|
|
95
95
|
const SIDECAR_VERSION = 27;
|
|
96
|
+
// Invalidate only Codex token summaries; other sources keep their cached rows.
|
|
97
|
+
const CODEX_USAGE_VERSION = 1;
|
|
96
98
|
const EDIT_TOOLS = new Set([
|
|
97
99
|
"apply_patch",
|
|
98
100
|
"edit",
|
|
@@ -803,6 +805,7 @@ async function scanCodexSession(filePath) {
|
|
|
803
805
|
seenTokenEvents: new Set(),
|
|
804
806
|
collectBreakdowns: false,
|
|
805
807
|
collectModelUsage: true,
|
|
808
|
+
ledgerUsage: true,
|
|
806
809
|
onObject(obj, model) {
|
|
807
810
|
signalCollector.consume(obj, model);
|
|
808
811
|
performanceCollector.consume(obj, model);
|
|
@@ -2273,7 +2276,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
2273
2276
|
previousMeta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
|
|
2274
2277
|
const checkedAt = Date.parse(previousMeta.checked_at || previousMeta.generated_at || "");
|
|
2275
2278
|
if (
|
|
2276
|
-
!retryZcodeScan && !retryOpencodeScan && !retryKimiScan && !retryOmpScan && !retryDshScan && previousMeta.version === SIDECAR_VERSION &&
|
|
2279
|
+
!retryZcodeScan && !retryOpencodeScan && !retryKimiScan && !retryOmpScan && !retryDshScan && previousMeta.version === SIDECAR_VERSION && previousMeta.codex_usage_version === CODEX_USAGE_VERSION &&
|
|
2277
2280
|
Number.isFinite(checkedAt) &&
|
|
2278
2281
|
Date.now() - checkedAt < Math.max(0, Number(cacheTtlMs) || 0)
|
|
2279
2282
|
) {
|
|
@@ -2314,7 +2317,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
2314
2317
|
// db + its WAL sidecars: the trio is one logical file for change detection.
|
|
2315
2318
|
...[...(discovered.zcode || []), ...(discovered.opencode || [])].flatMap((dbPath) => [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]),
|
|
2316
2319
|
]);
|
|
2317
|
-
if (!force && !retryZcodeScan && !retryOpencodeScan && !retryKimiScan && !retryOmpScan && !retryDshScan && previousMeta?.version === SIDECAR_VERSION && previousMeta.signature === signature) {
|
|
2320
|
+
if (!force && !retryZcodeScan && !retryOpencodeScan && !retryKimiScan && !retryOmpScan && !retryDshScan && previousMeta?.version === SIDECAR_VERSION && previousMeta.codex_usage_version === CODEX_USAGE_VERSION && previousMeta.signature === signature) {
|
|
2318
2321
|
await writeAtomic(metaPath, `${JSON.stringify({ ...previousMeta, checked_at: new Date().toISOString() })}\n`);
|
|
2319
2322
|
return readSidecar(sidecarPath);
|
|
2320
2323
|
}
|
|
@@ -2344,6 +2347,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
2344
2347
|
// vanished mid-scan). Swallowing these silently made sessions disappear with
|
|
2345
2348
|
// no signal at all; count them so the API can say so.
|
|
2346
2349
|
let skippedFiles = 0;
|
|
2350
|
+
let incompleteCodexUsage = false;
|
|
2347
2351
|
let incompleteZcodeScan = false;
|
|
2348
2352
|
let incompleteOpencodeScan = false;
|
|
2349
2353
|
let incompleteKimiScan = false;
|
|
@@ -2354,6 +2358,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
2354
2358
|
const statKey = analyticsEntryStatKey(entry.source, entry.filePath);
|
|
2355
2359
|
if (!statKey) {
|
|
2356
2360
|
skippedFiles += 1;
|
|
2361
|
+
if (entry.source === "codex") incompleteCodexUsage = true;
|
|
2357
2362
|
if (entry.source === "zcode") incompleteZcodeScan = true;
|
|
2358
2363
|
if (entry.source === "opencode") incompleteOpencodeScan = true;
|
|
2359
2364
|
if (entry.source === "kimi") incompleteKimiScan = true;
|
|
@@ -2362,7 +2367,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
2362
2367
|
continue;
|
|
2363
2368
|
}
|
|
2364
2369
|
let rows = null;
|
|
2365
|
-
if (!force && !(entry.source === "zcode" && retryZcodeScan) && !(entry.source === "opencode" && retryOpencodeScan) && !(entry.source === "kimi" && retryKimiScan) && !(entry.source === "omp" && retryOmpScan) && !(entry.source === "dsh" && retryDshScan) && previousFiles[cacheKey]?.stat_key === statKey) {
|
|
2370
|
+
if (!force && !(entry.source === "codex" && previousMeta?.codex_usage_version !== CODEX_USAGE_VERSION) && !(entry.source === "zcode" && retryZcodeScan) && !(entry.source === "opencode" && retryOpencodeScan) && !(entry.source === "kimi" && retryKimiScan) && !(entry.source === "omp" && retryOmpScan) && !(entry.source === "dsh" && retryDshScan) && previousFiles[cacheKey]?.stat_key === statKey) {
|
|
2366
2371
|
if (entry.source === "zcode" || entry.source === "opencode" || entry.source === "kimi" || entry.source === "omp" || entry.source === "dsh") {
|
|
2367
2372
|
// One db scans to many cached rows; recover them all from the previous
|
|
2368
2373
|
// pass. An empty recovery means the sidecar lost its rows — rescan.
|
|
@@ -2392,6 +2397,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
2392
2397
|
// One active/partial session must not poison the sidecar, but do not
|
|
2393
2398
|
// pretend it never existed either.
|
|
2394
2399
|
skippedFiles += 1;
|
|
2400
|
+
if (entry.source === "codex") incompleteCodexUsage = true;
|
|
2395
2401
|
if (entry.source === "zcode") incompleteZcodeScan = true;
|
|
2396
2402
|
if (entry.source === "opencode") incompleteOpencodeScan = true;
|
|
2397
2403
|
if (entry.source === "kimi") incompleteKimiScan = true;
|
|
@@ -2455,6 +2461,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
2455
2461
|
const generatedAt = new Date().toISOString();
|
|
2456
2462
|
await writeAtomic(metaPath, `${JSON.stringify({
|
|
2457
2463
|
version: SIDECAR_VERSION,
|
|
2464
|
+
codex_usage_version: incompleteCodexUsage ? 0 : CODEX_USAGE_VERSION,
|
|
2458
2465
|
signature,
|
|
2459
2466
|
generated_at: generatedAt,
|
|
2460
2467
|
checked_at: generatedAt,
|
package/src/lib/usage-limits.js
CHANGED
|
@@ -35,7 +35,7 @@ const { fetchDevinLimits } = require("./devin-limits");
|
|
|
35
35
|
const { fetchQoderLimits, fetchQoderCnLimits } = require("./qoder-limits");
|
|
36
36
|
const { fetchArkCodingPlanLimits } = require("./ark-coding-plan-limits");
|
|
37
37
|
const { fetchArkAgentPlanLimits } = require("./ark-agent-plan-limits");
|
|
38
|
-
const { fetchWorkbuddyLimits, fetchWorkbuddyAiLimits
|
|
38
|
+
const { fetchWorkbuddyLimits, fetchWorkbuddyAiLimits } = require("./workbuddy-limits");
|
|
39
39
|
const { fetchProviderServiceStatus } = require("./provider-status");
|
|
40
40
|
const { resolveKimiProfile } = require("./kimi-profile");
|
|
41
41
|
const { readSqliteJsonRows, readSqliteJsonRowsAsync } = require("./sqlite-reader");
|
|
@@ -3938,7 +3938,7 @@ function normalizePlanLabel(raw, brand) {
|
|
|
3938
3938
|
let s = String(raw).replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
|
|
3939
3939
|
if (!s) return null;
|
|
3940
3940
|
const lower = s.toLowerCase();
|
|
3941
|
-
if (["free", "none", "unknown"].includes(lower)) return null;
|
|
3941
|
+
if (["free", "none", "unknown", "invalid"].includes(lower)) return null;
|
|
3942
3942
|
if (brand && lower === brand.toLowerCase()) return null; // e.g. Kiro defaults plan_name to "Kiro" when parse fails
|
|
3943
3943
|
if (brand) {
|
|
3944
3944
|
s = s.replace(new RegExp("^" + brand + "\\s+", "i"), "").trim();
|
|
@@ -4506,8 +4506,10 @@ async function fetchUsageLimitsUncached({
|
|
|
4506
4506
|
codingPlan: withPlanLabel(codingPlan, codingPlan?.plan_label, "Ark Coding Plan"),
|
|
4507
4507
|
agentPlan: withPlanLabel(agentPlan, agentPlan?.plan_label, "Ark Agent Plan"),
|
|
4508
4508
|
// WorkBuddy preserves provider English names and official edition labels
|
|
4509
|
-
// verbatim (including Free, STANDARD and Pro-Trial).
|
|
4510
|
-
|
|
4509
|
+
// verbatim (including Free, STANDARD and Pro-Trial). China and AI publish
|
|
4510
|
+
// as separate providers — one row per signed-in edition, never merged.
|
|
4511
|
+
workbuddy,
|
|
4512
|
+
workbuddyAi,
|
|
4511
4513
|
};
|
|
4512
4514
|
|
|
4513
4515
|
for (const [providerName, provider] of Object.entries(data)) {
|
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
// WorkBuddy adds its own credentials, including encrypted desktop sessions.
|
|
13
13
|
// Legacy plaintext credentials remain a fallback when no local IPC exists.
|
|
14
14
|
//
|
|
15
|
-
// Only
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
15
|
+
// Only two windows are returned, mirroring the desktop app's own split:
|
|
16
|
+
// the subscription plan package (the row the app calls 套餐基础积分, with that
|
|
17
|
+
// package's cycle end as the reset) and one pooled resource-package window
|
|
18
|
+
// for every non-plan credit group (套餐赠送积分 / 购买积分 / 平台奖励积分).
|
|
19
|
+
// Display names are English regardless of the backend's localized names.
|
|
19
20
|
|
|
20
21
|
const fs = require("node:fs");
|
|
21
22
|
const path = require("node:path");
|
|
@@ -278,8 +279,8 @@ function planWindow(row) {
|
|
|
278
279
|
};
|
|
279
280
|
}
|
|
280
281
|
|
|
281
|
-
//
|
|
282
|
-
// editions
|
|
282
|
+
// Legacy merge for the pre-split unified row. The local app now publishes
|
|
283
|
+
// both editions side by side; cloud mirrors of this module still merge.
|
|
283
284
|
function selectWorkbuddyLimits(china, international) {
|
|
284
285
|
const editions = [china, international].filter(Boolean);
|
|
285
286
|
const usable = (value) => value.configured === true && !value.error;
|
|
@@ -289,9 +290,10 @@ function selectWorkbuddyLimits(china, international) {
|
|
|
289
290
|
|| { configured: false };
|
|
290
291
|
}
|
|
291
292
|
|
|
292
|
-
// Pick the one subscription-plan package. Reward rows are ignored
|
|
293
|
-
// several plan packages exist, the
|
|
294
|
-
// matching the desktop app's
|
|
293
|
+
// Pick the one subscription-plan package. Reward rows are ignored here — they
|
|
294
|
+
// feed the resource-pool window instead. When several plan packages exist, the
|
|
295
|
+
// highest tier that still has credit wins, matching the desktop app's
|
|
296
|
+
// pickActivePlanBase.
|
|
295
297
|
function selectWorkbuddyPlanQuota(rows, identity = {}, variantId = "workbuddy") {
|
|
296
298
|
const merged = mergePackageRows(rows.filter(Boolean));
|
|
297
299
|
const subscriptionPackageCode = identity.code || "";
|
|
@@ -309,6 +311,93 @@ function selectWorkbuddyPlanQuota(rows, identity = {}, variantId = "workbuddy")
|
|
|
309
311
|
};
|
|
310
312
|
}
|
|
311
313
|
|
|
314
|
+
// The desktop app's billing meter splits an account into CreditGroups:
|
|
315
|
+
// plan_base (套餐基础积分) plus the non-plan pools plan_bonus (套餐赠送积分),
|
|
316
|
+
// addon (购买积分) and benefit (平台奖励积分). The app shows those pools as
|
|
317
|
+
// resource packages on top of the plan, so mirror that split: one window for
|
|
318
|
+
// the plan cycle, one pooled window for everything else. Groups the summary
|
|
319
|
+
// does not quantify are ignored; a pool with no credit at all stays null so
|
|
320
|
+
// the UI drops it like a missing window.
|
|
321
|
+
function collectResourceGroups(summary) {
|
|
322
|
+
const data = unwrapBillingBody(summary);
|
|
323
|
+
if (!data) return null;
|
|
324
|
+
const groups = [
|
|
325
|
+
data.CreditGroups,
|
|
326
|
+
data.creditGroups,
|
|
327
|
+
data.credit_groups,
|
|
328
|
+
].find(Array.isArray);
|
|
329
|
+
if (groups) {
|
|
330
|
+
const pool = { total: 0, used: 0, remain: 0, remainDerived: 0, seen: false, usedSeen: false, remainSeen: false };
|
|
331
|
+
for (const group of groups) {
|
|
332
|
+
if (!group || typeof group !== "object") continue;
|
|
333
|
+
const kind = firstString(group, ["Kind", "kind"]);
|
|
334
|
+
if (kind && kind.toLowerCase() === "plan_base") continue;
|
|
335
|
+
const total = firstCount(group, ["CycleTotalCapacity", "total"]);
|
|
336
|
+
const used = firstCount(group, ["CycleUsedCapacity", "used"]);
|
|
337
|
+
const remain = firstCount(group, ["CycleRemainCapacity", "remain"]);
|
|
338
|
+
if (total === null && used === null && remain === null) continue;
|
|
339
|
+
pool.seen = true;
|
|
340
|
+
// A group that omits its usage is still counted: derive it from the
|
|
341
|
+
// capacity identity, the way planWindow does for the plan row.
|
|
342
|
+
const knownUsed = used ?? (total !== null && remain !== null ? Math.max(0, total - remain) : null);
|
|
343
|
+
pool.usedSeen = pool.usedSeen || knownUsed !== null;
|
|
344
|
+
pool.remainSeen = pool.remainSeen || remain !== null;
|
|
345
|
+
pool.total += total ?? 0;
|
|
346
|
+
pool.used += knownUsed ?? 0;
|
|
347
|
+
if (remain === null) pool.remainDerived += Math.max(0, (total ?? 0) - (used ?? 0));
|
|
348
|
+
else pool.remain += remain;
|
|
349
|
+
}
|
|
350
|
+
return pool.seen ? pool : null;
|
|
351
|
+
}
|
|
352
|
+
// Older summaries predate CreditGroups. Their package rows carry the same
|
|
353
|
+
// pools one row per package — every row that cannot be the subscription
|
|
354
|
+
// plan is resource credit.
|
|
355
|
+
const identity = readSubscriptionIdentity(summary);
|
|
356
|
+
const rows = collectPackageRows(summary)
|
|
357
|
+
.filter((row) => !isSelectablePlan(row, identity.code));
|
|
358
|
+
if (rows.length === 0) return null;
|
|
359
|
+
const derivedUsed = (row) =>
|
|
360
|
+
row.used ?? (row.total !== null && row.remain !== null ? Math.max(0, row.total - row.remain) : null);
|
|
361
|
+
return {
|
|
362
|
+
total: rows.reduce((sum, row) => sum + (row.total ?? 0), 0),
|
|
363
|
+
used: rows.reduce((sum, row) => sum + (derivedUsed(row) ?? 0), 0),
|
|
364
|
+
remain: rows.reduce((sum, row) => sum + (row.remain ?? 0), 0),
|
|
365
|
+
remainDerived: rows.reduce(
|
|
366
|
+
(sum, row) => sum + (row.remain === null ? Math.max(0, (row.total ?? 0) - (row.used ?? 0)) : 0),
|
|
367
|
+
0,
|
|
368
|
+
),
|
|
369
|
+
seen: true,
|
|
370
|
+
usedSeen: rows.some((row) => derivedUsed(row) !== null),
|
|
371
|
+
remainSeen: rows.some((row) => row.remain !== null),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Resource credits have no shared cycle: benefit and add-on packages carry
|
|
376
|
+
// their own grants, so the pooled window has no reset. An empty pool (0/0)
|
|
377
|
+
// yields no window instead of a full or zeroed bar.
|
|
378
|
+
function resourceWindow(pool) {
|
|
379
|
+
if (!pool || !(pool.total > 0) || !Number.isFinite(pool.total)) return null;
|
|
380
|
+
// No group reported either side of the meter: usage is unknowable, not
|
|
381
|
+
// zero — hide the pool instead of rendering a full bar.
|
|
382
|
+
if (!pool.usedSeen && !pool.remainSeen) return null;
|
|
383
|
+
const used = pool.usedSeen || pool.used > 0
|
|
384
|
+
? pool.used
|
|
385
|
+
: Math.max(0, pool.total - (pool.remain + pool.remainDerived));
|
|
386
|
+
const usedPercent = clampPercent((used / pool.total) * 100);
|
|
387
|
+
if (usedPercent === null) return null;
|
|
388
|
+
const remaining = pool.remainSeen
|
|
389
|
+
? pool.remain + pool.remainDerived
|
|
390
|
+
: Math.max(0, pool.total - used);
|
|
391
|
+
if (!Number.isFinite(remaining)) return null;
|
|
392
|
+
return {
|
|
393
|
+
used_percent: usedPercent,
|
|
394
|
+
reset_at: null,
|
|
395
|
+
used_credits: used,
|
|
396
|
+
limit_credits: pool.total,
|
|
397
|
+
remaining_credits: remaining,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
312
401
|
function englishPlanLabel(row, identity, variantId) {
|
|
313
402
|
// An actual English subscription/package name takes precedence over the
|
|
314
403
|
// client's edition fallback. Unknown localized tiers stay unnamed.
|
|
@@ -479,6 +568,7 @@ async function fetchPlanQuota(post, variantId) {
|
|
|
479
568
|
configured: true, error: null,
|
|
480
569
|
plan_label: quota?.plan_label || englishPlanLabel({ code: identity.code }, identity, variantId),
|
|
481
570
|
primary_window: quota?.primary_window || null,
|
|
571
|
+
secondary_window: resourceWindow(collectResourceGroups(summary)),
|
|
482
572
|
};
|
|
483
573
|
}
|
|
484
574
|
|
|
@@ -556,6 +646,8 @@ module.exports = {
|
|
|
556
646
|
selectWorkbuddyPlanQuota,
|
|
557
647
|
selectWorkbuddyLimits,
|
|
558
648
|
collectPackageRows,
|
|
649
|
+
collectResourceGroups,
|
|
650
|
+
resourceWindow,
|
|
559
651
|
readSubscriptionIdentity,
|
|
560
652
|
originAllowed,
|
|
561
653
|
hostAllowed,
|