tokentracker-cli 0.91.0 → 0.91.3
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 +6 -5
- package/README.ja.md +8 -5
- package/README.ko.md +8 -5
- package/README.md +6 -5
- package/README.zh-CN.md +5 -4
- package/dashboard/dist/assets/{AchievementBadge-iyRg0rYF.js → AchievementBadge-LHOuekQ3.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-CkBm3bN3.js → AchievementsPage-DNzeYWBl.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-a4Z7VtlP.js → AchievementsSection-Ch5dImt8.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-DHTN0Ky6.js → BadgeDetailModal-CuikWkUy.js} +1 -1
- package/dashboard/dist/assets/{Card-CdygGEqz.js → Card-Chwjmxjz.js} +1 -1
- package/dashboard/dist/assets/{ClawdAnimated-D-Z823Nc.js → ClawdAnimated-BdB7aycS.js} +1 -1
- package/dashboard/dist/assets/{CommandPalette-VGkj8w2X.js → CommandPalette-BMwuSEWM.js} +1 -1
- package/dashboard/dist/assets/{CommunityStatsModal-BDT4xqfF.js → CommunityStatsModal-eEDF-xn8.js} +1 -1
- package/dashboard/dist/assets/{ConfirmModal-BIKv4AlL.js → ConfirmModal-Be7f6XdL.js} +1 -1
- package/dashboard/dist/assets/{Controls-BkZVm9t1.js → Controls-CtBFPCBY.js} +1 -1
- package/dashboard/dist/assets/{DashboardPage-BFkdjUdd.js → DashboardPage-M0p-SU-f.js} +1 -1
- package/dashboard/dist/assets/{DevicePage-BGw861bu.js → DevicePage-CAfcUond.js} +1 -1
- package/dashboard/dist/assets/{DialogClose-CSEl6ru8.js → DialogClose-kd1wIljN.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-CjJLpfCL.js +1 -0
- package/dashboard/dist/assets/{DialogTitle-CWPdu5V-.js → DialogTitle-DhB-tbnZ.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-CdCANtvs.js → FadeIn-B8eRWBdy.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-dQQnH85f.js → HeaderGithubStar-DmWep8uP.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-CqJUI-WG.js → HoverTooltip-DDyTZli0.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-BRqTXlSu.js → IpCheckPage-CN8hYm4o.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-DhMTgDng.js → LandingPage-B3cHASqK.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardAvatar-BrGcByG1.js → LeaderboardAvatar-CEcLXrJL.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardPage-BmdopPjo.js → LeaderboardPage-H6HW9hon.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardProfileModal-DRZqPo-A.js → LeaderboardProfileModal-B_5PyjRA.js} +4 -4
- package/dashboard/dist/assets/{LeaderboardProfilePage-CTbC39KX.js → LeaderboardProfilePage-DR9KWLWv.js} +3 -3
- package/dashboard/dist/assets/{LimitsPage-B7AuLq1S.js → LimitsPage-Da0hRxO_.js} +1 -1
- package/dashboard/dist/assets/{LocalOnlyNotice-DyIAspoq.js → LocalOnlyNotice-DuTdva_n.js} +1 -1
- package/dashboard/dist/assets/LoginCard-DOL3osrj.js +1 -0
- package/dashboard/dist/assets/{LoginModal-D3bmr_jI.js → LoginModal-DBmaQL2L.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-D7SgmEhZ.js → LoginPage-Dk08VrkC.js} +1 -1
- package/dashboard/dist/assets/{PetPage-5A3Q0zlw.js → PetPage-BbCIdpzx.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-ajEhdI8m.js → PopoverPopup-C1fJZ4qt.js} +1 -1
- package/dashboard/dist/assets/ProviderIcon-FfLD-f73.js +1 -0
- package/dashboard/dist/assets/{ResetPasswordPage-Bwbt8tH2.js → ResetPasswordPage-Bg7eVc6L.js} +1 -1
- package/dashboard/dist/assets/{SegmentedControl-BSa5hMnN.js → SegmentedControl-CFNHcUz7.js} +1 -1
- package/dashboard/dist/assets/{Select-kPKDGb7M.js → Select-B0Q0eDcJ.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-DNgLclTV.js → SelectItemText-BVbLYxSQ.js} +1 -1
- package/dashboard/dist/assets/{ServiceStatusPage-C1j7rPDp.js → ServiceStatusPage-DCZH2kz5.js} +1 -1
- package/dashboard/dist/assets/{SessionsPage-BgoKs_Yq.js → SessionsPage-DO5Et7ss.js} +2 -2
- package/dashboard/dist/assets/{SettingsPage-BUaPftV0.js → SettingsPage-afgxGfzT.js} +2 -2
- package/dashboard/dist/assets/{SkillsPage-C5GkzfWW.js → SkillsPage-DDKiHCF7.js} +1 -1
- package/dashboard/dist/assets/{TokenGalaxy-DZffAa_0.js → TokenGalaxy-DV1Xoe5z.js} +1 -1
- package/dashboard/dist/assets/{ToolbarRootContext-CuLP92P_.js → ToolbarRootContext-DNckd9Qz.js} +1 -1
- package/dashboard/dist/assets/TrendMonitor-CliMJxCC.js +19 -0
- package/dashboard/dist/assets/{WidgetsPage-BUhiCpcq.js → WidgetsPage-BrwG6YMM.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-sNFISXVG.js → WrappedPage-Ft9YJd0D.js} +1 -1
- package/dashboard/dist/assets/{__vite-browser-external-Cyw7ZNtj.js → __vite-browser-external-C6_BtvDo.js} +4 -3
- package/dashboard/dist/assets/agent-logos-yYNu1ZUy.js +1 -0
- package/dashboard/dist/assets/{arrow-up-right-BcRkt7hr.js → arrow-up-right-B_K57Vbg.js} +1 -1
- package/dashboard/dist/assets/{check-qhr4U2S5.js → check-xH63o2xJ.js} +1 -1
- package/dashboard/dist/assets/{chevron-right-iLGEAP7_.js → chevron-right-D3F2rAfr.js} +1 -1
- package/dashboard/dist/assets/{copy-BhEPCq-q.js → copy-WPfJGGI0.js} +1 -1
- package/dashboard/dist/assets/{download-D7O1ZzsX.js → download-BgtWtEXI.js} +1 -1
- package/dashboard/dist/assets/{external-link-CY2kK2hC.js → external-link-DSHGoZRM.js} +1 -1
- package/dashboard/dist/assets/{flame-D4FPuBNn.js → flame-CNGl6Qac.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-CVHJ7Hdh.js → format-tokens-jVm2UX7Y.js} +1 -1
- package/dashboard/dist/assets/{icons-B_-51UXa.js → icons-B1JZg-Id.js} +1 -1
- package/dashboard/dist/assets/{index-cwdr25Zm.js → index-BTZibjLi.js} +1 -1
- package/dashboard/dist/assets/{index-Djv-aJy0.js → index-BjD81Qce.js} +1 -1
- package/dashboard/dist/assets/{index-ClVWat-1.js → index-CUFHknAy.js} +1 -1
- package/dashboard/dist/assets/{info-DOab0eZz.js → info-CLM460KM.js} +1 -1
- package/dashboard/dist/assets/{limits-providers-CESm2d6B.js → limits-providers-CFO4O1qh.js} +1 -1
- package/dashboard/dist/assets/{link-2-B4LMsC4m.js → link-2-BHOsUnAI.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-CPx6wpSn.js → loader-circle-CgcIQi92.js} +1 -1
- package/dashboard/dist/assets/{maximize-2-C9z8mbdn.js → maximize-2-DvqfuJEA.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-5xRgAl_R.js → modifiers.esm-CNiwIkPh.js} +1 -1
- package/dashboard/dist/assets/module-CV1mYFsv.js +3 -0
- package/dashboard/dist/assets/{monitor-smartphone-B9HAspdR.js → monitor-smartphone-BLutpxrG.js} +1 -1
- package/dashboard/dist/assets/provider-display-BxIgWUEa.js +42 -0
- package/dashboard/dist/assets/{react-C45OGvDd.js → react-CM-ozVnh.js} +1 -1
- package/dashboard/dist/assets/{search-Bg1qyEdz.js → search-4T07INKt.js} +1 -1
- package/dashboard/dist/assets/{skills-api-a8pi7UTd.js → skills-api-EXMD3LD1.js} +1 -1
- package/dashboard/dist/assets/{sortable.esm-CtPp8cFA.js → sortable.esm-jC6mPJiL.js} +1 -1
- package/dashboard/dist/assets/{store-b8zXM1yr.js → store-CvSEdgf2.js} +1 -1
- package/dashboard/dist/assets/{terminal-CPf0K0Fn.js → terminal-D9fyE2pP.js} +1 -1
- package/dashboard/dist/assets/{trash-2-uorzBdA0.js → trash-2-QLUsAY3l.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-DaVwywsM.js → use-community-stats-Chrt3kpU.js} +1 -1
- package/dashboard/dist/assets/{use-limits-display-prefs-UIzz7PJ3.js → use-limits-display-prefs-BJJd750e.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list-C2XOfMH3.js → use-ordered-list-BBJp5lFv.js} +1 -1
- package/dashboard/dist/assets/use-reduced-motion-BvXZCGFC.js +1 -0
- package/dashboard/dist/assets/{use-session-efficiency-pref-D5KHakYg.js → use-session-efficiency-pref-BXbvx2Ht.js} +1 -1
- package/dashboard/dist/assets/{use-transform-Cfo_2_id.js → use-transform-BVp_MUsU.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-CANTpxrA.js → use-usage-limits-Dk3EkeIs.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-CjvWekKH.js → useCurrency-Bi88Sd__.js} +1 -1
- package/dashboard/dist/assets/{useScrollLock-ChQdbuJg.js → useScrollLock-if34g2H-.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-4MY04JPl.js → useTokenFormat-C2cz-5qJ.js} +1 -1
- package/dashboard/dist/assets/{zap-BSXHFUBb.js → zap-D1yHmcQR.js} +1 -1
- package/dashboard/dist/index.html +12 -10
- package/dashboard/dist/ip-check.html +1 -1
- package/dashboard/dist/leaderboard.html +1 -1
- package/dashboard/dist/llms.txt +2 -1
- package/dashboard/dist/share.html +1 -1
- package/package.json +2 -2
- package/src/commands/init.js +2 -1
- package/src/commands/status.js +46 -0
- package/src/commands/sync.js +188 -4
- package/src/lib/account-usage-dedup.js +267 -0
- package/src/lib/local-api.js +4 -0
- package/src/lib/pricing/curated-overrides.json +15 -9
- package/src/lib/pricing/index.js +32 -1
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +730 -1
- package/src/lib/source-metadata.js +1 -1
- package/src/lib/trae-cn-config.js +539 -0
- package/dashboard/dist/assets/DialogDescription-DfNXnqay.js +0 -1
- package/dashboard/dist/assets/LoginCard-LcOaKaCK.js +0 -1
- package/dashboard/dist/assets/ProviderIcon-C4yuu9FG.js +0 -1
- package/dashboard/dist/assets/TrendMonitor-BKLAKJmT.js +0 -19
- package/dashboard/dist/assets/agent-logos-Bwf2YFHS.js +0 -1
- package/dashboard/dist/assets/module-BRJxSJmz.js +0 -3
- package/dashboard/dist/assets/provider-display-C0NS0EvN.js +0 -42
- package/dashboard/dist/assets/use-reduced-motion-CNyMWBog.js +0 -1
package/src/commands/status.js
CHANGED
|
@@ -89,6 +89,13 @@ const {
|
|
|
89
89
|
resolveCopilotAppDbPaths,
|
|
90
90
|
probeWslDistros,
|
|
91
91
|
} = require("../lib/rollout");
|
|
92
|
+
const {
|
|
93
|
+
TRAE_CN_USAGE_ENV,
|
|
94
|
+
isTraeCnUsageEnabled,
|
|
95
|
+
resolveTraeCnStoragePath,
|
|
96
|
+
readTraeCnAuthFromStorage,
|
|
97
|
+
extractTraeCnToken,
|
|
98
|
+
} = require("../lib/trae-cn-config");
|
|
92
99
|
const wsl = require("../lib/wsl-probe");
|
|
93
100
|
const { getWslMode, isInvalidWslMode, shouldProbeWsl, discoverWslHome } = wsl;
|
|
94
101
|
const { resolveInstallPaths, resolveZcodeNativeDbPath } = require("../lib/install-resolver");
|
|
@@ -642,6 +649,34 @@ async function cmdStatus(argv = []) {
|
|
|
642
649
|
? readTraeEntitlementFromStorage(traeStoragePath)
|
|
643
650
|
: null;
|
|
644
651
|
|
|
652
|
+
// Trae SOLO CN — opt-in usage-API reader. Unlike the passive entitlement
|
|
653
|
+
// reader above, this source sends the locally stored sign-in JWT to TRAE's
|
|
654
|
+
// official API, so status surfaces the opt-in flag, the resolved storage
|
|
655
|
+
// path, and whether the auth blob decrypts — the three inputs a "why is my
|
|
656
|
+
// TRAE CN data missing" diagnosis needs.
|
|
657
|
+
// A resolvable default path does NOT mean the app is installed - the macOS
|
|
658
|
+
// resolver always derives one. Match the sync path semantics exactly:
|
|
659
|
+
// installed iff the storage file actually exists on disk.
|
|
660
|
+
const traeCnStoragePath = resolveTraeCnStoragePath({ env: process.env, home });
|
|
661
|
+
const traeCnInstalled = Boolean(
|
|
662
|
+
traeCnStoragePath && fssync.existsSync(traeCnStoragePath),
|
|
663
|
+
);
|
|
664
|
+
let traeCnAuthState = "not-signed-in";
|
|
665
|
+
if (traeCnInstalled) {
|
|
666
|
+
try {
|
|
667
|
+
const traeCnAuth = readTraeCnAuthFromStorage({ env: process.env, home, platform: process.platform });
|
|
668
|
+
if (traeCnAuth) {
|
|
669
|
+
extractTraeCnToken(traeCnAuth);
|
|
670
|
+
traeCnAuthState = "readable";
|
|
671
|
+
}
|
|
672
|
+
} catch (error) {
|
|
673
|
+
// Distinguish a real IO failure (unreadable) from present-but-bad
|
|
674
|
+
// data (malformed); neither ever carries storage contents.
|
|
675
|
+
traeCnAuthState = error?.code === "TRAE_CN_STORAGE_UNREADABLE" ? "unreadable" : "malformed";
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const traeCnUsageOptIn = isTraeCnUsageEnabled(process.env);
|
|
679
|
+
|
|
645
680
|
// Grok Build (xAI TUI)
|
|
646
681
|
const grokHookState = await probeGrokHookState({ home, trackerDir, env: process.env });
|
|
647
682
|
const grokSessions = grokHookState.hasGrokInstall || grokHookState.sessionsDir
|
|
@@ -933,6 +968,14 @@ async function cmdStatus(argv = []) {
|
|
|
933
968
|
...(traeEntitlement ? { entitlement: traeEntitlement } : {}),
|
|
934
969
|
}
|
|
935
970
|
: { installed: false },
|
|
971
|
+
"trae-cn": traeCnInstalled
|
|
972
|
+
? {
|
|
973
|
+
installed: true,
|
|
974
|
+
detail: traeCnStoragePath,
|
|
975
|
+
auth: traeCnAuthState,
|
|
976
|
+
usage_opt_in: traeCnUsageOptIn,
|
|
977
|
+
}
|
|
978
|
+
: { installed: false },
|
|
936
979
|
grok_build: grokInstalled
|
|
937
980
|
? {
|
|
938
981
|
installed: true,
|
|
@@ -1105,6 +1148,9 @@ async function cmdStatus(argv = []) {
|
|
|
1105
1148
|
traeEntitlement
|
|
1106
1149
|
? `- Trae SOLO plan: ${formatTraeEntitlementLine(traeEntitlement)}`
|
|
1107
1150
|
: null,
|
|
1151
|
+
traeCnInstalled
|
|
1152
|
+
? `- Trae SOLO CN: usage sync ${traeCnUsageOptIn ? "opted in" : `off (set ${TRAE_CN_USAGE_ENV}=1 to enable)`}, auth ${traeCnAuthState} (${traeCnStoragePath})`
|
|
1153
|
+
: null,
|
|
1108
1154
|
...(() => {
|
|
1109
1155
|
if (!hermesInstalled) return [];
|
|
1110
1156
|
return [`- Hermes Agent: state.db found (${hermesPath})`];
|
package/src/commands/sync.js
CHANGED
|
@@ -112,6 +112,7 @@ const {
|
|
|
112
112
|
resolveDroidModel,
|
|
113
113
|
resolveDshSessionFiles,
|
|
114
114
|
parseDshIncremental,
|
|
115
|
+
parseTraeCnApiIncremental,
|
|
115
116
|
bucketKey,
|
|
116
117
|
toUtcHalfHourStart,
|
|
117
118
|
totalsKey,
|
|
@@ -134,6 +135,11 @@ const {
|
|
|
134
135
|
parseCursorCsv,
|
|
135
136
|
} = require("../lib/cursor-config");
|
|
136
137
|
const { purgeProjectUsage } = require("../lib/project-usage-purge");
|
|
138
|
+
const {
|
|
139
|
+
fetchTraeCnUsageWithAuth,
|
|
140
|
+
resolveTraeCnStoragePath,
|
|
141
|
+
isTraeCnUsageEnabled,
|
|
142
|
+
} = require("../lib/trae-cn-config");
|
|
137
143
|
const {
|
|
138
144
|
isCodexSessionCursorPath,
|
|
139
145
|
isCursorStoreRetry,
|
|
@@ -291,6 +297,7 @@ const AUTO_SYNC_SOURCES = new Set([
|
|
|
291
297
|
"qoder-cn",
|
|
292
298
|
"reasonix",
|
|
293
299
|
"roocode",
|
|
300
|
+
"trae-cn",
|
|
294
301
|
"workbuddy",
|
|
295
302
|
"zcode",
|
|
296
303
|
"zed",
|
|
@@ -452,6 +459,15 @@ async function cmdSync(argv, context = {}) {
|
|
|
452
459
|
const lockWaitOptions = context && typeof context === "object"
|
|
453
460
|
? context.lockWaitOptions
|
|
454
461
|
: undefined;
|
|
462
|
+
// Narrow test-only seam for TRAE Work CN: deterministic injected fetch + a
|
|
463
|
+
// fixed "now" for the rolling range. Production keeps the global fetch and
|
|
464
|
+
// current time; no public CLI flag/config is introduced.
|
|
465
|
+
const traeCnFetchImpl = context && typeof context === "object"
|
|
466
|
+
? context.traeCnFetchImpl
|
|
467
|
+
: undefined;
|
|
468
|
+
const traeCnNowMs = context && typeof context === "object"
|
|
469
|
+
? context.traeCnNowMs
|
|
470
|
+
: undefined;
|
|
455
471
|
const syncDiagnostics = diagnostics && typeof diagnostics === "object" ? diagnostics : null;
|
|
456
472
|
const home = os.homedir();
|
|
457
473
|
const { trackerDir } = await resolveTrackerPaths({ home });
|
|
@@ -1663,6 +1679,93 @@ async function cmdSync(argv, context = {}) {
|
|
|
1663
1679
|
}
|
|
1664
1680
|
}
|
|
1665
1681
|
|
|
1682
|
+
// ── Trae Work CN (国内版) — account-level usage API ──
|
|
1683
|
+
// A simple explicit rolling 30-day window captured once per sync (no
|
|
1684
|
+
// cursor checkpoints / full-history import / page retries / background
|
|
1685
|
+
// requests). Runs only when the user has opted in via
|
|
1686
|
+
// TOKENTRACKER_TRAE_CN_USAGE=1 (the read transmits the locally stored
|
|
1687
|
+
// sign-in JWT to TRAE's official endpoint, so it is never default-on),
|
|
1688
|
+
// the sync is non-lightweight, the source is allowed, and the CN storage
|
|
1689
|
+
// file exists — which excludes both ordinary background and `--auto
|
|
1690
|
+
// --background --all-local-sources`. Fetching goes through the
|
|
1691
|
+
// storage-backed helper so its single 401/403 reread/retry is used; an
|
|
1692
|
+
// empty response is a successful no-op; each window is bounded to 100
|
|
1693
|
+
// pages/2,000 rows and an over-capacity window is split into staggered
|
|
1694
|
+
// sub-windows (still over capacity at the finest allowed granularity, or
|
|
1695
|
+
// any other fetch/page/schema/auth failure) skips the parser and leaves
|
|
1696
|
+
// prior data untouched while unrelated providers continue.
|
|
1697
|
+
let traeCnResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
|
|
1698
|
+
if (
|
|
1699
|
+
!isBackgroundLightweightSync &&
|
|
1700
|
+
isTraeCnUsageEnabled(process.env) &&
|
|
1701
|
+
sourceAllowed("trae-cn")
|
|
1702
|
+
) {
|
|
1703
|
+
const nowMs = Number.isFinite(traeCnNowMs) ? traeCnNowMs : Date.now();
|
|
1704
|
+
const fetchImpl = typeof traeCnFetchImpl === "function" ? traeCnFetchImpl : fetch;
|
|
1705
|
+
const endTime = Math.floor(nowMs / 1000);
|
|
1706
|
+
// Align the REAL fetch start down to a half-hour boundary so the API
|
|
1707
|
+
// range fully contains every bucket it can touch (a raw 08:37 start
|
|
1708
|
+
// would only partially cover the 08:30 bucket) - session bucket floors
|
|
1709
|
+
// never straddle the queried range.
|
|
1710
|
+
const HALF_HOUR_SEC = 30 * 60;
|
|
1711
|
+
const startTime = Math.max(
|
|
1712
|
+
1,
|
|
1713
|
+
Math.floor((endTime - 30 * 24 * 60 * 60) / HALF_HOUR_SEC) * HALF_HOUR_SEC,
|
|
1714
|
+
);
|
|
1715
|
+
try {
|
|
1716
|
+
// Absent storage means "not signed in" — a silent skip, not an error.
|
|
1717
|
+
const traeCnStoragePath = resolveTraeCnStoragePath({ env: process.env, home });
|
|
1718
|
+
if (traeCnStoragePath && fssync.existsSync(traeCnStoragePath)) {
|
|
1719
|
+
if (progress?.enabled) {
|
|
1720
|
+
progress.start(`Fetching TRAE Work CN usage...`);
|
|
1721
|
+
}
|
|
1722
|
+
const traeCnUsage = await fetchTraeCnUsageWithAuth({
|
|
1723
|
+
start_time: startTime,
|
|
1724
|
+
end_time: endTime,
|
|
1725
|
+
fetchImpl,
|
|
1726
|
+
env: process.env,
|
|
1727
|
+
home,
|
|
1728
|
+
});
|
|
1729
|
+
const traeCnSessions = Array.isArray(traeCnUsage?.sessions) ? traeCnUsage.sessions : [];
|
|
1730
|
+
// An empty payload parses as a pure no-op: the TRAE absence
|
|
1731
|
+
// contract is NOT PROVEN (no evidence that a missing session means
|
|
1732
|
+
// deleted/zero), so an empty response asserts nothing - no usage
|
|
1733
|
+
// mutation and no session states. Non-empty snapshots append one
|
|
1734
|
+
// canonical session-state observation per CHANGED session (the
|
|
1735
|
+
// cloud LWW upsert reconciles cross-device versions).
|
|
1736
|
+
{
|
|
1737
|
+
if (progress?.enabled && traeCnSessions.length > 0) {
|
|
1738
|
+
progress.start(
|
|
1739
|
+
`Parsing TRAE Work CN ${renderBar(0)} 0/${formatNumber(
|
|
1740
|
+
traeCnSessions.length,
|
|
1741
|
+
)} records | buckets 0`,
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
traeCnResult = await parseTraeCnApiIncremental({
|
|
1745
|
+
sessions: traeCnSessions,
|
|
1746
|
+
cursors,
|
|
1747
|
+
queuePath,
|
|
1748
|
+
onProgress: makeProviderProgress("TRAE Work CN"),
|
|
1749
|
+
windowStartMs: startTime * 1000,
|
|
1750
|
+
windowEndMs: endTime * 1000,
|
|
1751
|
+
// The LOGICAL fetch stamp carried into every session state's
|
|
1752
|
+
// snapshot_verified_at: the same clock that produced the query
|
|
1753
|
+
// range, not the later enqueue moment (append-only queue
|
|
1754
|
+
// replays it verbatim, so transport retries never fake
|
|
1755
|
+
// freshness).
|
|
1756
|
+
snapshotVerifiedAtMs: nowMs,
|
|
1757
|
+
});
|
|
1758
|
+
// A partially malformed snapshot throws inside the parser (fail
|
|
1759
|
+
// closed - it must not become the authoritative window state);
|
|
1760
|
+
// warnProviderParseFailure below reports it without sensitive
|
|
1761
|
+
// data while unrelated providers continue.
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
} catch (err) {
|
|
1765
|
+
warnProviderParseFailure("TRAE Work CN", err, opts);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1666
1769
|
// ── Kiro (SQLite-based, with JSONL fallback; dual-install aware) ──
|
|
1667
1770
|
let kiroResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
|
|
1668
1771
|
if (sourceAllowed("kiro")) {
|
|
@@ -2157,7 +2260,12 @@ async function cmdSync(argv, context = {}) {
|
|
|
2157
2260
|
}
|
|
2158
2261
|
|
|
2159
2262
|
// ── Grok Build (xAI) ──
|
|
2160
|
-
let grokResult = {
|
|
2263
|
+
let grokResult = {
|
|
2264
|
+
recordsProcessed: 0,
|
|
2265
|
+
eventsAggregated: 0,
|
|
2266
|
+
bucketsQueued: 0,
|
|
2267
|
+
projectBucketsQueued: 0,
|
|
2268
|
+
};
|
|
2161
2269
|
// Full passive scan of all Grok sessions (historical + any not covered by hook)
|
|
2162
2270
|
const grokSessions = sourceAllowed("grok") ? resolveGrokBuildSessions(process.env) : [];
|
|
2163
2271
|
const grokSessionInputs = [...grokSessions];
|
|
@@ -2179,6 +2287,11 @@ async function cmdSync(argv, context = {}) {
|
|
|
2179
2287
|
sessionId: hookSessionId,
|
|
2180
2288
|
sessionDir:
|
|
2181
2289
|
typeof grokHookSignal.sessionDir === "string" ? grokHookSignal.sessionDir : undefined,
|
|
2290
|
+
cwd: typeof grokHookSignal.cwd === "string" ? grokHookSignal.cwd : undefined,
|
|
2291
|
+
encodedCwd:
|
|
2292
|
+
typeof grokHookSignal.sessionDir === "string" && grokHookSignal.sessionDir
|
|
2293
|
+
? path.basename(path.dirname(grokHookSignal.sessionDir))
|
|
2294
|
+
: undefined,
|
|
2182
2295
|
updatesPath:
|
|
2183
2296
|
typeof grokHookSignal.updatesPath === "string" ? grokHookSignal.updatesPath : undefined,
|
|
2184
2297
|
signalsPath:
|
|
@@ -2208,6 +2321,7 @@ async function cmdSync(argv, context = {}) {
|
|
|
2208
2321
|
sessions: grokSessionInputs,
|
|
2209
2322
|
cursors,
|
|
2210
2323
|
queuePath,
|
|
2324
|
+
projectQueuePath,
|
|
2211
2325
|
env: process.env,
|
|
2212
2326
|
onProgress: (p) => {
|
|
2213
2327
|
if (!progress?.enabled) return;
|
|
@@ -2224,6 +2338,8 @@ async function cmdSync(argv, context = {}) {
|
|
|
2224
2338
|
recordsProcessed: grokResult.recordsProcessed + grokScanResult.recordsProcessed,
|
|
2225
2339
|
eventsAggregated: grokResult.eventsAggregated + grokScanResult.eventsAggregated,
|
|
2226
2340
|
bucketsQueued: grokResult.bucketsQueued + grokScanResult.bucketsQueued,
|
|
2341
|
+
projectBucketsQueued:
|
|
2342
|
+
(grokResult.projectBucketsQueued || 0) + (grokScanResult.projectBucketsQueued || 0),
|
|
2227
2343
|
};
|
|
2228
2344
|
}
|
|
2229
2345
|
if (isFullSourceScan && opts.repairGrok) {
|
|
@@ -2584,6 +2700,7 @@ async function cmdSync(argv, context = {}) {
|
|
|
2584
2700
|
qoderCnResult.recordsProcessed +
|
|
2585
2701
|
claudeScienceResult.recordsProcessed +
|
|
2586
2702
|
cursorResult.recordsProcessed +
|
|
2703
|
+
traeCnResult.recordsProcessed +
|
|
2587
2704
|
kiroResult.recordsProcessed +
|
|
2588
2705
|
kiroCliResult.recordsProcessed +
|
|
2589
2706
|
hermesResult.recordsProcessed +
|
|
@@ -2619,6 +2736,7 @@ async function cmdSync(argv, context = {}) {
|
|
|
2619
2736
|
qoderCnResult.bucketsQueued +
|
|
2620
2737
|
claudeScienceResult.bucketsQueued +
|
|
2621
2738
|
cursorResult.bucketsQueued +
|
|
2739
|
+
traeCnResult.bucketsQueued +
|
|
2622
2740
|
kiroResult.bucketsQueued +
|
|
2623
2741
|
kiroCliResult.bucketsQueued +
|
|
2624
2742
|
hermesResult.bucketsQueued +
|
|
@@ -2650,6 +2768,7 @@ async function cmdSync(argv, context = {}) {
|
|
|
2650
2768
|
cursorStore.requiresCommit !== true &&
|
|
2651
2769
|
totalParsed === 0 &&
|
|
2652
2770
|
totalBuckets === 0 &&
|
|
2771
|
+
!(grokResult.projectBucketsQueued > 0) &&
|
|
2653
2772
|
!codexColdAuditDue &&
|
|
2654
2773
|
!codexFallbackRetryRan &&
|
|
2655
2774
|
!grokHookSignalConsumed &&
|
|
@@ -2972,6 +3091,8 @@ function recordCodexColdScanAudit(
|
|
|
2972
3091
|
module.exports = {
|
|
2973
3092
|
cmdSync,
|
|
2974
3093
|
acquireSyncLock,
|
|
3094
|
+
readQueueBatch,
|
|
3095
|
+
drainQueueToCloud,
|
|
2975
3096
|
migrateCursorUnknownBuckets,
|
|
2976
3097
|
migrateRolloutCumulativeDeltaBuckets,
|
|
2977
3098
|
repairCodebuddyLogJsonlOverlap,
|
|
@@ -3472,7 +3593,10 @@ async function drainQueueToCloud({ baseUrl, deviceToken, queuePath, queueStatePa
|
|
|
3472
3593
|
for (let batch = 0; batch < maxBatches; batch++) {
|
|
3473
3594
|
if (offset >= queueSize) break;
|
|
3474
3595
|
const result = await readQueueBatch(queuePath, offset, limit);
|
|
3475
|
-
|
|
3596
|
+
// A states-only tail (e.g. a retry batch whose bucket rows already
|
|
3597
|
+
// uploaded) is still a meaningful upload: it advances the canonical
|
|
3598
|
+
// session corrections cloud-side.
|
|
3599
|
+
if (result.buckets.length === 0 && result.sessionStates.length === 0) break;
|
|
3476
3600
|
|
|
3477
3601
|
const root = baseUrl.replace(/\/$/, "");
|
|
3478
3602
|
const anonKey = process.env.TOKENTRACKER_INSFORGE_ANON_KEY || "";
|
|
@@ -3485,7 +3609,12 @@ async function drainQueueToCloud({ baseUrl, deviceToken, queuePath, queueStatePa
|
|
|
3485
3609
|
const res = await fetch(`${root}/functions/${INGEST_SLUG}`, {
|
|
3486
3610
|
method: "POST",
|
|
3487
3611
|
headers,
|
|
3488
|
-
body: JSON.stringify({
|
|
3612
|
+
body: JSON.stringify({
|
|
3613
|
+
hourly: result.buckets,
|
|
3614
|
+
...(result.sessionStates.length > 0
|
|
3615
|
+
? { account_session_states: result.sessionStates }
|
|
3616
|
+
: {}),
|
|
3617
|
+
}),
|
|
3489
3618
|
});
|
|
3490
3619
|
|
|
3491
3620
|
const rawText = await res.text().catch(() => "");
|
|
@@ -3522,6 +3651,26 @@ async function readQueueBatch(queuePath, startOffset, maxBuckets) {
|
|
|
3522
3651
|
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
3523
3652
|
|
|
3524
3653
|
const bucketMap = new Map();
|
|
3654
|
+
// Account session states (kind: "account_session_state") are queue
|
|
3655
|
+
// control records, not usage rows: collect them separately so they ride
|
|
3656
|
+
// the same upload (after the bucket rows in file order) without entering
|
|
3657
|
+
// the bucket stream. Keyed by (source, session_id) LAST-WINS: the
|
|
3658
|
+
// append-only queue writes a session's newer observation after its older
|
|
3659
|
+
// one, and only the latest per session may reach the ingest batch (a
|
|
3660
|
+
// duplicate would make the cloud ON CONFLICT affect one row twice).
|
|
3661
|
+
// snapshot_verified_at rides along verbatim from the ORIGINAL fetch, so a
|
|
3662
|
+
// transport retry never fakes logical freshness.
|
|
3663
|
+
//
|
|
3664
|
+
// Batching: session-state records count toward the SAME per-batch record
|
|
3665
|
+
// cap (maxBuckets) as bucket rows. The ingest edge rejects a request with
|
|
3666
|
+
// more than 500 states (HTTP 400); before they counted, a states-heavy
|
|
3667
|
+
// queue (a fresh device's first 30-day TRAE sync appends one record per
|
|
3668
|
+
// observed session) was read in ONE uncapped batch - the 400 left the
|
|
3669
|
+
// queue offset untouched and every retry re-read the identical oversized
|
|
3670
|
+
// request, a permanent upload failure. With the default batchSize of 200
|
|
3671
|
+
// (and the MAX_INGEST_BUCKETS=500 clamp above), every request stays under
|
|
3672
|
+
// the edge cap while batching follows the existing design.
|
|
3673
|
+
const sessionStateMap = new Map();
|
|
3525
3674
|
let offset = startOffset;
|
|
3526
3675
|
let linesRead = 0;
|
|
3527
3676
|
for await (const line of rl) {
|
|
@@ -3534,6 +3683,37 @@ async function readQueueBatch(queuePath, startOffset, maxBuckets) {
|
|
|
3534
3683
|
} catch (_e) {
|
|
3535
3684
|
continue;
|
|
3536
3685
|
}
|
|
3686
|
+
if (bucket?.kind === "account_session_state") {
|
|
3687
|
+
if (typeof bucket.source === "string" && typeof bucket.session_id === "string" && bucket.session_id.trim()) {
|
|
3688
|
+
const ssSource = bucket.source.trim().toLowerCase();
|
|
3689
|
+
sessionStateMap.set(ssSource + "|" + bucket.session_id.trim(), {
|
|
3690
|
+
source: ssSource,
|
|
3691
|
+
session_id: bucket.session_id.trim(),
|
|
3692
|
+
model: typeof bucket.model === "string" ? bucket.model : "",
|
|
3693
|
+
bucket_start: typeof bucket.bucket_start === "string" ? bucket.bucket_start : "",
|
|
3694
|
+
input_tokens: Number(bucket.input_tokens) || 0,
|
|
3695
|
+
output_tokens: Number(bucket.output_tokens) || 0,
|
|
3696
|
+
cached_input_tokens: Number(bucket.cached_input_tokens) || 0,
|
|
3697
|
+
cache_creation_input_tokens: Number(bucket.cache_creation_input_tokens) || 0,
|
|
3698
|
+
reasoning_output_tokens: Number(bucket.reasoning_output_tokens) || 0,
|
|
3699
|
+
total_tokens: Number(bucket.total_tokens) || 0,
|
|
3700
|
+
...(typeof bucket.snapshot_verified_at === "string" && bucket.snapshot_verified_at
|
|
3701
|
+
? { snapshot_verified_at: bucket.snapshot_verified_at }
|
|
3702
|
+
: {}),
|
|
3703
|
+
});
|
|
3704
|
+
// Counted like a bucket row: the record rides THIS batch and its
|
|
3705
|
+
// bytes are covered by nextOffset, so the next batch resumes right
|
|
3706
|
+
// after it.
|
|
3707
|
+
linesRead += 1;
|
|
3708
|
+
if (linesRead >= maxBuckets) break;
|
|
3709
|
+
}
|
|
3710
|
+
continue;
|
|
3711
|
+
}
|
|
3712
|
+
// Pre-release watermark records from this feature branch's dev queues
|
|
3713
|
+
// are obsolete control records: drop them instead of letting them enter
|
|
3714
|
+
// the bucket stream (they carry no hour_start and would be skipped
|
|
3715
|
+
// downstream anyway, but be explicit).
|
|
3716
|
+
if (bucket?.kind === "account_sync_watermark") continue;
|
|
3537
3717
|
const hourStart = typeof bucket?.hour_start === "string" ? bucket.hour_start : null;
|
|
3538
3718
|
if (!hourStart) continue;
|
|
3539
3719
|
const source = (typeof bucket?.source === "string" ? bucket.source.trim().toLowerCase() : "") || "codex";
|
|
@@ -3553,7 +3733,11 @@ async function readQueueBatch(queuePath, startOffset, maxBuckets) {
|
|
|
3553
3733
|
|
|
3554
3734
|
rl.close();
|
|
3555
3735
|
stream.close?.();
|
|
3556
|
-
return {
|
|
3736
|
+
return {
|
|
3737
|
+
buckets: Array.from(bucketMap.values()),
|
|
3738
|
+
sessionStates: Array.from(sessionStateMap.values()),
|
|
3739
|
+
nextOffset: offset,
|
|
3740
|
+
};
|
|
3557
3741
|
}
|
|
3558
3742
|
|
|
3559
3743
|
function normalizeGrokRepairSource(value) {
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Account-level cross-device truth - JS reference implementation.
|
|
5
|
+
*
|
|
6
|
+
* Account-level sources (ACCOUNT_LEVEL_SOURCES in src/lib/source-metadata.js)
|
|
7
|
+
* come from a per-ACCOUNT cloud API, so every device that syncs them stores
|
|
8
|
+
* its own copy of the SAME account data. How to dedup depends on the source:
|
|
9
|
+
*
|
|
10
|
+
* - 'cursor' has NO stable session identity: its rows are identical across
|
|
11
|
+
* devices, so the legacy whole-row MAX pick per (hour, source, model)
|
|
12
|
+
* dedups them (dedupeAccountLevelRows below).
|
|
13
|
+
* - 'trae-cn' is a CORRECTABLE snapshot: totals can be revised down, a
|
|
14
|
+
* session can move to another model or another half-hour. Hour-level
|
|
15
|
+
* dedup CANNOT express that (a fresh device's first data bucket cannot
|
|
16
|
+
* safely displace earlier hours - that would need an unproven "absent =
|
|
17
|
+
* deleted" contract - and a 10:00 -> 10:30 bucket migration strands the
|
|
18
|
+
* old hour). Canonical truth for trae-cn therefore lives at the SESSION
|
|
19
|
+
* level (tokentracker_account_session_states; migrations/
|
|
20
|
+
* 20260817120000_account-session-states.sql): identity is
|
|
21
|
+
* (user_id, source, session_id) - device_id is NOT identity (the usage
|
|
22
|
+
* API request carries no device discriminator). Session-id evidence
|
|
23
|
+
* (2026-08-17): repeated-fetch stability VERIFIED, cross-window
|
|
24
|
+
* stability VERIFIED; cross-device same-account stability NOT DIRECTLY
|
|
25
|
+
* VERIFIED (no second independent device/auth experiment). Every
|
|
26
|
+
* observation of one
|
|
27
|
+
* session whole-row-replaces the previous state under a strict LWW
|
|
28
|
+
* guard, so the three correction classes collapse into ONE operation:
|
|
29
|
+
*
|
|
30
|
+
* downward S tokens 100 -> 60 replaced, total 60
|
|
31
|
+
* model S model A -> B replaced, only B remains
|
|
32
|
+
* bucket S bucket 10:00 -> 10:30 replaced, only 10:30 remains
|
|
33
|
+
*
|
|
34
|
+
* ABSENCE is NOT PROVEN to mean deletion, so nothing ever deletes a session
|
|
35
|
+
* row: a session missing from a non-empty snapshot asserts nothing, and an
|
|
36
|
+
* empty snapshot asserts nothing at all.
|
|
37
|
+
*
|
|
38
|
+
* Freshness: the TRAE API exposes no provider-side ordering signal (probed
|
|
39
|
+
* 2026-08-17: headers carry only CDN trace ids, rows carry no revision), so
|
|
40
|
+
* snapshot_verified_at is the CLIENT logical fetch stamp (best-effort
|
|
41
|
+
* cross-device ordering under clock skew - documented residual risk, NOT
|
|
42
|
+
* strict correctness). It is stamped once per real fetch and replayed
|
|
43
|
+
* verbatim by the append-only queue; the LWW guard applies strictly newer
|
|
44
|
+
* (>) stamps only, so retries are idempotent and a re-delivered older
|
|
45
|
+
* observation cannot displace a newer one. Equal stamps keep the
|
|
46
|
+
* first-applied row (stable under retries).
|
|
47
|
+
*
|
|
48
|
+
* This module is the executable specification of that semantics; the
|
|
49
|
+
* deployed SQL (migrations/20260817120000_account-session-states.sql upsert,
|
|
50
|
+
* scripts/ops/account-usage-grouped-rpc.sql + leaderboard_hourly_dedup_v2
|
|
51
|
+
* aggregation branches) implements the same algorithm and MUST stay in sync
|
|
52
|
+
* - test/account-usage-dedup.test.js pins both.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
// Queue record kind the TRAE CN parser emits for one changed session's
|
|
56
|
+
// canonical observation (src/lib/rollout.js appendTraeCnSessionStates).
|
|
57
|
+
const SESSION_STATE_KIND = "account_session_state";
|
|
58
|
+
|
|
59
|
+
function parseMs(value) {
|
|
60
|
+
const ms = Date.parse(value);
|
|
61
|
+
return Number.isFinite(ms) ? ms : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Token columns that make up a canonical session state row (the SQL table's
|
|
65
|
+
// non-key, non-metadata columns).
|
|
66
|
+
const TOKEN_COLUMNS = [
|
|
67
|
+
"input_tokens",
|
|
68
|
+
"output_tokens",
|
|
69
|
+
"cached_input_tokens",
|
|
70
|
+
"cache_creation_input_tokens",
|
|
71
|
+
"reasoning_output_tokens",
|
|
72
|
+
"total_tokens",
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
function normalizeStateRow(observation) {
|
|
76
|
+
const source = String(observation?.source ?? "").trim().toLowerCase();
|
|
77
|
+
const sessionId = String(observation?.session_id ?? "").trim();
|
|
78
|
+
if (!source || !sessionId) return null;
|
|
79
|
+
const verifiedMs = parseMs(observation?.snapshot_verified_at);
|
|
80
|
+
if (verifiedMs === null) return null;
|
|
81
|
+
const bucketStart = String(observation?.bucket_start ?? "");
|
|
82
|
+
if (parseMs(bucketStart) === null) return null;
|
|
83
|
+
const model = String(observation?.model ?? "");
|
|
84
|
+
if (!model) return null;
|
|
85
|
+
const row = {
|
|
86
|
+
source,
|
|
87
|
+
session_id: sessionId,
|
|
88
|
+
model,
|
|
89
|
+
bucket_start: bucketStart,
|
|
90
|
+
snapshot_verified_at: observation.snapshot_verified_at,
|
|
91
|
+
};
|
|
92
|
+
for (const column of TOKEN_COLUMNS) {
|
|
93
|
+
const value = Number(observation?.[column]);
|
|
94
|
+
row[column] = Number.isFinite(value) ? value : 0;
|
|
95
|
+
}
|
|
96
|
+
return row;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whole-row LWW replace of canonical session states - mirrors
|
|
101
|
+
* tokentracker_upsert_account_session_states() (migrations/
|
|
102
|
+
* 20260817120000_account-session-states.sql): a row applies only when its
|
|
103
|
+
* snapshot_verified_at is STRICTLY newer (>) than the stored one. Replays of
|
|
104
|
+
* the same observation are no-ops; equal stamps keep the first-applied row.
|
|
105
|
+
*
|
|
106
|
+
* @param {Map<string, object>} states - canonical table keyed by
|
|
107
|
+
* `${source}|${session_id}` (mirrors the (user_id, source, session_id) PK
|
|
108
|
+
* for one account; mutated in place).
|
|
109
|
+
* @param {Array<object>} observations - ingest batch (already deduped to one
|
|
110
|
+
* row per session by the edge, like the SQL batch upsert requires).
|
|
111
|
+
* @returns {number} applied row count (mirrors the SQL ROW_COUNT return).
|
|
112
|
+
*/
|
|
113
|
+
function upsertAccountSessionStates(states, observations) {
|
|
114
|
+
const table = states instanceof Map ? states : new Map();
|
|
115
|
+
const batch = Array.isArray(observations) ? observations : [];
|
|
116
|
+
let applied = 0;
|
|
117
|
+
for (const observation of batch) {
|
|
118
|
+
const row = normalizeStateRow(observation);
|
|
119
|
+
if (!row) continue;
|
|
120
|
+
const key = `${row.source}|${row.session_id}`;
|
|
121
|
+
const stored = table.get(key);
|
|
122
|
+
if (!stored) {
|
|
123
|
+
table.set(key, row);
|
|
124
|
+
applied += 1;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const storedMs = parseMs(stored.snapshot_verified_at) ?? -Infinity;
|
|
128
|
+
const rowMs = parseMs(row.snapshot_verified_at) ?? -Infinity;
|
|
129
|
+
if (rowMs > storedMs) {
|
|
130
|
+
table.set(key, row);
|
|
131
|
+
applied += 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return applied;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Aggregate canonical session states into hourly rows - mirrors the trae-cn
|
|
139
|
+
* branch of account_usage_grouped (scripts/ops/account-usage-grouped-rpc.sql)
|
|
140
|
+
* and leaderboard_hourly_dedup_v2: SUM per (bucket_start, source, model),
|
|
141
|
+
* conversations = session count.
|
|
142
|
+
*
|
|
143
|
+
* @param {Map<string, object>|Array<object>} states
|
|
144
|
+
* @param {{from?: string, to?: string}} [opts] - optional [from, to) window.
|
|
145
|
+
* @returns {Array<object>} rows shaped like tokentracker_hourly aggregates.
|
|
146
|
+
*/
|
|
147
|
+
function aggregateSessionStates(states, opts = {}) {
|
|
148
|
+
const values = states instanceof Map ? [...states.values()] : Array.isArray(states) ? states : [];
|
|
149
|
+
const fromMs = parseMs(opts.from ?? "");
|
|
150
|
+
const toMs = parseMs(opts.to ?? "");
|
|
151
|
+
const grouped = new Map();
|
|
152
|
+
for (const state of values) {
|
|
153
|
+
const row = normalizeStateRow(state);
|
|
154
|
+
if (!row) continue;
|
|
155
|
+
const bucketMs = parseMs(row.bucket_start);
|
|
156
|
+
if (bucketMs === null) continue;
|
|
157
|
+
if (fromMs !== null && bucketMs < fromMs) continue;
|
|
158
|
+
if (toMs !== null && bucketMs >= toMs) continue;
|
|
159
|
+
const key = `${row.bucket_start}||${row.source}||${row.model}`;
|
|
160
|
+
const acc = grouped.get(key) || {
|
|
161
|
+
hour_start: row.bucket_start,
|
|
162
|
+
source: row.source,
|
|
163
|
+
model: row.model,
|
|
164
|
+
input_tokens: 0,
|
|
165
|
+
output_tokens: 0,
|
|
166
|
+
cached_input_tokens: 0,
|
|
167
|
+
cache_creation_input_tokens: 0,
|
|
168
|
+
reasoning_output_tokens: 0,
|
|
169
|
+
total_tokens: 0,
|
|
170
|
+
conversations: 0,
|
|
171
|
+
};
|
|
172
|
+
for (const column of TOKEN_COLUMNS) acc[column] += row[column];
|
|
173
|
+
acc.conversations += 1;
|
|
174
|
+
grouped.set(key, acc);
|
|
175
|
+
}
|
|
176
|
+
return [...grouped.values()].sort(
|
|
177
|
+
(a, b) =>
|
|
178
|
+
String(a.hour_start).localeCompare(String(b.hour_start)) ||
|
|
179
|
+
String(a.source).localeCompare(String(b.source)) ||
|
|
180
|
+
String(a.model).localeCompare(String(b.model)),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Mirrors the legacy SQL DISTINCT ON (hour, source, model)
|
|
185
|
+
// ORDER BY total_tokens DESC, updated_at DESC (device_id asc as the final
|
|
186
|
+
// deterministic tiebreak). Used ONLY for account-level sources without a
|
|
187
|
+
// stable session identity ('cursor') and watermark-less history.
|
|
188
|
+
function pickLegacyRow(rows) {
|
|
189
|
+
let best = null;
|
|
190
|
+
for (const row of rows) {
|
|
191
|
+
if (!best) {
|
|
192
|
+
best = row;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const rowTotal = Number(row.total_tokens) || 0;
|
|
196
|
+
const bestTotal = Number(best.total_tokens) || 0;
|
|
197
|
+
if (rowTotal !== bestTotal) {
|
|
198
|
+
if (rowTotal > bestTotal) best = row;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const rowUpdated = parseMs(row.updated_at ?? "") ?? -Infinity;
|
|
202
|
+
const bestUpdated = parseMs(best.updated_at ?? "") ?? -Infinity;
|
|
203
|
+
if (rowUpdated !== bestUpdated) {
|
|
204
|
+
if (rowUpdated > bestUpdated) best = row;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (String(row.device_id) < String(best.device_id)) best = row;
|
|
208
|
+
}
|
|
209
|
+
return best;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Legacy whole-row MAX dedup for account-level hourly rows WITHOUT a stable
|
|
214
|
+
* session identity ('cursor'): rows are identical across devices, so one
|
|
215
|
+
* canonical row per (hour, source, model) suffices. Sources with a session
|
|
216
|
+
* identity ('trae-cn') must instead aggregate from canonical session states
|
|
217
|
+
* (aggregateSessionStates) - never from per-device hourly rows.
|
|
218
|
+
*
|
|
219
|
+
* @param {Array<{device_id: string, hour_start: string, source: string,
|
|
220
|
+
* model: string, updated_at?: string, total_tokens: number}>} rows -
|
|
221
|
+
* every device's tokentracker_hourly-shaped rows.
|
|
222
|
+
* @returns {Array} canonical rows - one consistent row per (hour, model).
|
|
223
|
+
*/
|
|
224
|
+
function dedupeAccountLevelRows(rows) {
|
|
225
|
+
if (!Array.isArray(rows)) return [];
|
|
226
|
+
// Group rows by (source, hour_start); keep original row objects untouched.
|
|
227
|
+
const byHour = new Map();
|
|
228
|
+
for (const row of rows) {
|
|
229
|
+
if (!row || typeof row.hour_start !== "string" || !row.hour_start) continue;
|
|
230
|
+
const key = `${row.source}||${row.hour_start}`;
|
|
231
|
+
const list = byHour.get(key) || [];
|
|
232
|
+
list.push(row);
|
|
233
|
+
byHour.set(key, list);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const out = [];
|
|
237
|
+
for (const [, hourRows] of byHour) {
|
|
238
|
+
const byModel = new Map();
|
|
239
|
+
for (const row of hourRows) {
|
|
240
|
+
const modelKey = row.model || "unknown";
|
|
241
|
+
const list = byModel.get(modelKey) || [];
|
|
242
|
+
list.push(row);
|
|
243
|
+
byModel.set(modelKey, list);
|
|
244
|
+
}
|
|
245
|
+
for (const list of byModel.values()) {
|
|
246
|
+
out.push(pickLegacyRow(list));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Total tokens of a deduped row set - convenience for tests/diagnostics. */
|
|
253
|
+
function sumTotalTokens(rows) {
|
|
254
|
+
return (Array.isArray(rows) ? rows : []).reduce(
|
|
255
|
+
(acc, row) => acc + (Number(row?.total_tokens) || 0),
|
|
256
|
+
0,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
module.exports = {
|
|
261
|
+
SESSION_STATE_KIND,
|
|
262
|
+
TOKEN_COLUMNS,
|
|
263
|
+
upsertAccountSessionStates,
|
|
264
|
+
aggregateSessionStates,
|
|
265
|
+
dedupeAccountLevelRows,
|
|
266
|
+
sumTotalTokens,
|
|
267
|
+
};
|
package/src/lib/local-api.js
CHANGED
|
@@ -338,6 +338,10 @@ function readQueueData(queuePath) {
|
|
|
338
338
|
if (!line.trim()) continue;
|
|
339
339
|
try {
|
|
340
340
|
const row = JSON.parse(line);
|
|
341
|
+
// Account session states (and the pre-release watermark records
|
|
342
|
+
// this branch may still hold in a dev queue) are cloud-side control
|
|
343
|
+
// records, not usage rows - never surface them locally.
|
|
344
|
+
if (row?.kind === "account_session_state" || row?.kind === "account_sync_watermark") continue;
|
|
341
345
|
// Deduplicate: each sync appends cumulative totals per bucket, so for
|
|
342
346
|
// each (source, model, hour_start) keep only the latest (last) entry.
|
|
343
347
|
const key = `${row.source || ""}|${row.model || ""}|${row.hour_start || ""}`;
|