tokentracker-cli 1.5.3 → 1.5.5
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-uuDGO9Mq.js → AchievementBadge-BYxdWhS1.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-BCTVT7Jo.js → AchievementsPage-Bv9o7Aj8.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-B6fJSJSr.js → AchievementsSection-DE5bZGxg.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-FjUN__9G.js → BadgeDetailModal-CgZaSVTp.js} +1 -1
- package/dashboard/dist/assets/{BotAnimated-B-KqY_Wr.js → BotAnimated-CwhPfUBI.js} +1 -1
- package/dashboard/dist/assets/{Card-DS8T8piP.js → Card-Y7qaxMuE.js} +1 -1
- package/dashboard/dist/assets/{CloudCheckoutPage-_0UjsFPu.js → CloudCheckoutPage-DVl7jk6b.js} +1 -1
- package/dashboard/dist/assets/{CloudPage-BstgVBaV.js → CloudPage-BnIvpHGH.js} +2 -2
- package/dashboard/dist/assets/{CommandPalette-DtVsYZgc.js → CommandPalette-CzMv2Zxn.js} +1 -1
- package/dashboard/dist/assets/{CommunityStatsModal-DTpEJqV3.js → CommunityStatsModal-D8sOZqUc.js} +1 -1
- package/dashboard/dist/assets/{ConfirmModal-Lfdh2vr7.js → ConfirmModal-Bun5xKMm.js} +1 -1
- package/dashboard/dist/assets/{Controls-DpwQ7pkq.js → Controls-ltfnvkFW.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-C3Xu6_3t.js +1 -0
- package/dashboard/dist/assets/{DateRangePopover-CrWgrzT2.js → DateRangePopover-e3gJYqFJ.js} +1 -1
- package/dashboard/dist/assets/{DevicePage-DbK5KWJS.js → DevicePage-JgT16dEp.js} +1 -1
- package/dashboard/dist/assets/{DialogClose-Dd2B_o03.js → DialogClose-DeWert7h.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-Ca6W3nCT.js +1 -0
- package/dashboard/dist/assets/{DialogTitle-3JQBVha3.js → DialogTitle-BfK_yOeR.js} +1 -1
- package/dashboard/dist/assets/{ElectricLogo-LYAqG8pG.js → ElectricLogo-Bbev0Y8Q.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-DhDvUor7.js → FadeIn-fypYWLDK.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-9f1XmgYC.js → HeaderGithubStar-Cnil2Lbo.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-BXhHAkP7.js → HoverTooltip-Bw_8tru-.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-ChPdDeMl.js → IpCheckPage-CHnhUgA2.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-DnJ7Y9zw.js → LandingPage-Bq4iQS6S.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardPage-BF9isLzu.js → LeaderboardPage-D42lUdw-.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardProBadge-BM4JKgkC.js → LeaderboardProBadge-eTKceT90.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardProfileModal-DfZABOpN.js → LeaderboardProfileModal-D-3tRrOR.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfilePage-BaZ38iT5.js → LeaderboardProfilePage-DkRCJoiA.js} +1 -1
- package/dashboard/dist/assets/LimitsPage-dUXjP3_1.js +6 -0
- package/dashboard/dist/assets/{LocalOnlyNotice-DvhlYoqQ.js → LocalOnlyNotice-CtpjB6Z2.js} +1 -1
- package/dashboard/dist/assets/{LoginCard-g_Gbnzhs.js → LoginCard-zrRd8o54.js} +1 -1
- package/dashboard/dist/assets/{LoginModal-DkJrI6b7.js → LoginModal-DNl5Rt1e.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-CqCKSo0n.js → LoginPage-BuB707vV.js} +1 -1
- package/dashboard/dist/assets/{PetPage-DbF7ROsj.js → PetPage-BpDriXkp.js} +2 -2
- package/dashboard/dist/assets/{PopoverPopup-4WuH41ZT.js → PopoverPopup-rQNSs8R6.js} +1 -1
- package/dashboard/dist/assets/{ProviderIcon-6j0A2KD9.js → ProviderIcon-CumyO371.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-DBd-iwuM.js → ResetPasswordPage-ecUHfJAk.js} +1 -1
- package/dashboard/dist/assets/{Select-BBEpLeiG.js → Select-YVEqEhk4.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-Cn_U_rrO.js → SelectItemText-D1NBc4uc.js} +1 -1
- package/dashboard/dist/assets/{SelfHostPage-2WZc6Rml.js → SelfHostPage-8nAin68c.js} +1 -1
- package/dashboard/dist/assets/ServiceStatusPage-D1zxuzOg.js +1 -0
- package/dashboard/dist/assets/SessionsPage-B6ksEER_.js +3 -0
- package/dashboard/dist/assets/{SettingsPage-rXNr96i-.js → SettingsPage-COqP8ZbJ.js} +3 -3
- package/dashboard/dist/assets/{SkillsPage-CudTvbZL.js → SkillsPage-DCX7xTj0.js} +1 -1
- package/dashboard/dist/assets/TokenGalaxy-D4n7AR-R.js +4431 -0
- package/dashboard/dist/assets/{TooltipPopup-CsLKXhWC.js → TooltipPopup-DvSoB_eS.js} +1 -1
- package/dashboard/dist/assets/{TrendMonitor-8onUTbkk.js → TrendMonitor-5GVzh0Ye.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-CcwE0lz8.js → WidgetsPage-BIc4MBiu.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-Cj9LLYqP.js → WrappedPage-BpQk4g0o.js} +1 -1
- package/dashboard/dist/assets/__vite-browser-external-B_rKFTTN.js +45 -0
- package/dashboard/dist/assets/__vite-browser-external-CMBWGkOq.css +1 -0
- package/dashboard/dist/assets/{agent-logos-ChBA1nAH.js → agent-logos-DVg-sTm4.js} +1 -1
- package/dashboard/dist/assets/{arrow-left-Bb5mfoPc.js → arrow-left-Ch6Q7eU5.js} +1 -1
- package/dashboard/dist/assets/{arrow-right-D1Ti2XUI.js → arrow-right-B2B9GvSI.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-B_CjA_y8.js → arrow-up-right-DwNO_uSb.js} +1 -1
- package/dashboard/dist/assets/{calendar-days-DdfrAqie.js → calendar-days---NjJlBi.js} +1 -1
- package/dashboard/dist/assets/{check-29zGw1MK.js → check-D3mhgRrW.js} +1 -1
- package/dashboard/dist/assets/{chevron-down-DAzlFX7g.js → chevron-down-x-b39Zto.js} +1 -1
- package/dashboard/dist/assets/{cloud-checkout-C2SeCjFM.js → cloud-checkout-CycWyTsg.js} +1 -1
- package/dashboard/dist/assets/{composite-DIf735Hq.js → composite-CnA1qtYn.js} +1 -1
- package/dashboard/dist/assets/{copy-BOuOM0HQ.js → copy-JRnRMgdX.js} +1 -1
- package/dashboard/dist/assets/{download-50tQRpGe.js → download-CRLqGD62.js} +1 -1
- package/dashboard/dist/assets/{external-link-BhlQClVe.js → external-link-BrmaWV_t.js} +1 -1
- package/dashboard/dist/assets/{flame-CeGfA-0L.js → flame-meQ5-vRD.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-Cq7YiAkJ.js → format-tokens-D8mIJGz1.js} +1 -1
- package/dashboard/dist/assets/{gift-Do-UFhiT.js → gift-Cd0FPXgO.js} +1 -1
- package/dashboard/dist/assets/{icons-C6EZ_HmP.js → icons-D-PTKw7b.js} +1 -1
- package/dashboard/dist/assets/{index-BWhbBAd9.js → index-CMV9UKNf.js} +1 -1
- package/dashboard/dist/assets/{index-c0crpe9Z.js → index-C_6gEM83.js} +1 -1
- package/dashboard/dist/assets/{index-BFrbVqsm.js → index-DCIZaLNC.js} +1 -1
- package/dashboard/dist/assets/index-HLYcwmPD.js +502 -0
- package/dashboard/dist/assets/{info-B4MXmsNE.js → info-e0tP9P3D.js} +1 -1
- package/dashboard/dist/assets/{link-2-DuWCslzl.js → link-2-DZ2TQhER.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-C2I3NZAO.js → loader-circle-DjflRntu.js} +1 -1
- package/dashboard/dist/assets/{local-usage-transfer-BQasvsUF.js → local-usage-transfer-XTZLA3XH.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-DbeNS6FT.js → modifiers.esm-BpEQ8-Gt.js} +1 -1
- package/dashboard/dist/assets/module-BbVsRaE2.js +3 -0
- package/dashboard/dist/assets/{monitor-smartphone-C6Y4ktsc.js → monitor-smartphone-VVBcI30G.js} +1 -1
- package/dashboard/dist/assets/{play-CwgcDJC2.js → play-DN4Sb3W_.js} +1 -1
- package/dashboard/dist/assets/{plus-B796wsUO.js → plus-Cz_k7arH.js} +1 -1
- package/dashboard/dist/assets/{provider-display-Cw26RFDm.js → provider-display-S1mSaydF.js} +1 -1
- package/dashboard/dist/assets/{react-d7Bk1-_K.js → react-MQBcjrn_.js} +1 -1
- package/dashboard/dist/assets/{safePolygon-CDE0dNv_.js → safePolygon-InKxYcXd.js} +1 -1
- package/dashboard/dist/assets/{search-DtKvsdRs.js → search-C-N0RDi0.js} +1 -1
- package/dashboard/dist/assets/{server-gAURHUyz.js → server-CZkn8vIZ.js} +1 -1
- package/dashboard/dist/assets/{shield-check-DFVdDTwh.js → shield-check-DPrGFouZ.js} +1 -1
- package/dashboard/dist/assets/{skills-api-2rdPUrJI.js → skills-api-wMwM-GpF.js} +1 -1
- package/dashboard/dist/assets/{sortable.esm-DFlfwnJr.js → sortable.esm-CUPP1ihT.js} +1 -1
- package/dashboard/dist/assets/{store-BKPCk_de.js → store-BpVdRYnQ.js} +1 -1
- package/dashboard/dist/assets/{terminal-BtWFakvS.js → terminal-C-7TFeKg.js} +1 -1
- package/dashboard/dist/assets/{trash-bpr9zTAM.js → trash-Be8cd4HD.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-DMnGgLne.js → use-community-stats-cG0WrB4Y.js} +1 -1
- package/dashboard/dist/assets/{use-latest-request-guard-BSBlJJHe.js → use-latest-request-guard-C91enyPD.js} +1 -1
- package/dashboard/dist/assets/use-limits-display-prefs-DdwoLqPD.js +1 -0
- package/dashboard/dist/assets/{use-native-settings-OthqiF6g.js → use-native-settings-BR-NiMKh.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list-DpAtr0R-.js → use-ordered-list-oqV724BP.js} +1 -1
- package/dashboard/dist/assets/use-reduced-motion-DW5YHBBI.js +1 -0
- package/dashboard/dist/assets/{use-transform-BRz9dz_2.js → use-transform-BsNSgVD6.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-CwmhudAX.js → use-usage-limits-Dk8LcMU-.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-CAA-tQ27.js → useCurrency-FxerRfd2.js} +1 -1
- package/dashboard/dist/assets/{usePositioner-Cj2DPmwM.js → usePositioner-D2J-rSfX.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-Bf5-f7Hx.js → useTokenFormat-CaI2nl2C.js} +1 -1
- package/dashboard/dist/assets/{zap-B8Fg6PaC.js → zap-D3C37XAj.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dashboard/dist/ip-check.html +2 -2
- package/dashboard/dist/leaderboard.html +2 -2
- package/dashboard/dist/share.html +2 -2
- package/package.json +4 -4
- package/src/commands/serve.js +3 -2
- package/src/lib/accept-encoding.js +59 -0
- package/src/lib/cursor-config.js +18 -2
- package/src/lib/deepseek-limits.js +280 -0
- package/src/lib/grok-limits.js +40 -1
- package/src/lib/local-api.js +229 -31
- package/src/lib/opencode-status.js +88 -0
- package/src/lib/pricing/curated-overrides.json +14 -0
- package/src/lib/pricing/index.js +182 -79
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/provider-api-key-store.js +2 -0
- package/src/lib/provider-status.js +17 -5
- package/src/lib/rollout.js +13 -4
- package/src/lib/session-analytics.js +194 -29
- package/src/lib/static-server.js +74 -2
- package/src/lib/usage-limits.js +801 -26
- package/src/lib/workbuddy-limits.js +106 -14
- package/dashboard/dist/assets/DashboardPage-DUFWejkE.js +0 -1
- package/dashboard/dist/assets/DialogDescription-CLy0arbt.js +0 -1
- package/dashboard/dist/assets/LimitsPage-C7LbG3LX.js +0 -6
- package/dashboard/dist/assets/ServiceStatusPage-B-g4MDl0.js +0 -1
- package/dashboard/dist/assets/SessionsPage-CWS0iQ1L.js +0 -3
- package/dashboard/dist/assets/TokenGalaxy-BRKrbTsJ.js +0 -4426
- package/dashboard/dist/assets/__vite-browser-external-07djSGmy.js +0 -45
- package/dashboard/dist/assets/__vite-browser-external-BDldtsD2.css +0 -1
- package/dashboard/dist/assets/index-D9kwYWLX.js +0 -502
- package/dashboard/dist/assets/module-ZyVsxBG_.js +0 -3
- package/dashboard/dist/assets/use-limits-display-prefs-eYlvnUKn.js +0 -1
- package/dashboard/dist/assets/use-reduced-motion-DF0grwHs.js +0 -1
|
@@ -19,11 +19,13 @@ const { ensureDir, writeFileAtomic, chmod600IfPossible } = require("./fs");
|
|
|
19
19
|
const PROVIDER_KEY_FILES = {
|
|
20
20
|
"opencode-go": "opencode-go-api-key.json",
|
|
21
21
|
"command-code": "command-code-api-key.json",
|
|
22
|
+
"deepseek": "deepseek-api-key.json",
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
const PROVIDER_ENV_NAMES = {
|
|
25
26
|
"opencode-go": "OPENCODE_GO_API_KEY",
|
|
26
27
|
"command-code": "COMMAND_CODE_API_KEY",
|
|
28
|
+
"deepseek": "DEEPSEEK_API_KEY",
|
|
27
29
|
};
|
|
28
30
|
|
|
29
31
|
const MIN_API_KEY_LENGTH = 8;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// { "page": { "updated_at": ISO }, "status": { "indicator": "none|minor|major|critical", "description": "..." } }
|
|
11
11
|
|
|
12
12
|
const { parseDeepSeekStatusPage } = require("./deepseek-status");
|
|
13
|
+
const { parseOpenCodeStatusPayload } = require("./opencode-status");
|
|
13
14
|
|
|
14
15
|
const STATUS_PROBE_TIMEOUT_MS = 6_000;
|
|
15
16
|
// Status pages change on incident timescales; 5 minutes keeps the limits
|
|
@@ -32,6 +33,13 @@ const STATUS_PAGE_SPECS = {
|
|
|
32
33
|
pageUrl: "https://status.deepseek.com",
|
|
33
34
|
format: "deepseek-rsc",
|
|
34
35
|
},
|
|
36
|
+
opencode: {
|
|
37
|
+
// Official status feed (custom JSON schema) served without CORS headers.
|
|
38
|
+
// Relayed server-side; `format` selects the payload parser below.
|
|
39
|
+
apiUrl: "https://status.opencode.ai/api/status",
|
|
40
|
+
pageUrl: "https://status.opencode.ai",
|
|
41
|
+
format: "opencode-json",
|
|
42
|
+
},
|
|
35
43
|
};
|
|
36
44
|
|
|
37
45
|
const VALID_INDICATORS = new Set(["none", "minor", "major", "critical"]);
|
|
@@ -77,16 +85,20 @@ async function fetchProviderServiceStatus(providerId, { fetchImpl = fetch, nowMs
|
|
|
77
85
|
const controller = new AbortController();
|
|
78
86
|
const timer = setTimeout(() => controller.abort(), STATUS_PROBE_TIMEOUT_MS);
|
|
79
87
|
try {
|
|
80
|
-
const isDeepSeek = spec.format === "deepseek-rsc";
|
|
81
88
|
const res = await fetchImpl(spec.apiUrl, {
|
|
82
89
|
method: "GET",
|
|
83
|
-
headers: { Accept:
|
|
90
|
+
headers: { Accept: spec.format === "deepseek-rsc" ? "text/html" : "application/json" },
|
|
84
91
|
signal: controller.signal,
|
|
85
92
|
});
|
|
86
93
|
if (res.ok) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
94
|
+
let payload;
|
|
95
|
+
if (spec.format === "deepseek-rsc") {
|
|
96
|
+
payload = parseDeepSeekStatusPage(await res.text());
|
|
97
|
+
} else if (spec.format === "opencode-json") {
|
|
98
|
+
payload = parseOpenCodeStatusPayload(await res.json());
|
|
99
|
+
} else {
|
|
100
|
+
payload = await res.json();
|
|
101
|
+
}
|
|
90
102
|
data = normalizeStatusPayload(payload, spec, nowMs);
|
|
91
103
|
}
|
|
92
104
|
} finally {
|
package/src/lib/rollout.js
CHANGED
|
@@ -5587,10 +5587,14 @@ function detectOpencodeMessageLayout(dbPath, sqliteOptions = {}, { propagateRead
|
|
|
5587
5587
|
}
|
|
5588
5588
|
|
|
5589
5589
|
// Shared provider resolver: v1 rows carry a top-level providerID, v2 rows nest
|
|
5590
|
-
// it under model.providerID.
|
|
5591
|
-
//
|
|
5590
|
+
// it under model.providerID. ZCode renamed the flat field to `providerId`
|
|
5591
|
+
// (lowercase d, ~2026-09, issue #1008); rows written since then only carry the
|
|
5592
|
+
// new spelling, so without it every post-rename native turn resolves to "" and
|
|
5593
|
+
// the zcode legacy-history leg silently drops it. Used by the mimo/zcode
|
|
5594
|
+
// discriminators so they work against all generations without duplicating the
|
|
5595
|
+
// resolution logic.
|
|
5592
5596
|
function opencodeMessageProvider(data) {
|
|
5593
|
-
return data?.providerID || data?.model?.providerID || "";
|
|
5597
|
+
return data?.providerID || data?.model?.providerID || data?.providerId || "";
|
|
5594
5598
|
}
|
|
5595
5599
|
|
|
5596
5600
|
// readSqliteJsonRows wraps its two backend errors into the thrown Error, so a
|
|
@@ -7743,7 +7747,12 @@ async function parseCursorApiIncremental({
|
|
|
7743
7747
|
|
|
7744
7748
|
const { normalizeCursorUsage } = require("./cursor-config");
|
|
7745
7749
|
const delta = normalizeCursorUsage(record);
|
|
7746
|
-
|
|
7750
|
+
// isAllZeroUsage reads only the token fields, so a request-only row (0
|
|
7751
|
+
// tokens across every column — e.g. bot/automation requests, issue #998)
|
|
7752
|
+
// must not be dropped here: it is kept as a 1-request record pairing
|
|
7753
|
+
// with the #997 cloud-side request retention. Rows that are all-zero
|
|
7754
|
+
// AND carry no model identity are degenerate and stay skipped.
|
|
7755
|
+
if (isAllZeroUsage(delta) && !String(record.model || "").trim()) continue;
|
|
7747
7756
|
|
|
7748
7757
|
delta.conversation_count = 1;
|
|
7749
7758
|
|
|
@@ -30,7 +30,7 @@ const path = require("node:path");
|
|
|
30
30
|
const readline = require("node:readline");
|
|
31
31
|
const { listClaudeProjectFiles, listRolloutFilesDeep, claudeMessageDedupKey, normalizeClaudeUsage, readZcodeDbMessages, coerceEpochMs, resolveZcodeInstallPaths, normalizeOpencodeModelFields, normalizeOpencodeTokens, deriveOpencodeMessageFingerprint, resolveDshHomes } = require("./rollout");
|
|
32
32
|
const { parseCodexRolloutFile } = require("./codex-rollout-parser");
|
|
33
|
-
const { computeRowCost, getModelPricingInfo } = require("./pricing");
|
|
33
|
+
const { computeRowCost, getModelPricingInfo, getPricingRevision } = require("./pricing");
|
|
34
34
|
const { normalizePerformance, mergePerformance, createClaudePerformanceCollector, createCodexPerformanceCollector, createZcodePerformanceCollector } = require("./session-performance");
|
|
35
35
|
const { readSqliteJsonRows, readSqliteJsonRowsAsync } = require("./sqlite-reader");
|
|
36
36
|
const { resolveZcodeNativeDbPath, resolveInstallPaths } = require("./install-resolver");
|
|
@@ -92,7 +92,9 @@ const {
|
|
|
92
92
|
// v26 retains Codex Ultrafast (service_tier ultrafast) subset columns so
|
|
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
|
+
// v28 records per-minute usage buckets for Claude and Grok, so date ranges
|
|
96
|
+
// recompute in-range tokens/cost instead of falling back to lifetime totals.
|
|
97
|
+
const SIDECAR_VERSION = 28;
|
|
96
98
|
// Invalidate only Codex token summaries; other sources keep their cached rows.
|
|
97
99
|
const CODEX_USAGE_VERSION = 1;
|
|
98
100
|
const EDIT_TOOLS = new Set([
|
|
@@ -558,6 +560,7 @@ async function scanClaudeSession(filePath) {
|
|
|
558
560
|
const seenMessages = new Set();
|
|
559
561
|
const performanceCollector = createClaudePerformanceCollector();
|
|
560
562
|
const modelUsageByModel = new Map();
|
|
563
|
+
const usageBuckets = new Map();
|
|
561
564
|
const editTurnsByModel = new Map();
|
|
562
565
|
const bounds = emptyBounds();
|
|
563
566
|
// The basename keeps grouping stable for files that never write a sessionId
|
|
@@ -652,6 +655,16 @@ async function scanClaudeSession(filePath) {
|
|
|
652
655
|
addTotals(modelUsage, usage);
|
|
653
656
|
modelUsage.usage_events += 1;
|
|
654
657
|
modelUsageByModel.set(model, modelUsage);
|
|
658
|
+
const usageMs = Date.parse(obj.timestamp || obj.message.timestamp || "");
|
|
659
|
+
if (Number.isFinite(usageMs)) {
|
|
660
|
+
const timestamp = new Date(Math.floor(usageMs / 60_000) * 60_000).toISOString();
|
|
661
|
+
const bucketKey = `${timestamp}\u0000${model}`;
|
|
662
|
+
const bucket = usageBuckets.get(bucketKey) || { timestamp, model: model || "unknown", ...emptyTotals(), usage_events: 0 };
|
|
663
|
+
addTotals(bucket, usage);
|
|
664
|
+
mergeUsageMetadata(bucket, usage);
|
|
665
|
+
bucket.usage_events = finite(bucket.usage_events) + 1;
|
|
666
|
+
usageBuckets.set(bucketKey, bucket);
|
|
667
|
+
}
|
|
655
668
|
}
|
|
656
669
|
const content = Array.isArray(obj.message.content) ? obj.message.content : [];
|
|
657
670
|
for (const block of content) {
|
|
@@ -696,6 +709,7 @@ async function scanClaudeSession(filePath) {
|
|
|
696
709
|
edit_turns: finite(editTurnsByModel.get(row.model)),
|
|
697
710
|
performance: performance.byModel.get(row.model) || normalizePerformance(),
|
|
698
711
|
})),
|
|
712
|
+
usage_buckets: [...usageBuckets.values()],
|
|
699
713
|
performance: performance.performance,
|
|
700
714
|
...bounds,
|
|
701
715
|
turns,
|
|
@@ -1016,6 +1030,7 @@ async function scanGrokSession(filePath) {
|
|
|
1016
1030
|
const summary = readJsonFileSync(grokSummaryPathFor(filePath)) || {};
|
|
1017
1031
|
const signals = readJsonFileSync(grokSignalsPathFor(filePath)) || {};
|
|
1018
1032
|
const tokens = emptyTotals();
|
|
1033
|
+
const usageBuckets = new Map();
|
|
1019
1034
|
const bounds = emptyBounds();
|
|
1020
1035
|
const dirName = path.basename(path.dirname(filePath));
|
|
1021
1036
|
let observedSessionId = typeof summary?.info?.id === "string" && summary.info.id
|
|
@@ -1125,6 +1140,20 @@ async function scanGrokSession(filePath) {
|
|
|
1125
1140
|
(providerCostUsd + normalizedUsage.total_cost_usd) * USD_TICKS_PER_USD,
|
|
1126
1141
|
) / USD_TICKS_PER_USD;
|
|
1127
1142
|
}
|
|
1143
|
+
const usageMs = Date.parse(grokTimestampIso(obj));
|
|
1144
|
+
if (Number.isFinite(usageMs) && normalizedUsage.total_tokens > 0) {
|
|
1145
|
+
const timestamp = new Date(Math.floor(usageMs / 60_000) * 60_000).toISOString();
|
|
1146
|
+
const bucketModel = pickGrokModel(usage, model) || model || "unknown";
|
|
1147
|
+
const bucketKey = `${timestamp}\u0000${bucketModel}`;
|
|
1148
|
+
const bucket = usageBuckets.get(bucketKey) || { timestamp, model: bucketModel, ...emptyTotals(), usage_events: 0, provider_cost_usd: 0 };
|
|
1149
|
+
addTotals(bucket, normalizedUsage);
|
|
1150
|
+
mergeUsageMetadata(bucket, normalizedUsage);
|
|
1151
|
+
bucket.usage_events = finite(bucket.usage_events) + 1;
|
|
1152
|
+
if (normalizedUsage.total_cost_usd != null) {
|
|
1153
|
+
bucket.provider_cost_usd = finite(bucket.provider_cost_usd) + Number(normalizedUsage.total_cost_usd);
|
|
1154
|
+
}
|
|
1155
|
+
usageBuckets.set(bucketKey, bucket);
|
|
1156
|
+
}
|
|
1128
1157
|
}
|
|
1129
1158
|
const candidate = pickGrokModel(usage, model);
|
|
1130
1159
|
if (candidate && candidate !== "unknown") model = candidate;
|
|
@@ -1172,6 +1201,7 @@ async function scanGrokSession(filePath) {
|
|
|
1172
1201
|
subagent_calls: subagentCalls,
|
|
1173
1202
|
subagent_types: Object.fromEntries([...subagentTypes.entries()].sort()),
|
|
1174
1203
|
tokens,
|
|
1204
|
+
usage_buckets: [...usageBuckets.values()],
|
|
1175
1205
|
usage_events: usageEvents,
|
|
1176
1206
|
usage_precision: usageIsIncomplete ? "reported_incomplete" : (usageEvents > 0 ? "reported" : "unavailable"),
|
|
1177
1207
|
usage_is_incomplete: usageIsIncomplete,
|
|
@@ -2139,7 +2169,10 @@ function filesSignature(files) {
|
|
|
2139
2169
|
for (const filePath of files) {
|
|
2140
2170
|
try {
|
|
2141
2171
|
const stat = fs.statSync(filePath);
|
|
2142
|
-
|
|
2172
|
+
// Keep this in sync with sessionFileStatKey(): a size+mtime-only hash
|
|
2173
|
+
// misses rewrites that preserve both (backup restore, mtime-preserving
|
|
2174
|
+
// editors, atomic replace with identical size), while ctime changes.
|
|
2175
|
+
hash.update(`${filePath}\0${stat.size}\0${stat.mtimeMs}\0${stat.ctimeMs}\n`);
|
|
2143
2176
|
} catch { /* vanished during discovery */ }
|
|
2144
2177
|
}
|
|
2145
2178
|
return hash.digest("hex");
|
|
@@ -2238,13 +2271,49 @@ function analyticsEntryStatKey(source, filePath) {
|
|
|
2238
2271
|
return sessionStat;
|
|
2239
2272
|
}
|
|
2240
2273
|
|
|
2274
|
+
// The sidecar is a local, append-only JSONL snapshot that can be several MB.
|
|
2275
|
+
// Re-reading and reparsing it on every cache-hit request showed up as ~150ms on
|
|
2276
|
+
// a real 7.5MB sidecar. Cache the parsed+repriced rows by file identity and
|
|
2277
|
+
// pricing revision; writeAtomic() replaces the file (new mtime/ctime), so a
|
|
2278
|
+
// sync invalidates this naturally.
|
|
2279
|
+
const SIDECAR_READ_CACHE_MAX = 4;
|
|
2280
|
+
const sidecarReadCache = new Map();
|
|
2281
|
+
|
|
2241
2282
|
function readSidecar(sidecarPath) {
|
|
2283
|
+
let fd;
|
|
2242
2284
|
try {
|
|
2243
|
-
|
|
2285
|
+
// Open once and use that descriptor for both stat and read, so a file
|
|
2286
|
+
// replacement between the two operations cannot mix identities.
|
|
2287
|
+
fd = fs.openSync(sidecarPath, "r");
|
|
2288
|
+
const stat = fs.fstatSync(fd);
|
|
2289
|
+
const cacheKey = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${getPricingRevision()}`;
|
|
2290
|
+
const cached = sidecarReadCache.get(sidecarPath);
|
|
2291
|
+
if (cached?.key === cacheKey) return cached.rows.map((row) => ({ ...row }));
|
|
2292
|
+
const rows = fs.readFileSync(fd, "utf8")
|
|
2244
2293
|
.split("\n")
|
|
2245
2294
|
.filter(Boolean)
|
|
2246
2295
|
.map((line) => repriceSessionRecord(JSON.parse(line)));
|
|
2247
|
-
|
|
2296
|
+
sidecarReadCache.set(sidecarPath, { key: cacheKey, rows });
|
|
2297
|
+
if (sidecarReadCache.size > SIDECAR_READ_CACHE_MAX) {
|
|
2298
|
+
const oldest = sidecarReadCache.keys().next().value;
|
|
2299
|
+
sidecarReadCache.delete(oldest);
|
|
2300
|
+
}
|
|
2301
|
+
return rows;
|
|
2302
|
+
} catch {
|
|
2303
|
+
const rows = [];
|
|
2304
|
+
Object.defineProperty(rows, "sidecarReadFailed", { value: true, enumerable: false });
|
|
2305
|
+
return rows;
|
|
2306
|
+
} finally {
|
|
2307
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
function readSidecarWithMeta(sidecarPath, meta) {
|
|
2312
|
+
const rows = readSidecar(sidecarPath);
|
|
2313
|
+
if (Number.isFinite(meta?.skipped_files)) {
|
|
2314
|
+
Object.defineProperty(rows, "skippedFiles", { value: meta.skipped_files, enumerable: false });
|
|
2315
|
+
}
|
|
2316
|
+
return rows;
|
|
2248
2317
|
}
|
|
2249
2318
|
|
|
2250
2319
|
async function writeAtomic(filePath, content) {
|
|
@@ -2254,7 +2323,11 @@ async function writeAtomic(filePath, content) {
|
|
|
2254
2323
|
await fsp.rename(temp, filePath);
|
|
2255
2324
|
}
|
|
2256
2325
|
|
|
2257
|
-
|
|
2326
|
+
// `force` rescans every session file. `recheck` (a user-triggered refresh)
|
|
2327
|
+
// only ignores the TTL: it still reuses every file whose stat key is unchanged
|
|
2328
|
+
// and rescans the rest, so Refresh costs one directory walk plus the delta
|
|
2329
|
+
// instead of a full re-parse of all transcripts.
|
|
2330
|
+
async function buildSessionAnalyticsInternal({ home = os.homedir(), force = false, recheck = false, cacheTtlMs = 5 * 60_000 } = {}) {
|
|
2258
2331
|
const sidecarPath = resolveSessionSidecarPath(home);
|
|
2259
2332
|
const metaPath = `${sidecarPath}.meta.json`;
|
|
2260
2333
|
// A lock/permission failure need not alter the database's stat signature.
|
|
@@ -2276,11 +2349,14 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
2276
2349
|
previousMeta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
|
|
2277
2350
|
const checkedAt = Date.parse(previousMeta.checked_at || previousMeta.generated_at || "");
|
|
2278
2351
|
if (
|
|
2352
|
+
// `recheck` deliberately skips the TTL so Refresh always re-walks the
|
|
2353
|
+
// directories; the per-file stat reuse below still avoids re-parsing.
|
|
2354
|
+
!recheck &&
|
|
2279
2355
|
!retryZcodeScan && !retryOpencodeScan && !retryKimiScan && !retryOmpScan && !retryDshScan && previousMeta.version === SIDECAR_VERSION && previousMeta.codex_usage_version === CODEX_USAGE_VERSION &&
|
|
2280
2356
|
Number.isFinite(checkedAt) &&
|
|
2281
2357
|
Date.now() - checkedAt < Math.max(0, Number(cacheTtlMs) || 0)
|
|
2282
2358
|
) {
|
|
2283
|
-
return
|
|
2359
|
+
return readSidecarWithMeta(sidecarPath, previousMeta);
|
|
2284
2360
|
}
|
|
2285
2361
|
} catch { /* first run */ }
|
|
2286
2362
|
}
|
|
@@ -2293,7 +2369,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
2293
2369
|
if (incompleteDirs.length > 0) {
|
|
2294
2370
|
warnIncompleteDiscovery(incompleteDirs);
|
|
2295
2371
|
if (!force && previousMeta?.version === SIDECAR_VERSION) {
|
|
2296
|
-
const cached =
|
|
2372
|
+
const cached = readSidecarWithMeta(sidecarPath, previousMeta);
|
|
2297
2373
|
Object.defineProperty(cached, "incompleteDirs", { value: incompleteDirs, enumerable: false });
|
|
2298
2374
|
return cached;
|
|
2299
2375
|
}
|
|
@@ -2317,9 +2393,21 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
2317
2393
|
// db + its WAL sidecars: the trio is one logical file for change detection.
|
|
2318
2394
|
...[...(discovered.zcode || []), ...(discovered.opencode || [])].flatMap((dbPath) => [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]),
|
|
2319
2395
|
]);
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2396
|
+
// The signature covers every discovered file's stat key, so an identical
|
|
2397
|
+
// signature proves the cached rows are current — refresh included. The one
|
|
2398
|
+
// exception: an explicit refresh re-parses after a previous run skipped a
|
|
2399
|
+
// transcript, so a transient read/parse failure can recover without waiting
|
|
2400
|
+
// for the file itself to change.
|
|
2401
|
+
const retrySkippedOnRecheck = recheck && Number(previousMeta?.skipped_files) > 0;
|
|
2402
|
+
if (!force && !retrySkippedOnRecheck && !retryZcodeScan && !retryOpencodeScan && !retryKimiScan && !retryOmpScan && !retryDshScan && previousMeta?.version === SIDECAR_VERSION && previousMeta.codex_usage_version === CODEX_USAGE_VERSION && previousMeta.signature === signature) {
|
|
2403
|
+
const cached = readSidecarWithMeta(sidecarPath, previousMeta);
|
|
2404
|
+
// A missing/corrupt sidecar reads as an empty list. The source signature
|
|
2405
|
+
// can still match, so only an explicit refresh used to notice the loss;
|
|
2406
|
+
// rebuild instead of returning an empty cache.
|
|
2407
|
+
if (!cached.sidecarReadFailed) {
|
|
2408
|
+
await writeAtomic(metaPath, `${JSON.stringify({ ...previousMeta, checked_at: new Date().toISOString() })}\n`);
|
|
2409
|
+
return cached;
|
|
2410
|
+
}
|
|
2323
2411
|
}
|
|
2324
2412
|
|
|
2325
2413
|
const previousRows = !force && previousMeta?.version === SIDECAR_VERSION
|
|
@@ -2465,6 +2553,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
2465
2553
|
signature,
|
|
2466
2554
|
generated_at: generatedAt,
|
|
2467
2555
|
checked_at: generatedAt,
|
|
2556
|
+
skipped_files: skippedFiles,
|
|
2468
2557
|
files: nextFiles,
|
|
2469
2558
|
})}\n`);
|
|
2470
2559
|
await fsp.rm(incompleteZcodePath, { force: true });
|
|
@@ -2490,17 +2579,22 @@ function buildSessionAnalytics(options = {}) {
|
|
|
2490
2579
|
const normalizedOptions = options && typeof options === "object" ? options : {};
|
|
2491
2580
|
const home = path.resolve(String(normalizedOptions.home || os.homedir()));
|
|
2492
2581
|
const force = Boolean(normalizedOptions.force);
|
|
2582
|
+
const recheck = Boolean(normalizedOptions.recheck);
|
|
2493
2583
|
const existing = sessionAnalyticsBuilds.get(home);
|
|
2494
2584
|
// Joining an in-flight build is only correct when that build is at least as
|
|
2495
2585
|
// thorough as what this caller asked for. A refresh that landed while a plain
|
|
2496
2586
|
// build was running used to be handed the un-refreshed result: the spinner
|
|
2497
2587
|
// stopped and nothing had actually been re-scanned. Chain instead of joining
|
|
2498
2588
|
// — never run two scans at once, they race the atomic sidecar write.
|
|
2499
|
-
if (existing && (!force || existing.force)) return existing.promise;
|
|
2589
|
+
if (existing && (!force && !recheck || existing.force || (recheck && existing.recheck))) return existing.promise;
|
|
2500
2590
|
|
|
2501
|
-
const run = () => buildSessionAnalyticsInternal({ ...normalizedOptions, home, force });
|
|
2591
|
+
const run = () => buildSessionAnalyticsInternal({ ...normalizedOptions, home, force, recheck });
|
|
2502
2592
|
const promise = existing ? existing.promise.then(run, run) : run();
|
|
2503
|
-
const entry = {
|
|
2593
|
+
const entry = {
|
|
2594
|
+
promise,
|
|
2595
|
+
force: force || Boolean(existing?.force),
|
|
2596
|
+
recheck: recheck || Boolean(existing?.recheck),
|
|
2597
|
+
};
|
|
2504
2598
|
sessionAnalyticsBuilds.set(home, entry);
|
|
2505
2599
|
const clear = () => {
|
|
2506
2600
|
if (sessionAnalyticsBuilds.get(home) === entry) sessionAnalyticsBuilds.delete(home);
|
|
@@ -2531,7 +2625,9 @@ function withinDayRange(row, from, to, context) {
|
|
|
2531
2625
|
const startDay = sessionUsageDay(row?.started_at || row?.ended_at || "", context);
|
|
2532
2626
|
const endDay = sessionUsageDay(row?.ended_at || row?.started_at || "", context);
|
|
2533
2627
|
if (from && (!endDay || endDay < from)) return false;
|
|
2534
|
-
|
|
2628
|
+
// A row whose only timestamp is unreadable keeps the old client's tolerant
|
|
2629
|
+
// behavior: it is dropped only when its known end day precedes the window.
|
|
2630
|
+
if (to && startDay && startDay > to) return false;
|
|
2535
2631
|
return true;
|
|
2536
2632
|
}
|
|
2537
2633
|
|
|
@@ -2543,16 +2639,39 @@ function scopeSessionUsage(row, from, to, context) {
|
|
|
2543
2639
|
});
|
|
2544
2640
|
const tokens = emptyTotals();
|
|
2545
2641
|
const byModel = new Map();
|
|
2642
|
+
// Grok reports its own cost per turn; keep the in-range subset so a single
|
|
2643
|
+
// day of a multi-day session does not inherit the lifetime total.
|
|
2644
|
+
let bucketProviderCost = 0;
|
|
2645
|
+
let bucketProviderCostEvents = 0;
|
|
2546
2646
|
for (const bucket of buckets) {
|
|
2647
|
+
if (Number.isFinite(Number(bucket.provider_cost_usd))) {
|
|
2648
|
+
bucketProviderCost += Number(bucket.provider_cost_usd);
|
|
2649
|
+
bucketProviderCostEvents += 1;
|
|
2650
|
+
}
|
|
2547
2651
|
addTotals(tokens, bucket);
|
|
2548
|
-
const
|
|
2652
|
+
const previousModelUsage = Array.isArray(row.model_usage)
|
|
2653
|
+
? row.model_usage.find((usage) => usage.model === bucket.model)
|
|
2654
|
+
: null;
|
|
2655
|
+
// A record with no per-model breakdown reports exactly one model: its own
|
|
2656
|
+
// label (that is what toSessionBrowserRow emits unscoped). Attribute its
|
|
2657
|
+
// buckets to that label so the range view and the lifetime facets agree on
|
|
2658
|
+
// a model name the filter can actually match.
|
|
2659
|
+
const modelName = previousModelUsage ? previousModelUsage.model : (row.model || bucket.model);
|
|
2660
|
+
const model = byModel.get(modelName) || {
|
|
2661
|
+
...(previousModelUsage || {}),
|
|
2662
|
+
model: modelName,
|
|
2663
|
+
...Object.fromEntries(MODEL_USAGE_SUM_FIELDS.filter((field) => field !== "edit_turns").map((field) => [field, 0])),
|
|
2664
|
+
usage_events: 0,
|
|
2665
|
+
};
|
|
2549
2666
|
addTotals(model, bucket);
|
|
2550
2667
|
mergeUsageMetadata(model, bucket);
|
|
2551
2668
|
if (!bucket.usage_events) model.usage_events += 1;
|
|
2552
|
-
byModel.set(
|
|
2669
|
+
byModel.set(modelName, model);
|
|
2553
2670
|
}
|
|
2671
|
+
const providerScoped = row.cost_source === "provider_reported" && bucketProviderCostEvents > 0;
|
|
2554
2672
|
return repriceSessionRecord({ ...row, tokens, total_tokens: tokens.total_tokens,
|
|
2555
2673
|
model_usage: [...byModel.values()], usage_buckets: buckets, cost_usd: 0,
|
|
2674
|
+
...(providerScoped ? { provider_cost_usd: bucketProviderCost } : {}),
|
|
2556
2675
|
lifetime_total_tokens: row.total_tokens, lifetime_cost_usd: row.cost_usd });
|
|
2557
2676
|
}
|
|
2558
2677
|
|
|
@@ -2761,7 +2880,21 @@ function resumeCommandFor(source, sessionId) {
|
|
|
2761
2880
|
return null;
|
|
2762
2881
|
}
|
|
2763
2882
|
|
|
2764
|
-
|
|
2883
|
+
// The list page reads a model name plus a token/cost/performance triple per
|
|
2884
|
+
// model row; the other counters in the dense 44-field model_usage row are not
|
|
2885
|
+
// read by the dashboard at all. Keeping this projection here (rather than
|
|
2886
|
+
// deleting fields at the HTTP layer) guarantees the shape stays declared by
|
|
2887
|
+
// dashboard/src/lib/sessions-api.ts.
|
|
2888
|
+
const BROWSER_MODEL_USAGE_FIELDS = ["model", "total_tokens", "cost_usd", "performance", "pricing"];
|
|
2889
|
+
|
|
2890
|
+
function projectModelUsageForBrowser(rows, includeModelDetail) {
|
|
2891
|
+
if (includeModelDetail) return rows;
|
|
2892
|
+
return rows.map((row) => Object.fromEntries(
|
|
2893
|
+
BROWSER_MODEL_USAGE_FIELDS.filter((field) => row[field] !== undefined).map((field) => [field, row[field]]),
|
|
2894
|
+
));
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
function toSessionBrowserRow(row, { includeBuckets = true, includeModelDetail = true } = {}) {
|
|
2765
2898
|
const tokens = row.tokens && typeof row.tokens === "object" ? row.tokens : {};
|
|
2766
2899
|
return {
|
|
2767
2900
|
session_hash: row.session_hash,
|
|
@@ -2787,8 +2920,8 @@ function toSessionBrowserRow(row) {
|
|
|
2787
2920
|
// browser can show where a session ran and compose a resume command.
|
|
2788
2921
|
project_ref: row.project_ref || null,
|
|
2789
2922
|
model: row.model,
|
|
2790
|
-
model_usage: modelUsageForAggregation(row),
|
|
2791
|
-
...(Array.isArray(row.usage_buckets) ? { usage_buckets: row.usage_buckets } : {}),
|
|
2923
|
+
model_usage: projectModelUsageForBrowser(modelUsageForAggregation(row), includeModelDetail),
|
|
2924
|
+
...(includeBuckets && Array.isArray(row.usage_buckets) ? { usage_buckets: row.usage_buckets } : {}),
|
|
2792
2925
|
performance: normalizePerformance(row.performance),
|
|
2793
2926
|
started_at: row.started_at || null,
|
|
2794
2927
|
ended_at: row.ended_at || null,
|
|
@@ -3048,19 +3181,49 @@ function annotateThreadUsage(rows) {
|
|
|
3048
3181
|
return rows;
|
|
3049
3182
|
}
|
|
3050
3183
|
|
|
3184
|
+
// Distinct projects and models for the dashboard filter menus. Compute this
|
|
3185
|
+
// before applying the date window so a project/model with no rows in the
|
|
3186
|
+
// current range remains selectable (and can therefore be cleared again).
|
|
3187
|
+
function buildSessionBrowserFacets(rows) {
|
|
3188
|
+
const projects = new Set();
|
|
3189
|
+
const models = new Set();
|
|
3190
|
+
for (const row of rows || []) {
|
|
3191
|
+
if (row.project_key) projects.add(row.project_key);
|
|
3192
|
+
// Mirror what toSessionBrowserRow() emits for an unscoped request:
|
|
3193
|
+
// normalizeModelUsageRows() over the stored rows, else the row's own label.
|
|
3194
|
+
// Bucket-only models are deliberately excluded — an unscoped row reports
|
|
3195
|
+
// them as its label, so offering the bucket model would let the user filter
|
|
3196
|
+
// out the very row that introduced the option.
|
|
3197
|
+
const usageRows = normalizeModelUsageRows(row.model_usage);
|
|
3198
|
+
if (usageRows.length === 0) {
|
|
3199
|
+
if (typeof row.model === "string" && row.model) models.add(row.model);
|
|
3200
|
+
continue;
|
|
3201
|
+
}
|
|
3202
|
+
for (const usage of usageRows) {
|
|
3203
|
+
if (usage && typeof usage.model === "string" && usage.model) models.add(usage.model);
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
return {
|
|
3207
|
+
projects: [...projects].sort((a, b) => String(a).localeCompare(String(b))),
|
|
3208
|
+
models: [...models].sort((a, b) => String(a).localeCompare(String(b))),
|
|
3209
|
+
};
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3051
3212
|
// Local-only session list for the dashboard session browser. Unlike
|
|
3052
3213
|
// summarizeSessions (cloud/CSV safe), this retains session_id + project_ref so
|
|
3053
3214
|
// the UI can offer one-click resume. Callers must only expose it over the
|
|
3054
3215
|
// local API.
|
|
3055
|
-
function listSessionsForBrowser(sessions, { from = "", to = "", limit = 0, timeZone, offsetMinutes } = {}) {
|
|
3216
|
+
function listSessionsForBrowser(sessions, { from = "", to = "", limit = 0, includeBuckets = true, includeModelDetail = true, timeZone, offsetMinutes } = {}) {
|
|
3056
3217
|
const context = { timeZone, offsetMinutes };
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
.filter((row) => finite(row.total_tokens) > 0)
|
|
3218
|
+
// Only sessions that actually spent tokens are worth listing. This drops
|
|
3219
|
+
// two kinds of noise: non-session logs under ~/.claude/projects
|
|
3220
|
+
// (skill-injections.jsonl, journal.jsonl, …), and sessions abandoned before
|
|
3221
|
+
// the model replied. Both render as "unknown · 0 tokens · $0.00" rows with
|
|
3222
|
+
// no model, no cost and nothing to analyze.
|
|
3223
|
+
const merged = mergeSessionFragments(sessions)
|
|
3224
|
+
.filter((row) => finite(row.total_tokens) > 0);
|
|
3225
|
+
const facets = buildSessionBrowserFacets(merged);
|
|
3226
|
+
const filtered = annotateThreadUsage(merged
|
|
3064
3227
|
.filter((row) => withinDayRange(row, from, to, context))
|
|
3065
3228
|
.map((row) => scopeSessionUsage(row, from, to, context))
|
|
3066
3229
|
.filter((row) => !Array.isArray(row.usage_buckets) || finite(row.total_tokens) > 0));
|
|
@@ -3070,9 +3233,11 @@ function listSessionsForBrowser(sessions, { from = "", to = "", limit = 0, timeZ
|
|
|
3070
3233
|
return {
|
|
3071
3234
|
available: filtered.length > 0,
|
|
3072
3235
|
session_count: filtered.length,
|
|
3236
|
+
lifetime_session_count: merged.length,
|
|
3073
3237
|
returned_count: limited.length,
|
|
3074
3238
|
skipped_files: finite(sessions?.skippedFiles),
|
|
3075
|
-
|
|
3239
|
+
facets,
|
|
3240
|
+
sessions: limited.map((row) => toSessionBrowserRow(row, { includeBuckets, includeModelDetail })),
|
|
3076
3241
|
provenance: {
|
|
3077
3242
|
source: "local-session-log",
|
|
3078
3243
|
confidence: "observed",
|
package/src/lib/static-server.js
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
const fsPromises = require("node:fs/promises");
|
|
2
2
|
const path = require("node:path");
|
|
3
|
+
const zlib = require("node:zlib");
|
|
4
|
+
const { promisify } = require("node:util");
|
|
5
|
+
const { negotiateEncoding } = require("./accept-encoding");
|
|
6
|
+
|
|
7
|
+
const gzip = promisify(zlib.gzip);
|
|
8
|
+
const brotliCompress = promisify(zlib.brotliCompress);
|
|
9
|
+
const COMPRESSIBLE_EXTENSIONS = new Set([".html", ".js", ".mjs", ".css", ".json", ".svg", ".xml", ".txt", ".webmanifest"]);
|
|
10
|
+
const STATIC_COMPRESSION_CACHE_MAX = 16;
|
|
11
|
+
const STATIC_COMPRESSION_MAX_BYTES = 8 * 1024 * 1024;
|
|
12
|
+
const staticCompressionCache = new Map();
|
|
13
|
+
|
|
14
|
+
function sendNotAcceptable(res) {
|
|
15
|
+
const message = "Not Acceptable";
|
|
16
|
+
res.writeHead(406, {
|
|
17
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
18
|
+
"Content-Length": Buffer.byteLength(message),
|
|
19
|
+
"Vary": "Accept-Encoding",
|
|
20
|
+
});
|
|
21
|
+
res.end(message);
|
|
22
|
+
}
|
|
3
23
|
|
|
4
24
|
const MIME_TYPES = {
|
|
5
25
|
".html": "text/html; charset=utf-8",
|
|
@@ -22,11 +42,26 @@ const MIME_TYPES = {
|
|
|
22
42
|
".webmanifest": "application/manifest+json",
|
|
23
43
|
};
|
|
24
44
|
|
|
45
|
+
async function compressedAsset(filePath, stat, encoding, handle) {
|
|
46
|
+
const key = `${filePath}\0${stat.dev}\0${stat.ino}\0${stat.size}\0${stat.mtimeMs}\0${stat.ctimeMs}\0${encoding}`;
|
|
47
|
+
const cached = staticCompressionCache.get(key);
|
|
48
|
+
if (cached) return cached;
|
|
49
|
+
const source = await handle.readFile();
|
|
50
|
+
const body = encoding === "br"
|
|
51
|
+
? await brotliCompress(source, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 } })
|
|
52
|
+
: await gzip(source);
|
|
53
|
+
staticCompressionCache.set(key, body);
|
|
54
|
+
while (staticCompressionCache.size > STATIC_COMPRESSION_CACHE_MAX) {
|
|
55
|
+
staticCompressionCache.delete(staticCompressionCache.keys().next().value);
|
|
56
|
+
}
|
|
57
|
+
return body;
|
|
58
|
+
}
|
|
59
|
+
|
|
25
60
|
/**
|
|
26
61
|
* Serve a static file from baseDir. Returns true if served, false otherwise.
|
|
27
62
|
* For SPA: caller should fall back to index.html when this returns false.
|
|
28
63
|
*/
|
|
29
|
-
async function serveStaticFile(baseDir, pathname, res, { localRuntimeConfig = false } = {}) {
|
|
64
|
+
async function serveStaticFile(baseDir, pathname, res, { localRuntimeConfig = false, acceptEncoding = "" } = {}) {
|
|
30
65
|
const safePath = path.normalize(pathname).replace(/^(\.\.[/\\])+/, "");
|
|
31
66
|
const filePath = path.join(baseDir, safePath);
|
|
32
67
|
|
|
@@ -53,8 +88,17 @@ async function serveStaticFile(baseDir, pathname, res, { localRuntimeConfig = fa
|
|
|
53
88
|
const ext = path.extname(filePath).toLowerCase();
|
|
54
89
|
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
55
90
|
const isHtml = ext === ".html";
|
|
91
|
+
const negotiation = negotiateEncoding(acceptEncoding);
|
|
92
|
+
if (!negotiation.encoding && !negotiation.identityAllowed) {
|
|
93
|
+
sendNotAcceptable(res);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
56
96
|
|
|
57
97
|
if (isHtml && localRuntimeConfig && path.basename(filePath) === "index.html") {
|
|
98
|
+
if (!negotiation.identityAllowed) {
|
|
99
|
+
sendNotAcceptable(res);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
58
102
|
const source = await handle.readFile("utf8");
|
|
59
103
|
const content = source.replace(/<head(?:\s[^>]*)?>/i,
|
|
60
104
|
"$&\n<script>window.__TOKENTRACKER_RUNTIME_CONFIG__={configurationError:\"runtime_config_unavailable\"};</script>\n<script src=\"/api/runtime-config.js\"></script>");
|
|
@@ -66,13 +110,41 @@ async function serveStaticFile(baseDir, pathname, res, { localRuntimeConfig = fa
|
|
|
66
110
|
|
|
67
111
|
if (clientGone) return true;
|
|
68
112
|
|
|
113
|
+
const encoding = negotiation.encoding;
|
|
114
|
+
if (encoding && COMPRESSIBLE_EXTENSIONS.has(ext) && stat.size > 1024 && stat.size <= STATIC_COMPRESSION_MAX_BYTES) {
|
|
115
|
+
let compressed = null;
|
|
116
|
+
try {
|
|
117
|
+
compressed = await compressedAsset(filePath, stat, encoding, handle);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.warn("[static-server] compression failed, serving identity:", error?.message || error);
|
|
120
|
+
}
|
|
121
|
+
if (clientGone) return true;
|
|
122
|
+
if (compressed) {
|
|
123
|
+
res.writeHead(200, {
|
|
124
|
+
"Content-Type": contentType,
|
|
125
|
+
"Content-Length": compressed.length,
|
|
126
|
+
"Content-Encoding": encoding,
|
|
127
|
+
"Vary": "Accept-Encoding",
|
|
128
|
+
"Cache-Control": isHtml ? "no-cache" : "public, max-age=31536000, immutable",
|
|
129
|
+
});
|
|
130
|
+
res.end(compressed);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!negotiation.identityAllowed) {
|
|
136
|
+
sendNotAcceptable(res);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
69
140
|
res.writeHead(200, {
|
|
70
141
|
"Content-Type": contentType,
|
|
71
142
|
"Content-Length": stat.size,
|
|
143
|
+
"Vary": "Accept-Encoding",
|
|
72
144
|
"Cache-Control": isHtml ? "no-cache" : "public, max-age=31536000, immutable",
|
|
73
145
|
});
|
|
74
146
|
|
|
75
|
-
stream = handle.createReadStream();
|
|
147
|
+
stream = handle.createReadStream({ start: 0 });
|
|
76
148
|
handle = null; // The stream owns and closes this descriptor.
|
|
77
149
|
stream.on("error", (error) => res.destroy(error));
|
|
78
150
|
stream.pipe(res);
|