tokentracker-cli 1.5.0 → 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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/dashboard/dist/.well-known/submitby-ai/6e2fa70e-9eb2-4c97-a9a9-fed567a3d4a9.txt +1 -0
- package/dashboard/dist/assets/AchievementBadge-B5kmt0VD.js +1 -0
- package/dashboard/dist/assets/{AchievementsPage-9_MxdI7O.js → AchievementsPage-C0FlkOo0.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-CKDQGcJh.js → AchievementsSection-CRcp80Pp.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-DdrXPVRL.js → BadgeDetailModal-DAFjGbKW.js} +1 -1
- package/dashboard/dist/assets/{BotAnimated-DsJDdDid.js → BotAnimated-CO0bbimB.js} +1 -1
- package/dashboard/dist/assets/{Card-Bz-sWY1C.js → Card-Xhr5exz8.js} +1 -1
- package/dashboard/dist/assets/{CloudCheckoutPage-DRjZcE0W.js → CloudCheckoutPage-DsQ7FmsI.js} +1 -1
- package/dashboard/dist/assets/{CloudPage-BNDyyX9r.js → CloudPage-DkSexu5V.js} +2 -2
- package/dashboard/dist/assets/{CommandPalette-LbE6Icc0.js → CommandPalette-Bm0huQdj.js} +1 -1
- package/dashboard/dist/assets/{CommunityStatsModal-DuEcAOB9.js → CommunityStatsModal-BsL8GN8Q.js} +1 -1
- package/dashboard/dist/assets/{ConfirmModal-B5V7ONKv.js → ConfirmModal-_WPp292g.js} +1 -1
- package/dashboard/dist/assets/{Controls-ZfN0QwwD.js → Controls-ktzn68KM.js} +1 -1
- package/dashboard/dist/assets/{DashboardPage-DqZPFmlv.js → DashboardPage-CdNXi3Im.js} +1 -1
- package/dashboard/dist/assets/{DateRangePopover-CbkBeaEI.js → DateRangePopover-DWr4zIzF.js} +1 -1
- package/dashboard/dist/assets/{DevicePage-DrHjmpPH.js → DevicePage-CBa8Mk47.js} +1 -1
- package/dashboard/dist/assets/{DialogClose-DHaAwV1u.js → DialogClose-CZ5jzgtb.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-kCZ4gN09.js +1 -0
- package/dashboard/dist/assets/{DialogTitle-CBhd4oLc.js → DialogTitle-Dr0N6q15.js} +1 -1
- package/dashboard/dist/assets/{ElectricLogo-CSWd3b2H.js → ElectricLogo-sYqgrO6-.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-D5p6nDla.js → FadeIn-Qq-hcrZW.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-BKVSzY27.js → HeaderGithubStar-B77zSv7i.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-CvkAyCDm.js → HoverTooltip-lRDaUmoW.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-BEMJGKtl.js → IpCheckPage-B6AvHZjA.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-iRp33enQ.js → LandingPage-DgzOjf3x.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardPage-DVMPrN4z.js → LeaderboardPage-BtjjtDMC.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardProBadge-DwnaJoje.js → LeaderboardProBadge-B2wNTUTU.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardProfileModal-DnzVweLM.js → LeaderboardProfileModal-DJJckwHh.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfilePage-BHNuPuW7.js → LeaderboardProfilePage-DJdxByl2.js} +1 -1
- package/dashboard/dist/assets/LimitsPage-gSBHSBi_.js +6 -0
- package/dashboard/dist/assets/{LocalOnlyNotice-DStlbPET.js → LocalOnlyNotice-B2lVqf_H.js} +1 -1
- package/dashboard/dist/assets/{LoginCard-I47ukXU5.js → LoginCard-i0gg1yby.js} +1 -1
- package/dashboard/dist/assets/{LoginModal-C4iotNjY.js → LoginModal-B-loLNZM.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-46QSEnG0.js → LoginPage-D_swI75j.js} +1 -1
- package/dashboard/dist/assets/{PetPage-SvgDUR8o.js → PetPage-DDpKvpjG.js} +2 -2
- package/dashboard/dist/assets/{PopoverPopup-k2ejLK1I.js → PopoverPopup-Gja4MyEK.js} +1 -1
- package/dashboard/dist/assets/{ProviderIcon-Bx1XKzin.js → ProviderIcon-DOPj7tAZ.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-CcKIDknp.js → ResetPasswordPage-BVADLk89.js} +1 -1
- package/dashboard/dist/assets/{Select-D0hLQyU8.js → Select-D-38tN-E.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-DJGBJhDK.js → SelectItemText-jN61ukxk.js} +1 -1
- package/dashboard/dist/assets/{SelfHostPage-CeIFGRep.js → SelfHostPage-k7AkGpl1.js} +1 -1
- package/dashboard/dist/assets/ServiceStatusPage-D_Fa_pYz.js +1 -0
- package/dashboard/dist/assets/{SessionsPage-D6h5vSZn.js → SessionsPage-BymccEEz.js} +1 -1
- package/dashboard/dist/assets/{SettingsPage-BZgpB5SB.js → SettingsPage-DfnMCAuA.js} +3 -3
- package/dashboard/dist/assets/{SkillsPage-CtFuZ1ob.js → SkillsPage-YyIHIaHN.js} +1 -1
- package/dashboard/dist/assets/{TokenGalaxy-DC-no047.js → TokenGalaxy-BLcvZuQ9.js} +1 -1
- package/dashboard/dist/assets/{TooltipPopup-ZM0Ie2_C.js → TooltipPopup-3E8kyVzz.js} +1 -1
- package/dashboard/dist/assets/{TrendMonitor-fpLaqJ-o.js → TrendMonitor-ByKAdXSF.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-DIoHY6ZJ.js → WidgetsPage-BgEVIIzL.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-D1-7o8Z1.js → WrappedPage-HjbX8QaA.js} +1 -1
- package/dashboard/dist/assets/__vite-browser-external-D_cMeuqI.js +45 -0
- package/dashboard/dist/assets/{agent-logos-YBC1iAbE.js → agent-logos-BVF2siPQ.js} +1 -1
- package/dashboard/dist/assets/{arrow-left-BIe3z7iW.js → arrow-left-CYO24PGP.js} +1 -1
- package/dashboard/dist/assets/{arrow-right-CJmRcvYZ.js → arrow-right-Xg9PZYhw.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-IgkBHoCl.js → arrow-up-right-B7ah6Qv-.js} +1 -1
- package/dashboard/dist/assets/{calendar-days-DTuIv3ts.js → calendar-days-BV_iG938.js} +1 -1
- package/dashboard/dist/assets/{check-DrXhhj46.js → check--EZH8RTp.js} +1 -1
- package/dashboard/dist/assets/{chevron-down-DHaFKHMK.js → chevron-down-BzGTlk0-.js} +1 -1
- package/dashboard/dist/assets/{cloud-checkout-BffVMyzI.js → cloud-checkout-BgyxPHEh.js} +1 -1
- package/dashboard/dist/assets/{composite-M_CP_KSx.js → composite-C0KrmLEK.js} +1 -1
- package/dashboard/dist/assets/{copy-D5xoS-Q8.js → copy-BtucSK2U.js} +1 -1
- package/dashboard/dist/assets/{download-CqomMv4-.js → download-CWHq03Y2.js} +1 -1
- package/dashboard/dist/assets/{external-link--q3pCw6b.js → external-link-DCTXF5VZ.js} +1 -1
- package/dashboard/dist/assets/{flame-CUGlDV__.js → flame-BE5TsqVF.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-BZ6TX9Km.js → format-tokens-ThNIMWnZ.js} +1 -1
- package/dashboard/dist/assets/{gift-BBgOeC20.js → gift-DgaN9s4-.js} +1 -1
- package/dashboard/dist/assets/{icons-BELeB4ms.js → icons-B4r5EiDC.js} +1 -1
- package/dashboard/dist/assets/{index-B1B6oAbl.js → index-CKhcCLNC.js} +1 -1
- package/dashboard/dist/assets/{index-CsdgnKGf.js → index-DFF9xo6f.js} +1 -1
- package/dashboard/dist/assets/{index-CoRqgJ71.js → index-DqCtgU0G.js} +1 -1
- package/dashboard/dist/assets/{index-DkBvk4Qg.js → index-fNKH4U7j.js} +33 -33
- package/dashboard/dist/assets/{info-BG1vQZ1r.js → info-B4YzRR4y.js} +1 -1
- package/dashboard/dist/assets/{link-2-oi5TrXqi.js → link-2-DzbUbaBw.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-BQQ_Xbby.js → loader-circle-B9c7D-7v.js} +1 -1
- package/dashboard/dist/assets/{local-usage-transfer-Dwz-clJ-.js → local-usage-transfer-btuk_b0Y.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-C7a817l7.js → modifiers.esm-BjrU-Xz7.js} +1 -1
- package/dashboard/dist/assets/module-ZyVsxBG_.js +3 -0
- package/dashboard/dist/assets/{monitor-smartphone-Bnbo7uRg.js → monitor-smartphone-Bwn4tb4q.js} +1 -1
- package/dashboard/dist/assets/{play-C_xI_JM_.js → play-DIFOwGIQ.js} +1 -1
- package/dashboard/dist/assets/{plus-xF6PK5As.js → plus-BpBqnwVg.js} +1 -1
- package/dashboard/dist/assets/{provider-display-DxquW3js.js → provider-display-DSrs4u7T.js} +1 -1
- package/dashboard/dist/assets/{react-CGK0_x3G.js → react-BgG7_MY0.js} +1 -1
- package/dashboard/dist/assets/{safePolygon-CCoDMR4f.js → safePolygon-BUGWIDOM.js} +1 -1
- package/dashboard/dist/assets/{search-EZAGpmfJ.js → search-aHrmBmtZ.js} +1 -1
- package/dashboard/dist/assets/{server-2qbrx2G0.js → server-DOEvwlEU.js} +1 -1
- package/dashboard/dist/assets/{shield-check-D8kUejpE.js → shield-check-BPTnLhsT.js} +1 -1
- package/dashboard/dist/assets/{skills-api-DYPf1Vxh.js → skills-api-C052Mwez.js} +1 -1
- package/dashboard/dist/assets/{sortable.esm-D9gC08dQ.js → sortable.esm-DaZKhjhu.js} +1 -1
- package/dashboard/dist/assets/{store-BWUCbK6Y.js → store-Cq30Pofw.js} +1 -1
- package/dashboard/dist/assets/{terminal-B16b7r6H.js → terminal-CoSDSkE7.js} +1 -1
- package/dashboard/dist/assets/{trash-BIEoE13C.js → trash-BAmt-kPj.js} +1 -1
- package/dashboard/dist/assets/use-community-stats-Bxq0kBhm.js +1 -0
- package/dashboard/dist/assets/{use-latest-request-guard-CYKu-VOB.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-vTg4j5Ln.js → use-native-settings-s_CIC8Bb.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list-CtXedlqH.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-CdeW5xhz.js → use-transform-CVeMFlS6.js} +1 -1
- package/dashboard/dist/assets/use-usage-limits-DRZMI20u.js +1 -0
- package/dashboard/dist/assets/{useCurrency-bqycM681.js → useCurrency-DilqL2Iv.js} +1 -1
- package/dashboard/dist/assets/{usePositioner-DEXS10fY.js → usePositioner-BcnySw8a.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-YF6hY8_m.js → useTokenFormat-CvKW46WP.js} +1 -1
- package/dashboard/dist/assets/{zap-Dg-ij-lq.js → zap-D7VRNTwk.js} +1 -1
- package/dashboard/dist/index.html +24 -1
- package/dashboard/dist/indexnow-key.txt +1 -0
- package/dashboard/dist/ip-check.html +1 -1
- package/dashboard/dist/leaderboard.html +1 -1
- package/dashboard/dist/share.html +1 -1
- package/dashboard/dist/tt6a1c9e2f4b8d.txt +1 -0
- package/package.json +3 -3
- 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 +252 -55
- 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/AchievementBadge-Dn39pyx9.js +0 -1
- package/dashboard/dist/assets/DialogDescription-CEY6jbs9.js +0 -1
- package/dashboard/dist/assets/LimitsPage-9LitacBf.js +0 -6
- package/dashboard/dist/assets/ServiceStatusPage-DMtw0YDh.js +0 -1
- package/dashboard/dist/assets/__vite-browser-external-DZF5L1pt.js +0 -45
- package/dashboard/dist/assets/module-C2SOSUuR.js +0 -3
- package/dashboard/dist/assets/use-community-stats-DWS7YKbM.js +0 -1
- package/dashboard/dist/assets/use-limits-display-prefs-CEAjVxFr.js +0 -1
- package/dashboard/dist/assets/use-reduced-motion-DVLsAhSA.js +0 -1
- package/dashboard/dist/assets/use-usage-limits-DmDa5Py8.js +0 -1
package/src/lib/qoder-limits.js
CHANGED
|
@@ -40,6 +40,14 @@ const QODER_ACTIVITY_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
|
40
40
|
const QODER_LIMITS_CACHE_FILE = "qoder-usage-limits-cache.json";
|
|
41
41
|
const QODER_LIMITS_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
42
42
|
const QODER_LIMITS_CACHE_UNKNOWN_RESET_TTL_MS = 12 * 60 * 60 * 1000;
|
|
43
|
+
// Qoder Desktop 0.4+ writes its account usage summary to main.log, which can
|
|
44
|
+
// accumulate a whole session before the quota is read. Scan a bounded window
|
|
45
|
+
// backwards instead of assuming the summary is near EOF.
|
|
46
|
+
const QODER_LOG_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
47
|
+
const QODER_LOG_SCAN_MAX_BYTES = 8 * 1024 * 1024;
|
|
48
|
+
const QODER_LOG_SCAN_CHUNK_BYTES = 512 * 1024;
|
|
49
|
+
const QODER_LOG_SCAN_OVERLAP_BYTES = 64 * 1024;
|
|
50
|
+
const QODER_LOG_MAX_FILES = 8;
|
|
43
51
|
const QODER_SERVER_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
|
|
44
52
|
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
|
|
45
53
|
4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
|
|
@@ -97,7 +105,10 @@ function normalizeQuotaSummary(summary) {
|
|
|
97
105
|
if (total === 0 && (used !== 0 || remaining !== 0)) {
|
|
98
106
|
throw new Error("Qoder zero total quota must have zero usage and remaining.");
|
|
99
107
|
}
|
|
100
|
-
|
|
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);
|
|
101
112
|
return {
|
|
102
113
|
used,
|
|
103
114
|
total,
|
|
@@ -123,7 +134,7 @@ function normalizeQoderUsageResponse(response) {
|
|
|
123
134
|
const usedPercent = shared
|
|
124
135
|
? total > 0
|
|
125
136
|
? (used / total) * 100
|
|
126
|
-
:
|
|
137
|
+
: 0
|
|
127
138
|
: base.usagePercentage;
|
|
128
139
|
|
|
129
140
|
return {
|
|
@@ -271,16 +282,17 @@ function normalizeQoderRpcUsage(response) {
|
|
|
271
282
|
const used = quota ? finiteNumber(quota.used, "userQuota.used") : 0;
|
|
272
283
|
const total = quota ? finiteNumber(quota.total, "userQuota.total") : 0;
|
|
273
284
|
const remaining = quota ? finiteNumber(quota.remaining, "userQuota.remaining") : 0;
|
|
274
|
-
|
|
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.
|
|
275
289
|
const usedPercent = total === 0
|
|
276
290
|
? 0
|
|
277
291
|
: addOnWindow
|
|
278
292
|
? (used / total) * 100
|
|
279
293
|
: response.isQuotaExceeded === true
|
|
280
294
|
? 100
|
|
281
|
-
:
|
|
282
|
-
? reportedPercent
|
|
283
|
-
: (used / total) * 100;
|
|
295
|
+
: (used / total) * 100;
|
|
284
296
|
const normalizedExpiry = normalizeResetAt(response.expiresAt);
|
|
285
297
|
const expiryMs = normalizedExpiry ? Date.parse(normalizedExpiry) : NaN;
|
|
286
298
|
return {
|
|
@@ -575,7 +587,38 @@ async function fetchQoderUsage(cookieHeader, site, fetchImpl) {
|
|
|
575
587
|
return normalizeQoderUsageResponse(await response.json());
|
|
576
588
|
}
|
|
577
589
|
|
|
578
|
-
function
|
|
590
|
+
function qoderLogRoots({
|
|
591
|
+
home = os.homedir(),
|
|
592
|
+
platform = process.platform,
|
|
593
|
+
env = process.env,
|
|
594
|
+
appDir = "Qoder",
|
|
595
|
+
envPrefix = "QODER",
|
|
596
|
+
} = {}) {
|
|
597
|
+
const explicitLogRoot = env[`${envPrefix}_LOG_ROOT`];
|
|
598
|
+
if (typeof explicitLogRoot === "string" && explicitLogRoot.trim()) {
|
|
599
|
+
return [path.resolve(explicitLogRoot.trim())];
|
|
600
|
+
}
|
|
601
|
+
const configuredHome = env[`${envPrefix}_HOME`];
|
|
602
|
+
if (typeof configuredHome === "string" && configuredHome.trim()) {
|
|
603
|
+
return [path.join(path.resolve(configuredHome.trim()), "logs")];
|
|
604
|
+
}
|
|
605
|
+
const base = platform === "darwin"
|
|
606
|
+
? path.join(home, "Library", "Application Support")
|
|
607
|
+
: platform === "win32"
|
|
608
|
+
? (env.APPDATA || path.join(home, "AppData", "Roaming"))
|
|
609
|
+
: path.join(home, ".config");
|
|
610
|
+
// Qoder Desktop 0.4.x moved its Electron profile from Qoder[CN]/ to the
|
|
611
|
+
// bundle id directory and writes the account usage summary to main.log.
|
|
612
|
+
const bundleId = appDir === "QoderCN" || envPrefix === "QODER_CN"
|
|
613
|
+
? "com.qodercn.app.stable"
|
|
614
|
+
: "com.qoder.app.stable";
|
|
615
|
+
return [
|
|
616
|
+
path.join(base, appDir, "logs"),
|
|
617
|
+
path.join(base, bundleId, "logs"),
|
|
618
|
+
];
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function listRecentQoderLogs(logRoot, maxFiles = QODER_LOG_MAX_FILES) {
|
|
579
622
|
if (!fs.existsSync(logRoot)) return [];
|
|
580
623
|
const files = [];
|
|
581
624
|
const stack = [{ dir: logRoot, depth: 0 }];
|
|
@@ -591,19 +634,112 @@ function listRecentQoderRendererLogs(logRoot, maxFiles = 12) {
|
|
|
591
634
|
const full = path.join(current.dir, entry.name);
|
|
592
635
|
if (entry.isDirectory() && current.depth < 4) {
|
|
593
636
|
stack.push({ dir: full, depth: current.depth + 1 });
|
|
594
|
-
} else if (entry.isFile() && entry.name === "renderer.log") {
|
|
637
|
+
} else if (entry.isFile() && (entry.name === "main.log" || entry.name === "renderer.log")) {
|
|
595
638
|
let mtimeMs = 0;
|
|
639
|
+
let size = 0;
|
|
596
640
|
try {
|
|
597
|
-
|
|
641
|
+
const stat = fs.statSync(full);
|
|
642
|
+
mtimeMs = stat.mtimeMs;
|
|
643
|
+
size = stat.size;
|
|
598
644
|
} catch (_error) {}
|
|
599
|
-
files.push({ full, mtimeMs });
|
|
645
|
+
files.push({ full, mtimeMs, size });
|
|
600
646
|
}
|
|
601
647
|
}
|
|
602
648
|
}
|
|
603
649
|
return files
|
|
604
650
|
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
605
|
-
.slice(0, maxFiles)
|
|
606
|
-
|
|
651
|
+
.slice(0, maxFiles);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Returns a parse hit, or null after a completed scan with no hit; undefined
|
|
655
|
+
// means the scan itself failed (open/read) so callers must not treat it as
|
|
656
|
+
// "no summary".
|
|
657
|
+
function scanQoderLogFromEnd(logPath, parse, {
|
|
658
|
+
maxBytes = QODER_LOG_SCAN_MAX_BYTES,
|
|
659
|
+
chunkBytes = QODER_LOG_SCAN_CHUNK_BYTES,
|
|
660
|
+
overlapBytes = QODER_LOG_SCAN_OVERLAP_BYTES,
|
|
661
|
+
} = {}) {
|
|
662
|
+
let fd;
|
|
663
|
+
try {
|
|
664
|
+
fd = fs.openSync(logPath, "r");
|
|
665
|
+
} catch (_error) {
|
|
666
|
+
return undefined;
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
const size = fs.fstatSync(fd).size;
|
|
670
|
+
const floor = Math.max(0, size - maxBytes);
|
|
671
|
+
let end = size;
|
|
672
|
+
let carry = "";
|
|
673
|
+
while (end > floor) {
|
|
674
|
+
const start = Math.max(floor, end - chunkBytes);
|
|
675
|
+
const length = end - start;
|
|
676
|
+
const buffer = Buffer.alloc(length);
|
|
677
|
+
const read = fs.readSync(fd, buffer, 0, length, start);
|
|
678
|
+
const text = buffer.subarray(0, read).toString("utf8") + carry;
|
|
679
|
+
const parsed = parse(text);
|
|
680
|
+
if (parsed) return parsed;
|
|
681
|
+
// Keep this chunk's head so a line split across the boundary parses
|
|
682
|
+
// when the previous chunk is prepended.
|
|
683
|
+
carry = text.slice(0, overlapBytes);
|
|
684
|
+
end = start;
|
|
685
|
+
}
|
|
686
|
+
} catch (_error) {
|
|
687
|
+
return undefined;
|
|
688
|
+
} finally {
|
|
689
|
+
fs.closeSync(fd);
|
|
690
|
+
}
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function parseQoderAccountUsageLog(text, { fallbackObservedAtMs = null } = {}) {
|
|
695
|
+
if (typeof text !== "string" || !text.includes('"qoderUsage"')) return null;
|
|
696
|
+
const lines = text.split(/\r?\n/);
|
|
697
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
698
|
+
const line = lines[index];
|
|
699
|
+
if (!line.includes('"qoderUsage"')) continue;
|
|
700
|
+
const jsonStart = line.indexOf('{"') >= 0 ? line.indexOf('{"') : line.indexOf("{");
|
|
701
|
+
if (jsonStart < 0) continue;
|
|
702
|
+
let payload;
|
|
703
|
+
try {
|
|
704
|
+
payload = JSON.parse(line.slice(jsonStart));
|
|
705
|
+
} catch (_error) {
|
|
706
|
+
try {
|
|
707
|
+
payload = JSON.parse(line.slice(jsonStart, line.lastIndexOf("}") + 1));
|
|
708
|
+
} catch (_error2) {
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
const usage = payload?.qoderUsage;
|
|
713
|
+
if (!usage || typeof usage !== "object") continue;
|
|
714
|
+
let limits;
|
|
715
|
+
try {
|
|
716
|
+
limits = normalizeQoderRpcUsage({
|
|
717
|
+
userType: usage.userType,
|
|
718
|
+
expiresAt: usage.expiresAt,
|
|
719
|
+
isQuotaExceeded: usage.isQuotaExceeded,
|
|
720
|
+
totalUsagePercentage: usage.totalUsagePercentage,
|
|
721
|
+
userQuota: usage.userQuota,
|
|
722
|
+
addOnQuota: usage.addOnQuota,
|
|
723
|
+
});
|
|
724
|
+
} catch (_error) {
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
if (!limits) continue;
|
|
728
|
+
limits.source = "local-log";
|
|
729
|
+
const timestampMatch = /^\[([^\]]+)\]/.exec(line);
|
|
730
|
+
const lineObservedAtMs = timestampMatch ? Date.parse(timestampMatch[1]) : NaN;
|
|
731
|
+
return {
|
|
732
|
+
limits,
|
|
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,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
return null;
|
|
607
743
|
}
|
|
608
744
|
|
|
609
745
|
function parseQoderQuotaLog(text) {
|
|
@@ -640,46 +776,80 @@ function parseQoderQuotaLog(text) {
|
|
|
640
776
|
};
|
|
641
777
|
}
|
|
642
778
|
|
|
643
|
-
|
|
779
|
+
// Maps a log path to its "size:mtimeMs" signature from the last completed
|
|
780
|
+
// scan that found no usable summary (failed I/O is never recorded). Qoder
|
|
781
|
+
// only appends to these logs, so an unchanged signature cannot have gained
|
|
782
|
+
// one; pruning to the current candidate set keeps the map bounded.
|
|
783
|
+
const qoderNoSummaryScans = new Map();
|
|
784
|
+
|
|
785
|
+
function readQoderLocalQuotaWithAge({
|
|
644
786
|
home = os.homedir(),
|
|
645
787
|
platform = process.platform,
|
|
646
788
|
env = process.env,
|
|
647
789
|
appDir = "Qoder",
|
|
648
790
|
envPrefix = "QODER",
|
|
791
|
+
nowMs = Date.now(),
|
|
792
|
+
maxAgeMs = QODER_LOG_MAX_AGE_MS,
|
|
793
|
+
olderThanMs = -Infinity,
|
|
649
794
|
} = {}) {
|
|
650
|
-
const
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
if (parsed) return parsed;
|
|
677
|
-
} finally {
|
|
678
|
-
fs.closeSync(fd);
|
|
795
|
+
const candidates = [];
|
|
796
|
+
for (const logRoot of qoderLogRoots({ home, platform, env, appDir, envPrefix })) {
|
|
797
|
+
for (const entry of listRecentQoderLogs(logRoot)) {
|
|
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;
|
|
803
|
+
candidates.push(entry);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
807
|
+
const scanned = candidates.slice(0, QODER_LOG_MAX_FILES);
|
|
808
|
+
const seenPaths = new Set();
|
|
809
|
+
let best = null;
|
|
810
|
+
for (const { full, mtimeMs, size } of scanned) {
|
|
811
|
+
seenPaths.add(full);
|
|
812
|
+
const signature = `${size}:${mtimeMs}`;
|
|
813
|
+
if (qoderNoSummaryScans.get(full) === signature) continue;
|
|
814
|
+
// A stale modern summary must not end the scan: fall through so a legacy
|
|
815
|
+
// match in the same chunk — or deeper — can still be used; only a fresh
|
|
816
|
+
// modern result returns early.
|
|
817
|
+
const parsed = scanQoderLogFromEnd(full, (text) => {
|
|
818
|
+
const accountUsage = parseQoderAccountUsageLog(text, { fallbackObservedAtMs: mtimeMs });
|
|
819
|
+
if (accountUsage && nowMs - (accountUsage.observedAtMs ?? mtimeMs) <= maxAgeMs) {
|
|
820
|
+
return accountUsage;
|
|
679
821
|
}
|
|
680
|
-
|
|
822
|
+
const legacy = parseQoderQuotaLog(text);
|
|
823
|
+
return legacy ? { limits: legacy, observedAtMs: mtimeMs, lineObservedAtMs: null } : null;
|
|
824
|
+
});
|
|
825
|
+
if (parsed === undefined) continue; // Scan failed (I/O); retry on the next call.
|
|
826
|
+
if (!parsed) {
|
|
827
|
+
qoderNoSummaryScans.set(full, signature);
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
const observedAtMs = parsed.observedAtMs ?? mtimeMs;
|
|
831
|
+
if (nowMs - observedAtMs > maxAgeMs) continue;
|
|
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
|
+
}
|
|
681
844
|
}
|
|
682
|
-
|
|
845
|
+
for (const cachedPath of qoderNoSummaryScans.keys()) {
|
|
846
|
+
if (!seenPaths.has(cachedPath)) qoderNoSummaryScans.delete(cachedPath);
|
|
847
|
+
}
|
|
848
|
+
return best;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function readQoderLocalQuota(options = {}) {
|
|
852
|
+
return readQoderLocalQuotaWithAge(options)?.limits ?? null;
|
|
683
853
|
}
|
|
684
854
|
|
|
685
855
|
function siteFromEnv(value) {
|
|
@@ -760,9 +930,34 @@ async function fetchQoderLimits({
|
|
|
760
930
|
}
|
|
761
931
|
}
|
|
762
932
|
|
|
763
|
-
//
|
|
764
|
-
// 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.
|
|
765
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
|
+
}
|
|
766
961
|
if (cachedLimits) {
|
|
767
962
|
if (activityWindow) cachedLimits.secondary_window = activityWindow;
|
|
768
963
|
return cachedLimits;
|
|
@@ -791,15 +986,15 @@ async function fetchQoderLimits({
|
|
|
791
986
|
// Qoder 1.18 can return an all-zero active plan with usage_percentage=0
|
|
792
987
|
// while the desktop client separately marks the account quota-exceeded.
|
|
793
988
|
// Preserve that status as metadata, but match Qoder's Credits UI by
|
|
794
|
-
// rendering an empty 0/0 bucket as 0%, not a full red 100% bar.
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
if (
|
|
799
|
-
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;
|
|
800
995
|
result.primary_window.reset_at = null;
|
|
801
996
|
result.source = "provider-api+local-log";
|
|
802
|
-
result.plan_label =
|
|
997
|
+
result.plan_label = zeroPlanLocal.limits.plan_label || null;
|
|
803
998
|
}
|
|
804
999
|
if (activityWindow) {
|
|
805
1000
|
result.secondary_window = activityWindow;
|
|
@@ -817,8 +1012,8 @@ async function fetchQoderLimits({
|
|
|
817
1012
|
}
|
|
818
1013
|
}
|
|
819
1014
|
|
|
820
|
-
|
|
821
|
-
|
|
1015
|
+
if (localScan) {
|
|
1016
|
+
const local = localScan.limits;
|
|
822
1017
|
if (activityWindow) {
|
|
823
1018
|
local.secondary_window = activityWindow;
|
|
824
1019
|
local.source += activitySource === "disk-cache" ? "+cached-activity" : "+activity";
|
|
@@ -826,6 +1021,7 @@ async function fetchQoderLimits({
|
|
|
826
1021
|
writeQoderLimitsCache(local, { home, nowMs, namespace: cacheNamespace });
|
|
827
1022
|
return local;
|
|
828
1023
|
}
|
|
1024
|
+
|
|
829
1025
|
if (activityWindow) {
|
|
830
1026
|
return {
|
|
831
1027
|
configured: true,
|
|
@@ -874,6 +1070,7 @@ module.exports = {
|
|
|
874
1070
|
readQoderLimitsCache,
|
|
875
1071
|
writeQoderLimitsCache,
|
|
876
1072
|
parseQoderQuotaLog,
|
|
1073
|
+
parseQoderAccountUsageLog,
|
|
877
1074
|
readQoderLocalQuota,
|
|
878
1075
|
fetchQoderLimits,
|
|
879
1076
|
fetchQoderCnLimits,
|
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)) {
|