tokentracker-cli 1.3.4 → 1.3.6
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 +5 -5
- package/README.ja.md +3 -3
- package/README.ko.md +3 -3
- package/README.md +6 -5
- package/README.zh-CN.md +3 -3
- package/dashboard/dist/assets/{AchievementBadge-CKjtQp-U.js → AchievementBadge-CHDt55IP.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-DpOXrabE.js → AchievementsPage-DpbRHmb8.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-DiXyIoa0.js → AchievementsSection-Ck5CiX4P.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-CILTTkPF.js → BadgeDetailModal-BVPqlh0X.js} +1 -1
- package/dashboard/dist/assets/{BotAnimated-CrBHWiRi.js → BotAnimated-CVsv6kxT.js} +1 -1
- package/dashboard/dist/assets/{Card-DUItW3xI.js → Card-C9FjyAwh.js} +1 -1
- package/dashboard/dist/assets/{CloudCheckoutPage-C_t_PDlG.js → CloudCheckoutPage-ChkjId9l.js} +1 -1
- package/dashboard/dist/assets/{CloudPage-pvJUrhya.js → CloudPage-D1nrt3MG.js} +1 -1
- package/dashboard/dist/assets/{CloudUsageExport-DQbDwiyk.js → CloudUsageExport-jXdoiD0e.js} +1 -1
- package/dashboard/dist/assets/{CommandPalette-BvHilrbV.js → CommandPalette-DFnEeZzc.js} +1 -1
- package/dashboard/dist/assets/{CommunityStatsModal-CizxEYv-.js → CommunityStatsModal-CZDS8gPA.js} +1 -1
- package/dashboard/dist/assets/{ConfirmModal-h9nISVy7.js → ConfirmModal-DRhgBoGE.js} +1 -1
- package/dashboard/dist/assets/{Controls-BjdIonTX.js → Controls-BmT6-x_L.js} +1 -1
- package/dashboard/dist/assets/{DashboardPage-Bxryr4qp.js → DashboardPage-DfwwXZBo.js} +1 -1
- package/dashboard/dist/assets/{DateRangePopover-DpKh-0l8.js → DateRangePopover-DuqNLatr.js} +1 -1
- package/dashboard/dist/assets/{DevicePage-DUJR9hJC.js → DevicePage-BtI0bWyg.js} +1 -1
- package/dashboard/dist/assets/{DialogClose-BmVAmN-o.js → DialogClose-DjnC3P4V.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-yy_QHg7O.js +1 -0
- package/dashboard/dist/assets/{DialogTitle-BsaIbN8k.js → DialogTitle-B9NNSz5p.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-BpSt2P_W.js → FadeIn-fsVQmJGa.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-Cc_VHB0R.js → HeaderGithubStar-Cg-f1iZc.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-WFd21iP9.js → HoverTooltip-DRIRlzXH.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-C_mUPSRB.js → IpCheckPage-DaAubaJQ.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-DVR8ClZO.js → LandingPage-DjBGIjHk.js} +2 -2
- package/dashboard/dist/assets/LeaderboardPage-BkCXqhz7.js +2 -0
- package/dashboard/dist/assets/{LeaderboardProBadge-BjX4EFl2.js → LeaderboardProBadge-Ds-wiWcN.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardProfileModal-vbPMNn7W.js → LeaderboardProfileModal-X_rfSQT5.js} +4 -4
- package/dashboard/dist/assets/{LeaderboardProfilePage-CqClmuA-.js → LeaderboardProfilePage-rpl1Ozwj.js} +1 -1
- package/dashboard/dist/assets/LimitsPage-BUvDBmgD.js +9 -0
- package/dashboard/dist/assets/{LocalOnlyNotice-B6nDke_u.js → LocalOnlyNotice-LBH1DBrs.js} +1 -1
- package/dashboard/dist/assets/{LoginCard-C28QVjd7.js → LoginCard-B0vRJEcg.js} +1 -1
- package/dashboard/dist/assets/{LoginModal-Bx1btNhH.js → LoginModal-DdzgP43f.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-DVOGNGYI.js → LoginPage-B4VCQxzn.js} +1 -1
- package/dashboard/dist/assets/{PetPage-nOKryTde.js → PetPage-Cmf6aXc9.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-Ch6NLnu6.js → PopoverPopup-BzfELnIC.js} +1 -1
- package/dashboard/dist/assets/{ProviderIcon-BRY2br-g.js → ProviderIcon-ClHXNkQ0.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-ko1OQecQ.js → ResetPasswordPage-BeUpNt_6.js} +1 -1
- package/dashboard/dist/assets/{Select-Q8mS3_od.js → Select-BHEXdYHu.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-C1id8GDj.js → SelectItemText-aaQK_QIt.js} +1 -1
- package/dashboard/dist/assets/{SelfHostPage-Dzb2bDiM.js → SelfHostPage-ClNhKnlP.js} +1 -1
- package/dashboard/dist/assets/{ServiceStatusPage-ewFwz29J.js → ServiceStatusPage-Byd_s6FS.js} +1 -1
- package/dashboard/dist/assets/SessionsPage-u3_b4PO7.js +3 -0
- package/dashboard/dist/assets/SettingsPage-DweCAbsy.js +2 -0
- package/dashboard/dist/assets/{SkillsPage-CcG77tqU.js → SkillsPage-Bb-PVfux.js} +1 -1
- package/dashboard/dist/assets/{TokenGalaxy-D2HDrbQR.js → TokenGalaxy-CubA3PXu.js} +1 -1
- package/dashboard/dist/assets/{TooltipPopup-C_bF2ZwO.js → TooltipPopup-DMliWhoK.js} +1 -1
- package/dashboard/dist/assets/{TrendMonitor-BcyoN5ns.js → TrendMonitor-as8idaOy.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-BirK0U_N.js → WidgetsPage-DDPR4v0R.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-thsXBuw6.js → WrappedPage-zZzgYz6s.js} +1 -1
- package/dashboard/dist/assets/{__vite-browser-external-Bq8nj-JV.js → __vite-browser-external-DWN9Fb-H.js} +12 -12
- package/dashboard/dist/assets/__vite-browser-external-LpFw4zXh.css +1 -0
- package/dashboard/dist/assets/agent-logos-CoNhCm9-.js +1 -0
- package/dashboard/dist/assets/{arrow-left-c_NOSfoq.js → arrow-left-C5hDp7fc.js} +1 -1
- package/dashboard/dist/assets/{arrow-right-CP7drKCT.js → arrow-right-DEvMmSiA.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-DQ6QfCpo.js → arrow-up-right-DxY0neJ4.js} +1 -1
- package/dashboard/dist/assets/{calendar-days-D_pxFQvT.js → calendar-days-bzh7-lab.js} +1 -1
- package/dashboard/dist/assets/{check-HAS31kTT.js → check-CSZvcC3X.js} +1 -1
- package/dashboard/dist/assets/{chevron-down-BIMuk6oN.js → chevron-down-CsiGcG0G.js} +1 -1
- package/dashboard/dist/assets/{cloud-checkout-COmDwrUT.js → cloud-checkout-z_eDVwm6.js} +1 -1
- package/dashboard/dist/assets/{composite-kLwqpqEk.js → composite-Cq0CmNAv.js} +1 -1
- package/dashboard/dist/assets/{download-9RgsZIiH.js → download-DqLpubIF.js} +1 -1
- package/dashboard/dist/assets/{external-link-C_gYjHGF.js → external-link-B8E8tbB3.js} +1 -1
- package/dashboard/dist/assets/{flame-BSsCvgT8.js → flame-CrJDlnro.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-N22Wqv1X.js → format-tokens-DPIGGOlt.js} +1 -1
- package/dashboard/dist/assets/gift-CCp4Hh50.js +1 -0
- package/dashboard/dist/assets/{host-mode-JzrhHX7F.js → host-mode-xzpMLcwP.js} +1 -1
- package/dashboard/dist/assets/{icons-pK99mIMv.js → icons-CbB9Y-eV.js} +1 -1
- package/dashboard/dist/assets/{index-BhviBroj.js → index-BiK5-tQp.js} +1 -1
- package/dashboard/dist/assets/{index-CmD84EJi.js → index-DX7nYrHb.js} +1 -1
- package/dashboard/dist/assets/{index-Dk43KNa6.js → index-zuC7FU-t.js} +1 -1
- package/dashboard/dist/assets/{info-D-RLFnSK.js → info-B3NKYwWG.js} +1 -1
- package/dashboard/dist/assets/{link-2-BiKXW9mV.js → link-2-BRc8d8M9.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-KVUaEKV2.js → loader-circle-Cm5Nsgp-.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-5NK8j3mT.js → modifiers.esm-CYLYBECB.js} +1 -1
- package/dashboard/dist/assets/module-BNnaKM__.js +3 -0
- package/dashboard/dist/assets/{monitor-smartphone-u9yr-_Es.js → monitor-smartphone--IZ4D84F.js} +1 -1
- package/dashboard/dist/assets/{play-D0MaDCOx.js → play-CUrMOLtq.js} +1 -1
- package/dashboard/dist/assets/{plus-DTJVtoAJ.js → plus-0hrCDl4r.js} +1 -1
- package/dashboard/dist/assets/{provider-display-YhtG0I9Y.js → provider-display-CNK0FN3Q.js} +1 -1
- package/dashboard/dist/assets/{react-gIVxucOB.js → react-CCNSVHEA.js} +1 -1
- package/dashboard/dist/assets/{safePolygon-C_sPnJ1L.js → safePolygon-DsolkMUX.js} +1 -1
- package/dashboard/dist/assets/{search-DdQRwFYV.js → search-DYmYb9xY.js} +1 -1
- package/dashboard/dist/assets/{server-M0rmbGWy.js → server-CYcqzdSW.js} +1 -1
- package/dashboard/dist/assets/{shield-check-9-sqQKdD.js → shield-check-5zABLIr6.js} +1 -1
- package/dashboard/dist/assets/{skills-api-B0qBWnuK.js → skills-api-CD-ojBGj.js} +1 -1
- package/dashboard/dist/assets/{sortable.esm-iqbm7x5D.js → sortable.esm-BCn2Ms6R.js} +1 -1
- package/dashboard/dist/assets/{store-DstuWd4a.js → store-B22kwLs0.js} +1 -1
- package/dashboard/dist/assets/{terminal-Dz0MxHNG.js → terminal-CTv-oTU2.js} +1 -1
- package/dashboard/dist/assets/{trash-DBMFOFVz.js → trash-C5MAh3GO.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-CacbAcUR.js → use-community-stats-BrgrxUD2.js} +1 -1
- package/dashboard/dist/assets/{use-latest-request-guard-nBuQVZOR.js → use-latest-request-guard-B1_rbvgH.js} +1 -1
- package/dashboard/dist/assets/use-limits-display-prefs-BOk5f-HM.js +1 -0
- package/dashboard/dist/assets/{use-native-settings-EAqAFx2x.js → use-native-settings-B2gc_m9W.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list-DGLjjU6Q.js → use-ordered-list-N3sGpJ0u.js} +1 -1
- package/dashboard/dist/assets/{use-pet-settings-C5MzWHE7.js → use-pet-settings-sWFXgR3m.js} +2 -2
- package/dashboard/dist/assets/use-reduced-motion-C3MBYVqm.js +1 -0
- package/dashboard/dist/assets/{use-transform-C25eCuz_.js → use-transform-BsaLmw46.js} +1 -1
- package/dashboard/dist/assets/use-usage-limits-wL7v-S0r.js +1 -0
- package/dashboard/dist/assets/{useCurrency-t3PYQi1l.js → useCurrency-CbCnjf-N.js} +1 -1
- package/dashboard/dist/assets/{usePositioner-C59mj6kG.js → usePositioner-MepYlL0A.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-C0xQsBGf.js → useTokenFormat-Bq-rwuKa.js} +1 -1
- package/dashboard/dist/assets/{zap-CyH0tvKM.js → zap-1ah5e11g.js} +1 -1
- package/dashboard/dist/index.html +14 -12
- package/dashboard/dist/ip-check.html +2 -2
- package/dashboard/dist/leaderboard.html +2 -2
- package/dashboard/dist/llms.txt +2 -2
- package/dashboard/dist/share.html +2 -2
- package/package.json +2 -2
- package/src/cli.js +1 -1
- package/src/commands/init.js +43 -1
- package/src/commands/status.js +27 -0
- package/src/commands/sync.js +54 -4
- package/src/commands/uninstall.js +19 -0
- package/src/lib/claude-config.js +47 -15
- package/src/lib/codex-rollout-parser.js +11 -3
- package/src/lib/command-runner.js +15 -1
- package/src/lib/cursor-config.js +2 -1
- package/src/lib/gemini-config.js +3 -2
- package/src/lib/local-api.js +13 -3
- package/src/lib/notify-command.js +38 -0
- package/src/lib/openclaw-session-plugin.js +47 -14
- package/src/lib/passive-mode.js +12 -0
- package/src/lib/pricing/curated-overrides.json +42 -5
- package/src/lib/pricing/matcher.js +14 -5
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +199 -44
- package/src/lib/session-analytics.js +653 -60
- package/src/lib/session-performance.js +24 -1
- package/src/lib/usage-limits.js +183 -20
- package/src/lib/zcode-limits.js +218 -18
- package/src/lib/zcode-reset-credits.js +66 -0
- package/dashboard/dist/assets/DialogDescription-C0Cgq67g.js +0 -1
- package/dashboard/dist/assets/LeaderboardPage-D8ydzVpn.js +0 -2
- package/dashboard/dist/assets/LimitsPage-DaRggHge.js +0 -8
- package/dashboard/dist/assets/SessionsPage-C6Y8kbnM.js +0 -3
- package/dashboard/dist/assets/SettingsPage-nztCXEPs.js +0 -2
- package/dashboard/dist/assets/__vite-browser-external-BwmeEfv7.css +0 -1
- package/dashboard/dist/assets/agent-logos-CK6SzhI7.js +0 -1
- package/dashboard/dist/assets/module-WSx8viXK.js +0 -3
- package/dashboard/dist/assets/use-limits-display-prefs-fqg8hs6b.js +0 -1
- package/dashboard/dist/assets/use-reduced-motion-DK2oZ2uC.js +0 -1
- package/dashboard/dist/assets/use-usage-limits-BhqnEai3.js +0 -1
|
@@ -28,10 +28,12 @@ const fsp = require("node:fs/promises");
|
|
|
28
28
|
const os = require("node:os");
|
|
29
29
|
const path = require("node:path");
|
|
30
30
|
const readline = require("node:readline");
|
|
31
|
-
const { listClaudeProjectFiles, listRolloutFilesDeep, claudeMessageDedupKey, normalizeClaudeUsage } = require("./rollout");
|
|
31
|
+
const { listClaudeProjectFiles, listRolloutFilesDeep, claudeMessageDedupKey, normalizeClaudeUsage, readZcodeDbMessages, coerceEpochMs, normalizeOpencodeModelFields, deriveOpencodeMessageFingerprint } = require("./rollout");
|
|
32
32
|
const { parseCodexRolloutFile } = require("./codex-rollout-parser");
|
|
33
33
|
const { computeRowCost, getModelPricingInfo } = require("./pricing");
|
|
34
|
-
const { normalizePerformance, mergePerformance, createClaudePerformanceCollector, createCodexPerformanceCollector } = require("./session-performance");
|
|
34
|
+
const { normalizePerformance, mergePerformance, createClaudePerformanceCollector, createCodexPerformanceCollector, createZcodePerformanceCollector } = require("./session-performance");
|
|
35
|
+
const { readSqliteJsonRows } = require("./sqlite-reader");
|
|
36
|
+
const { resolveZcodeNativeDbPath } = require("./install-resolver");
|
|
35
37
|
const { USD_TICKS_PER_USD, normalizeGrokUsage } = require("./grok-usage");
|
|
36
38
|
const wsl = require("./wsl-probe");
|
|
37
39
|
const {
|
|
@@ -70,7 +72,16 @@ const {
|
|
|
70
72
|
// long-context subset) and folds delivery signals into the token parser's
|
|
71
73
|
// single pass instead of parsing every Codex file twice.
|
|
72
74
|
// v14 adds request-level estimated performance and Claude model usage.
|
|
73
|
-
|
|
75
|
+
// v15/v16 tighten Codex delivery-signal parsing (uncommented bump).
|
|
76
|
+
// v17 adds ZCode sessions from ~/.zcode/cli/db/db.sqlite — one SQLite db
|
|
77
|
+
// scanning to many per-session rows — so every cached row is rebuilt.
|
|
78
|
+
// v18 corrects ZCode project keys and request timestamp units, and invalidates
|
|
79
|
+
// inventories that earlier builds may have cached after a failed database read.
|
|
80
|
+
// v19 scans both ZCode installs as one deduplicated inventory and separates
|
|
81
|
+
// provider retries from repeated user requests.
|
|
82
|
+
// v20 also invalidates unchanged-stat caches after an observed database lock.
|
|
83
|
+
// v21 retains the request count when several ZCode events share a minute.
|
|
84
|
+
const SIDECAR_VERSION = 21;
|
|
74
85
|
const EDIT_TOOLS = new Set([
|
|
75
86
|
"apply_patch",
|
|
76
87
|
"edit",
|
|
@@ -123,12 +134,23 @@ function addTotals(target, delta) {
|
|
|
123
134
|
for (const key of ["input_tokens", "cached_input_tokens", "cache_creation_input_tokens", "output_tokens", "reasoning_output_tokens", "total_tokens"]) {
|
|
124
135
|
target[key] = finite(target[key]) + finite(delta?.[key]);
|
|
125
136
|
}
|
|
137
|
+
for (const key of MODEL_USAGE_SUM_FIELDS) {
|
|
138
|
+
if (!/^(?:long_context_|priority_)|^(?:usage_events|rerouted_usage_events)$/.test(key)) continue;
|
|
139
|
+
if (delta?.[key]) target[key] = finite(target[key]) + finite(delta[key]);
|
|
140
|
+
}
|
|
126
141
|
}
|
|
127
142
|
|
|
128
143
|
function emptyTotals() {
|
|
129
144
|
return tokenTotals({});
|
|
130
145
|
}
|
|
131
146
|
|
|
147
|
+
function mergeUsageMetadata(target, usage) {
|
|
148
|
+
for (const field of ["selected_models", "reroute_reasons"]) {
|
|
149
|
+
if (Array.isArray(usage[field])) target[field] = [...new Set([...(target[field] || []), ...usage[field]])];
|
|
150
|
+
}
|
|
151
|
+
if (usage.model_attribution === "effective") target.model_attribution = "effective";
|
|
152
|
+
}
|
|
153
|
+
|
|
132
154
|
const MODEL_USAGE_SUM_FIELDS = [
|
|
133
155
|
"input_tokens",
|
|
134
156
|
"cached_input_tokens",
|
|
@@ -241,6 +263,11 @@ function serializeSessionRecord(row) {
|
|
|
241
263
|
}
|
|
242
264
|
|
|
243
265
|
function repriceSessionRecord(record) {
|
|
266
|
+
if (Array.isArray(record.usage_buckets)) {
|
|
267
|
+
record.usage_buckets = record.usage_buckets.map((bucket) => ({
|
|
268
|
+
...bucket, cost_usd: computeRowCost({ source: record.source, ...bucket }),
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
244
271
|
const modelUsage = normalizeModelUsageRows(record?.model_usage);
|
|
245
272
|
if (modelUsage.length > 0) {
|
|
246
273
|
let hasUnpricedUsage = false;
|
|
@@ -469,6 +496,19 @@ function finalizeRecord(record) {
|
|
|
469
496
|
return record;
|
|
470
497
|
}
|
|
471
498
|
|
|
499
|
+
function sessionStorageUsage(filePaths) {
|
|
500
|
+
let bytes = 0;
|
|
501
|
+
const allocated = process.platform !== "win32";
|
|
502
|
+
for (const file of new Set(filePaths)) {
|
|
503
|
+
try {
|
|
504
|
+
const stat = fs.statSync(file);
|
|
505
|
+
if (!stat.isFile()) continue;
|
|
506
|
+
bytes += allocated && Number.isFinite(stat.blocks) ? stat.blocks * 512 : stat.size;
|
|
507
|
+
} catch { /* deleted or unreadable while scanning */ }
|
|
508
|
+
}
|
|
509
|
+
return { session_store_bytes: bytes, session_store_metric: allocated ? "allocated" : "logical" };
|
|
510
|
+
}
|
|
511
|
+
|
|
472
512
|
function readableSessionPaths(filePath) {
|
|
473
513
|
const candidates = (Array.isArray(filePath) ? filePath : [filePath]).filter(Boolean);
|
|
474
514
|
const readable = candidates.filter((value) => sessionFileStatKey(value));
|
|
@@ -623,6 +663,7 @@ async function scanClaudeSession(filePath) {
|
|
|
623
663
|
// wrote one — the UI then falls back to the project name.
|
|
624
664
|
title: aiTitle,
|
|
625
665
|
source: "claude",
|
|
666
|
+
...sessionStorageUsage(filePaths),
|
|
626
667
|
project_key: projectKey(cwd, primaryFilePath),
|
|
627
668
|
project_ref: cwd || null,
|
|
628
669
|
model,
|
|
@@ -735,6 +776,7 @@ async function scanCodexSession(filePath) {
|
|
|
735
776
|
const primaryFilePath = filePaths[0] || String(filePath || "");
|
|
736
777
|
const signalCollector = createCodexDeliverySignalCollector();
|
|
737
778
|
const performanceCollector = createCodexPerformanceCollector();
|
|
779
|
+
const usageBuckets = new Map();
|
|
738
780
|
const parsed = await parseCodexRolloutFile(filePaths, {
|
|
739
781
|
seenTokenEvents: new Set(),
|
|
740
782
|
collectBreakdowns: false,
|
|
@@ -743,7 +785,20 @@ async function scanCodexSession(filePath) {
|
|
|
743
785
|
signalCollector.consume(obj, model);
|
|
744
786
|
performanceCollector.consume(obj, model);
|
|
745
787
|
},
|
|
746
|
-
onUsage
|
|
788
|
+
onUsage(event) {
|
|
789
|
+
if (event.origin !== "compaction") performanceCollector.consumeUsage(event);
|
|
790
|
+
if (!event.delta || event.delta.total_tokens <= 0) return;
|
|
791
|
+
const ms = Date.parse(event.timestamp);
|
|
792
|
+
if (!Number.isFinite(ms)) return;
|
|
793
|
+
// Minute buckets preserve every current IANA local-midnight boundary
|
|
794
|
+
// and every integer tz_offset_minutes accepted by the local API.
|
|
795
|
+
const timestamp = new Date(Math.floor(ms / 60_000) * 60_000).toISOString();
|
|
796
|
+
const key = `${timestamp}\u0000${event.model}`;
|
|
797
|
+
const bucket = usageBuckets.get(key) || { timestamp, model: event.model || "unknown", ...emptyTotals() };
|
|
798
|
+
addTotals(bucket, event.delta);
|
|
799
|
+
mergeUsageMetadata(bucket, event.delta);
|
|
800
|
+
usageBuckets.set(key, bucket);
|
|
801
|
+
},
|
|
747
802
|
});
|
|
748
803
|
const signals = signalCollector.finish();
|
|
749
804
|
const performance = performanceCollector.finish();
|
|
@@ -798,10 +853,12 @@ async function scanCodexSession(filePath) {
|
|
|
798
853
|
// Local-only: stripped in summarizeSessions before any cloud/CSV export.
|
|
799
854
|
title,
|
|
800
855
|
source: "codex",
|
|
856
|
+
...sessionStorageUsage(filePaths),
|
|
801
857
|
project_key: projectKey(parsed.cwd, primaryFilePath),
|
|
802
858
|
project_ref: parsed.cwd || null,
|
|
803
859
|
model,
|
|
804
860
|
model_usage: modelUsage,
|
|
861
|
+
usage_buckets: [...usageBuckets.values()],
|
|
805
862
|
performance: performance.performance,
|
|
806
863
|
...signals.bounds,
|
|
807
864
|
turns: signals.turns || finite(parsed.turnCount),
|
|
@@ -1079,6 +1136,7 @@ async function scanGrokSession(filePath) {
|
|
|
1079
1136
|
// Local-only: stripped in summarizeSessions before any cloud/CSV export.
|
|
1080
1137
|
title,
|
|
1081
1138
|
source: "grok",
|
|
1139
|
+
...sessionStorageUsage([filePath, grokSummaryPathFor(filePath), grokSignalsPathFor(filePath)]),
|
|
1082
1140
|
project_key: projectKey(cwd, filePath),
|
|
1083
1141
|
project_ref: cwd || null,
|
|
1084
1142
|
model: model || "unknown",
|
|
@@ -1115,6 +1173,408 @@ async function scanGrokSession(filePath) {
|
|
|
1115
1173
|
});
|
|
1116
1174
|
}
|
|
1117
1175
|
|
|
1176
|
+
// ZCode (Z.ai's CLI, an OpenCode fork) keeps everything in one SQLite DB at
|
|
1177
|
+
// ~/.zcode/cli/db/db.sqlite: a `session` table (id/parent_id/directory/title),
|
|
1178
|
+
// a `model_usage` table (per-request counters) and OpenCode's legacy `message`
|
|
1179
|
+
// table (assistant messages with inclusive token snapshots). Billable tokens
|
|
1180
|
+
// come from readZcodeDbMessages() — the exact same reader, inclusive→disjoint
|
|
1181
|
+
// normalization and sub-agent provider blocklist the usage sync uses — so the
|
|
1182
|
+
// session browser and the usage card can never drift apart. The per-request
|
|
1183
|
+
// signal columns live only in model_usage, so they are read separately and
|
|
1184
|
+
// feed performance/counter estimates only, never token totals.
|
|
1185
|
+
function zcodeTableColumns(dbPath, table) {
|
|
1186
|
+
// throwOnReadFailure: a missing table answers pragma_table_info with an
|
|
1187
|
+
// empty row set (no error), so the only way to land in the reader's failure
|
|
1188
|
+
// path is a genuinely unreadable db — which must propagate, never read as
|
|
1189
|
+
// "no table" (see the throw in scanZcodeSessions below).
|
|
1190
|
+
const rows = readSqliteJsonRows(dbPath, `SELECT name FROM pragma_table_info('${table}')`, {
|
|
1191
|
+
label: "ZCode",
|
|
1192
|
+
timeout: 10_000,
|
|
1193
|
+
maxBuffer: 1024 * 1024,
|
|
1194
|
+
throwOnReadFailure: true,
|
|
1195
|
+
});
|
|
1196
|
+
return new Set((Array.isArray(rows) ? rows : []).map((row) => String(row?.name || "")));
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// Messages from readZcodeDbMessages already carry disjoint opencode-shaped
|
|
1200
|
+
// tokens ({ input, output, reasoning, cache.read, cache.write }); map them
|
|
1201
|
+
// onto the session pipeline's column names. tokenTotals() recomputes the sum.
|
|
1202
|
+
function zcodeMessageTotals(data) {
|
|
1203
|
+
const tokens = data?.tokens || {};
|
|
1204
|
+
const totals = tokenTotals({
|
|
1205
|
+
input_tokens: tokens.input,
|
|
1206
|
+
cached_input_tokens: tokens.cache?.read,
|
|
1207
|
+
cache_creation_input_tokens: tokens.cache?.write,
|
|
1208
|
+
output_tokens: tokens.output,
|
|
1209
|
+
reasoning_output_tokens: tokens.reasoning,
|
|
1210
|
+
});
|
|
1211
|
+
// The usage queue's total is input + output + cache_creation + cache_read +
|
|
1212
|
+
// reasoning_output (rollout.js addTotals); tokenTotals' generic total omits
|
|
1213
|
+
// reasoning. ZCode splits reasoning out of output, so fold it back in here or
|
|
1214
|
+
// the session browser undercounts every thinking request against the card.
|
|
1215
|
+
totals.total_tokens += totals.reasoning_output_tokens;
|
|
1216
|
+
return totals;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
const ZCODE_SIGNAL_REQUIRED_COLUMNS = [
|
|
1220
|
+
"session_id", "model_id", "provider_id", "status", "started_at", "id",
|
|
1221
|
+
"output_tokens", "duration_ms", "time_to_first_token_ms", "query_source",
|
|
1222
|
+
];
|
|
1223
|
+
|
|
1224
|
+
// One db yields many session rows, so unlike the per-file scanners this scan
|
|
1225
|
+
// returns an array (the entries loop in buildSessionAnalyticsInternal handles
|
|
1226
|
+
// both shapes). Errors on the db as a whole propagate: the caller counts the
|
|
1227
|
+
// file as skipped instead of caching an empty inventory as "no sessions".
|
|
1228
|
+
//
|
|
1229
|
+
// WSL dbs arrive as UNC paths (\\wsl$\…), and SQLite over the Plan 9 bridge
|
|
1230
|
+
// cannot take the locks even a read needs ("database is locked"). The usage
|
|
1231
|
+
// readers snapshot such paths query-by-query; the scan runs several queries
|
|
1232
|
+
// plus readZcodeDbMessages against one db, so snapshot once here and run the
|
|
1233
|
+
// whole scan (metadata, signals, turns, tools, messages) against the local
|
|
1234
|
+
// copy — otherwise the metadata query succeeds while the message read fails
|
|
1235
|
+
// on the same database.
|
|
1236
|
+
async function scanZcodeSessions(dbPaths) {
|
|
1237
|
+
const paths = [...new Set((Array.isArray(dbPaths) ? dbPaths : [dbPaths]).filter(Boolean))];
|
|
1238
|
+
const snapshots = [];
|
|
1239
|
+
const inputs = [];
|
|
1240
|
+
try {
|
|
1241
|
+
for (const dbPath of paths) {
|
|
1242
|
+
let effectiveDbPath = dbPath;
|
|
1243
|
+
if (wsl.isUncPath(dbPath)) {
|
|
1244
|
+
try {
|
|
1245
|
+
const snapshot = wsl.snapshotSqliteDb(dbPath);
|
|
1246
|
+
snapshots.push(snapshot);
|
|
1247
|
+
effectiveDbPath = snapshot.path;
|
|
1248
|
+
} catch (_error) { /* Direct read decides whether this UNC server supports SQLite. */ }
|
|
1249
|
+
}
|
|
1250
|
+
inputs.push(readZcodeSessionInputs(effectiveDbPath));
|
|
1251
|
+
}
|
|
1252
|
+
return buildZcodeSessionRecords(inputs, paths[0]);
|
|
1253
|
+
} finally {
|
|
1254
|
+
for (const snapshot of snapshots) snapshot.cleanup();
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/** Read metadata and numeric events from one database; real read failures propagate. */
|
|
1259
|
+
function readZcodeSessionInputs(dbPath) {
|
|
1260
|
+
const options = { label: "ZCode", timeout: 30_000, maxBuffer: 50 * 1024 * 1024, readOnly: true, throwOnReadFailure: true };
|
|
1261
|
+
const sessionColumns = zcodeTableColumns(dbPath, "session");
|
|
1262
|
+
if (!sessionColumns.has("id")) return { sessionRows: [], messages: [], requestRows: [], turnRows: [], toolRows: [] };
|
|
1263
|
+
const optionalColumns = ["parent_id", "directory", "title", "time_created", "time_updated"];
|
|
1264
|
+
const selectColumns = ["id", ...optionalColumns.filter((name) => sessionColumns.has(name))];
|
|
1265
|
+
const orderBy = sessionColumns.has("time_created") ? " ORDER BY time_created ASC, id ASC" : " ORDER BY id ASC";
|
|
1266
|
+
const sessionRows = readSqliteJsonRows(
|
|
1267
|
+
dbPath,
|
|
1268
|
+
`SELECT ${selectColumns.join(", ")} FROM session${orderBy}`,
|
|
1269
|
+
options,
|
|
1270
|
+
);
|
|
1271
|
+
const messages = readZcodeDbMessages(dbPath, { readOnly: true }, { propagateReadFailure: true });
|
|
1272
|
+
const modelUsageColumns = zcodeTableColumns(dbPath, "model_usage");
|
|
1273
|
+
const requestRows = ZCODE_SIGNAL_REQUIRED_COLUMNS.every((name) => modelUsageColumns.has(name))
|
|
1274
|
+
? readSqliteJsonRows(dbPath, `SELECT id, session_id, model_id, status, started_at, output_tokens, duration_ms, time_to_first_token_ms, query_source
|
|
1275
|
+
FROM model_usage
|
|
1276
|
+
WHERE status IN ('completed', 'error')
|
|
1277
|
+
AND trim(provider_id) != ''
|
|
1278
|
+
AND provider_id NOT LIKE '%anthropic%'
|
|
1279
|
+
AND provider_id NOT LIKE '%openai%'
|
|
1280
|
+
AND provider_id NOT LIKE '%google%'
|
|
1281
|
+
ORDER BY started_at ASC, id ASC`, options)
|
|
1282
|
+
: [];
|
|
1283
|
+
const turnColumns = zcodeTableColumns(dbPath, "turn_usage");
|
|
1284
|
+
const turnRows = turnColumns.has("session_id")
|
|
1285
|
+
? readSqliteJsonRows(dbPath, `SELECT session_id${turnColumns.has("turn_id") ? ", turn_id" : ""} FROM turn_usage`, options)
|
|
1286
|
+
: [];
|
|
1287
|
+
const toolColumns = zcodeTableColumns(dbPath, "tool_usage");
|
|
1288
|
+
const toolRows = ["session_id", "tool_name", "turn_id"].every((name) => toolColumns.has(name))
|
|
1289
|
+
? readSqliteJsonRows(dbPath, "SELECT session_id, turn_id, tool_name FROM tool_usage", options)
|
|
1290
|
+
: [];
|
|
1291
|
+
return { sessionRows, messages, requestRows, turnRows, toolRows };
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/** Merge only shared event identities, retaining both installs' distinct tails. */
|
|
1295
|
+
function uniqueZcodeRows(rows, keyFor) {
|
|
1296
|
+
const seen = new Set();
|
|
1297
|
+
return rows.filter((row) => {
|
|
1298
|
+
const key = keyFor(row);
|
|
1299
|
+
if (!key) return true;
|
|
1300
|
+
if (seen.has(key)) return false;
|
|
1301
|
+
seen.add(key);
|
|
1302
|
+
return true;
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function buildZcodeSessionRecords(inputs, displayPath) {
|
|
1307
|
+
const collect = (key) => inputs.flatMap((input) => input[key] || []);
|
|
1308
|
+
const eventKey = (row, sessionKey, idKey) => row?.[sessionKey] && row?.[idKey]
|
|
1309
|
+
? `${row[sessionKey]}\u0000${row[idKey]}` : null;
|
|
1310
|
+
const sessionRows = uniqueZcodeRows(collect("sessionRows"), (row) => row?.id);
|
|
1311
|
+
const fingerprintOwners = new Map();
|
|
1312
|
+
const forkCopies = new Set();
|
|
1313
|
+
const messages = uniqueZcodeRows(collect("messages"), (row) => eventKey(row, "sessionID", "id"))
|
|
1314
|
+
.filter((message) => {
|
|
1315
|
+
// Use the ledger's existing narrow fork-copy contract, including both
|
|
1316
|
+
// timestamps, all token columns, model and provider. Never dedup two
|
|
1317
|
+
// separate turns within one session by counters alone.
|
|
1318
|
+
const fingerprint = deriveOpencodeMessageFingerprint({
|
|
1319
|
+
msg: message.data, totals: zcodeMessageTotals(message.data), source: "zcode",
|
|
1320
|
+
});
|
|
1321
|
+
const owner = fingerprint && fingerprintOwners.get(fingerprint);
|
|
1322
|
+
if (owner && owner !== message.sessionID) {
|
|
1323
|
+
forkCopies.add(eventKey(message, "sessionID", "id"));
|
|
1324
|
+
return false;
|
|
1325
|
+
}
|
|
1326
|
+
if (fingerprint) fingerprintOwners.set(fingerprint, message.sessionID);
|
|
1327
|
+
return true;
|
|
1328
|
+
});
|
|
1329
|
+
const requestRows = uniqueZcodeRows(collect("requestRows"), (row) => eventKey(row, "session_id", "id"))
|
|
1330
|
+
.filter((row) => !forkCopies.has(eventKey(row, "session_id", "id")));
|
|
1331
|
+
const turnRows = uniqueZcodeRows(collect("turnRows"), (row) => eventKey(row, "session_id", "turn_id"));
|
|
1332
|
+
const toolRows = collect("toolRows");
|
|
1333
|
+
|
|
1334
|
+
const messagesBySession = new Map();
|
|
1335
|
+
// propagateReadFailure: a failed native-usage query must abort the scan (the
|
|
1336
|
+
// caller counts the db as skipped) rather than silently degrade to the
|
|
1337
|
+
// legacy tables and cache a zero-token inventory for those sessions.
|
|
1338
|
+
for (const message of messages) {
|
|
1339
|
+
const sessionId = String(message?.sessionID || "").trim();
|
|
1340
|
+
if (!sessionId) continue;
|
|
1341
|
+
let group = messagesBySession.get(sessionId);
|
|
1342
|
+
if (!group) {
|
|
1343
|
+
group = [];
|
|
1344
|
+
messagesBySession.set(sessionId, group);
|
|
1345
|
+
}
|
|
1346
|
+
group.push(message);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Request-level signals (never token totals). Only completed requests feed
|
|
1350
|
+
// duration-based estimates; error rows count toward error_count alone. The
|
|
1351
|
+
// provider filter mirrors isZcodeNativeMessage's blocklist (see rollout.js):
|
|
1352
|
+
// rows ZCode orchestrated through claude/codex/gemini sub-agents already
|
|
1353
|
+
// appear as their own provider's sessions and must not skew signals either.
|
|
1354
|
+
const signalsBySession = new Map();
|
|
1355
|
+
for (const row of requestRows) {
|
|
1356
|
+
const sessionId = String(row?.session_id || "").trim();
|
|
1357
|
+
if (!sessionId) continue;
|
|
1358
|
+
let signals = signalsBySession.get(sessionId);
|
|
1359
|
+
if (!signals) {
|
|
1360
|
+
signals = {
|
|
1361
|
+
collector: createZcodePerformanceCollector(),
|
|
1362
|
+
apiDurationMs: 0,
|
|
1363
|
+
compactionCount: 0,
|
|
1364
|
+
errorCount: 0,
|
|
1365
|
+
// Request wall-clock extremes from model_usage. Messages only carry
|
|
1366
|
+
// startedAt (the reader maps it to timeUpdated), so without these the
|
|
1367
|
+
// last generation's duration never reaches ended_at and a session's
|
|
1368
|
+
// final request reports a zero-length span.
|
|
1369
|
+
firstRequestIso: null,
|
|
1370
|
+
lastRequestEndIso: null,
|
|
1371
|
+
// [startMs, endMs] per request for the active-time union below.
|
|
1372
|
+
activeSpans: [],
|
|
1373
|
+
};
|
|
1374
|
+
signalsBySession.set(sessionId, signals);
|
|
1375
|
+
}
|
|
1376
|
+
// Match readZcodeNativeUsageMessages: databases may store epoch seconds
|
|
1377
|
+
// or milliseconds, but request spans and message bounds must share units.
|
|
1378
|
+
const requestStartMs = coerceEpochMs(row?.started_at);
|
|
1379
|
+
if (requestStartMs > 0) {
|
|
1380
|
+
const startIso = new Date(requestStartMs).toISOString();
|
|
1381
|
+
if (!signals.firstRequestIso || startIso < signals.firstRequestIso) signals.firstRequestIso = startIso;
|
|
1382
|
+
// duration_ms only exists on completed rows; an error request still
|
|
1383
|
+
// occupied its start instant, so its end falls back to the start.
|
|
1384
|
+
const durationMs = row.status === "completed" ? finite(row.duration_ms) : 0;
|
|
1385
|
+
const endIso = new Date(requestStartMs + durationMs).toISOString();
|
|
1386
|
+
if (!signals.lastRequestEndIso || endIso > signals.lastRequestEndIso) signals.lastRequestEndIso = endIso;
|
|
1387
|
+
signals.activeSpans.push([requestStartMs, requestStartMs + durationMs]);
|
|
1388
|
+
}
|
|
1389
|
+
if (row.status === "error") {
|
|
1390
|
+
signals.errorCount += 1;
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1393
|
+
if (row.query_source === "compact") signals.compactionCount += 1;
|
|
1394
|
+
signals.apiDurationMs += finite(row.duration_ms);
|
|
1395
|
+
signals.collector.consumeUsage({
|
|
1396
|
+
model: normalizeSessionModel(row.model_id) || "unknown",
|
|
1397
|
+
outputTokens: finite(row.output_tokens),
|
|
1398
|
+
durationMs: finite(row.duration_ms),
|
|
1399
|
+
timeToFirstTokenMs: typeof row.time_to_first_token_ms === "number" ? row.time_to_first_token_ms : undefined,
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
const turnsBySession = new Map();
|
|
1403
|
+
for (const row of turnRows) {
|
|
1404
|
+
const sessionId = String(row?.session_id || "").trim();
|
|
1405
|
+
if (!sessionId) continue;
|
|
1406
|
+
turnsBySession.set(sessionId, (turnsBySession.get(sessionId) || 0) + 1);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// ZCode writes Capitalized tool names (Edit/Write); EDIT_TOOLS is lowercase.
|
|
1410
|
+
// An edit turn is a turn that used an edit tool, matching the Grok definition.
|
|
1411
|
+
const editTurnsBySession = new Map();
|
|
1412
|
+
const editTurnIds = new Map();
|
|
1413
|
+
for (const row of toolRows) {
|
|
1414
|
+
if (!EDIT_TOOLS.has(String(row?.tool_name || "").toLowerCase())) continue;
|
|
1415
|
+
const sessionId = String(row?.session_id || "").trim();
|
|
1416
|
+
const turnId = String(row?.turn_id || "").trim();
|
|
1417
|
+
if (!sessionId || !turnId) continue;
|
|
1418
|
+
let turns = editTurnIds.get(sessionId);
|
|
1419
|
+
if (!turns) {
|
|
1420
|
+
turns = new Set();
|
|
1421
|
+
editTurnIds.set(sessionId, turns);
|
|
1422
|
+
}
|
|
1423
|
+
turns.add(turnId);
|
|
1424
|
+
}
|
|
1425
|
+
for (const [sessionId, turns] of editTurnIds) editTurnsBySession.set(sessionId, turns.size);
|
|
1426
|
+
|
|
1427
|
+
const records = [];
|
|
1428
|
+
for (const row of Array.isArray(sessionRows) ? sessionRows : []) {
|
|
1429
|
+
const sessionId = String(row?.id || "").trim();
|
|
1430
|
+
if (!sessionId) continue;
|
|
1431
|
+
const messages = messagesBySession.get(sessionId) || [];
|
|
1432
|
+
const tokens = emptyTotals();
|
|
1433
|
+
const modelUsageByModel = new Map();
|
|
1434
|
+
const bounds = emptyBounds();
|
|
1435
|
+
const usageBuckets = new Map();
|
|
1436
|
+
const messagePoints = [];
|
|
1437
|
+
let messageCwd = null;
|
|
1438
|
+
for (const message of messages) {
|
|
1439
|
+
const data = message?.data || {};
|
|
1440
|
+
// OpenCode v1 messages carry a flat modelID; v2 (session_message era)
|
|
1441
|
+
// nests it as model.id, and some forks wrote model as a plain string.
|
|
1442
|
+
// Resolve every shape through the shared normalizer so by-model
|
|
1443
|
+
// attribution and pricing match the usage sync on v2-layout databases.
|
|
1444
|
+
const model = normalizeSessionModel(normalizeOpencodeModelFields(data).modelId) || "unknown";
|
|
1445
|
+
const delta = zcodeMessageTotals(data);
|
|
1446
|
+
addTotals(tokens, delta);
|
|
1447
|
+
let usage = modelUsageByModel.get(model);
|
|
1448
|
+
if (!usage) {
|
|
1449
|
+
usage = { model, ...emptyTotals(), usage_events: 0 };
|
|
1450
|
+
modelUsageByModel.set(model, usage);
|
|
1451
|
+
}
|
|
1452
|
+
addTotals(usage, delta);
|
|
1453
|
+
usage.usage_events += 1;
|
|
1454
|
+
// Message timestamps are epoch-ms integers; updateBounds() only accepts
|
|
1455
|
+
// ISO strings and silently drops anything else.
|
|
1456
|
+
const timeUpdated = Number(message?.timeUpdated);
|
|
1457
|
+
if (Number.isFinite(timeUpdated) && timeUpdated > 0) {
|
|
1458
|
+
updateBounds(bounds, new Date(timeUpdated).toISOString());
|
|
1459
|
+
messagePoints.push(timeUpdated);
|
|
1460
|
+
// Minute buckets (same shape as the Codex scanner's) feed the from/to
|
|
1461
|
+
// day filter in scopeSessionUsage; without them a multi-day session
|
|
1462
|
+
// bills its whole lifetime to every day it touches.
|
|
1463
|
+
const timestamp = new Date(Math.floor(timeUpdated / 60_000) * 60_000).toISOString();
|
|
1464
|
+
const bucketKey = `${timestamp}\u0000${model}`;
|
|
1465
|
+
const bucket = usageBuckets.get(bucketKey) || { timestamp, model, ...emptyTotals(), usage_events: 0 };
|
|
1466
|
+
addTotals(bucket, delta);
|
|
1467
|
+
bucket.usage_events += 1;
|
|
1468
|
+
usageBuckets.set(bucketKey, bucket);
|
|
1469
|
+
}
|
|
1470
|
+
if (!messageCwd && typeof data.path?.cwd === "string" && data.path.cwd.trim()) {
|
|
1471
|
+
messageCwd = data.path.cwd.trim();
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
// Sessions without any message (claude-import migrations the import did
|
|
1475
|
+
// not rebuild, sessions abandoned before the first reply) keep the row's
|
|
1476
|
+
// own wall-clock span; they end up with zero tokens and the browser's
|
|
1477
|
+
// existing total_tokens > 0 filter drops them from the listing.
|
|
1478
|
+
if (messages.length === 0) {
|
|
1479
|
+
const created = Number(row?.time_created);
|
|
1480
|
+
const updated = Number(row?.time_updated);
|
|
1481
|
+
if (Number.isFinite(created) && created > 0) updateBounds(bounds, new Date(created).toISOString());
|
|
1482
|
+
if (Number.isFinite(updated) && updated > 0) updateBounds(bounds, new Date(updated).toISOString());
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
const signals = signalsBySession.get(sessionId) || null;
|
|
1486
|
+
if (signals) {
|
|
1487
|
+
// Widen the wall-clock span with model_usage request extremes: message
|
|
1488
|
+
// timestamps are request starts, so the final request's duration would
|
|
1489
|
+
// otherwise vanish from ended_at (a 10s request reads as a 0-length
|
|
1490
|
+
// span). Direct comparison instead of updateBounds() keeps active_ms
|
|
1491
|
+
// (idle-gap working time) driven by the interval union below.
|
|
1492
|
+
if (signals.firstRequestIso && (!bounds.started_at || signals.firstRequestIso < bounds.started_at)) {
|
|
1493
|
+
bounds.started_at = signals.firstRequestIso;
|
|
1494
|
+
}
|
|
1495
|
+
if (signals.lastRequestEndIso && (!bounds.ended_at || signals.lastRequestEndIso > bounds.ended_at)) {
|
|
1496
|
+
bounds.ended_at = signals.lastRequestEndIso;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
// Active time = the union of request execution intervals plus message
|
|
1500
|
+
// instants, with spans ≤ IDLE_GAP_MS apart bridged. updateBounds' gap
|
|
1501
|
+
// accumulator only measures message-start-to-message-start, which loses
|
|
1502
|
+
// the execution time of every request but the last one (two 10s requests
|
|
1503
|
+
// around a 1h pause must read 20s, not 10s); the union also collapses
|
|
1504
|
+
// parallel requests instead of counting them twice. Legacy-only sessions
|
|
1505
|
+
// contribute points alone, which degenerates to the same idle-gap span
|
|
1506
|
+
// the generic scanners produce.
|
|
1507
|
+
const spans = [...(signals ? signals.activeSpans : []), ...messagePoints.map((ms) => [ms, ms])]
|
|
1508
|
+
.filter(([startMs, endMs]) => Number.isFinite(startMs) && Number.isFinite(endMs))
|
|
1509
|
+
.sort((a, b) => a[0] - b[0]);
|
|
1510
|
+
let unionActiveMs = 0;
|
|
1511
|
+
let unionStart = null;
|
|
1512
|
+
let unionEnd = null;
|
|
1513
|
+
for (const [startMs, endMs] of spans) {
|
|
1514
|
+
if (unionStart === null) {
|
|
1515
|
+
unionStart = startMs;
|
|
1516
|
+
unionEnd = endMs;
|
|
1517
|
+
continue;
|
|
1518
|
+
}
|
|
1519
|
+
if (startMs <= unionEnd + IDLE_GAP_MS) {
|
|
1520
|
+
if (endMs > unionEnd) unionEnd = endMs;
|
|
1521
|
+
continue;
|
|
1522
|
+
}
|
|
1523
|
+
unionActiveMs += unionEnd - unionStart;
|
|
1524
|
+
unionStart = startMs;
|
|
1525
|
+
unionEnd = endMs;
|
|
1526
|
+
}
|
|
1527
|
+
if (unionStart !== null) unionActiveMs += unionEnd - unionStart;
|
|
1528
|
+
bounds.active_ms = unionActiveMs;
|
|
1529
|
+
const finished = signals ? signals.collector.finish() : { performance: normalizePerformance(), byModel: new Map() };
|
|
1530
|
+
const modelUsage = [...modelUsageByModel.values()].map((usage) => ({
|
|
1531
|
+
...usage,
|
|
1532
|
+
performance: finished.byModel.get(usage.model) || normalizePerformance(),
|
|
1533
|
+
}));
|
|
1534
|
+
const cwd = typeof row?.directory === "string" && row.directory.trim() ? row.directory.trim() : messageCwd;
|
|
1535
|
+
records.push(finalizeRecord({
|
|
1536
|
+
version: SIDECAR_VERSION,
|
|
1537
|
+
session_hash: sessionHash("zcode", sessionId),
|
|
1538
|
+
session_id: sessionId,
|
|
1539
|
+
// Local-only lineage: ZCode's own parent_id (subagent/workflow children).
|
|
1540
|
+
parent_session_id: typeof row?.parent_id === "string" && row.parent_id.trim() ? row.parent_id.trim() : null,
|
|
1541
|
+
// Local-only: stripped in summarizeSessions before any cloud/CSV export.
|
|
1542
|
+
title: cleanSessionTitle(row?.title),
|
|
1543
|
+
source: "zcode",
|
|
1544
|
+
// A database copied between Windows and POSIX retains its original cwd.
|
|
1545
|
+
project_key: projectKey(cwd && cwd.replace(/\\/g, "/"), displayPath),
|
|
1546
|
+
project_ref: cwd || null,
|
|
1547
|
+
model: modelUsage.length > 1 ? "mixed" : (modelUsage[0]?.model || "unknown"),
|
|
1548
|
+
model_usage: modelUsage,
|
|
1549
|
+
usage_buckets: [...usageBuckets.values()],
|
|
1550
|
+
...bounds,
|
|
1551
|
+
turns: turnsBySession.get(sessionId) || 0,
|
|
1552
|
+
edit_turns: editTurnsBySession.get(sessionId) || 0,
|
|
1553
|
+
// Provider/API retries are not repeated user requests. This numeric
|
|
1554
|
+
// metadata has no user-prompt repetition evidence.
|
|
1555
|
+
retry_turns: 0,
|
|
1556
|
+
tokens,
|
|
1557
|
+
usage_events: messages.length,
|
|
1558
|
+
usage_precision: messages.length > 0 ? "reported" : "unavailable",
|
|
1559
|
+
cost_source: "model_pricing",
|
|
1560
|
+
provider_cost_usd: null,
|
|
1561
|
+
model_calls: messages.length,
|
|
1562
|
+
api_duration_ms: signals ? signals.apiDurationMs : 0,
|
|
1563
|
+
error_count: signals ? signals.errorCount : 0,
|
|
1564
|
+
compaction_count: signals ? signals.compactionCount : 0,
|
|
1565
|
+
performance: finished.performance,
|
|
1566
|
+
provenance: {
|
|
1567
|
+
source: "local-session-log",
|
|
1568
|
+
confidence: "observed",
|
|
1569
|
+
retry_confidence: "unavailable",
|
|
1570
|
+
content_retained: false,
|
|
1571
|
+
usage: messages.length > 0 ? "zcode-db-messages" : "unavailable",
|
|
1572
|
+
},
|
|
1573
|
+
}));
|
|
1574
|
+
}
|
|
1575
|
+
return records;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1118
1578
|
// Multi-root discovery: on Windows a native install and a WSL one both count.
|
|
1119
1579
|
// `sync` already walks both Claude homes (src/commands/sync.js), so without this
|
|
1120
1580
|
// the WSL work lands in the token totals but is invisible in the session browser
|
|
@@ -1340,7 +1800,36 @@ async function discoverSessionFiles(home, env = process.env, deps = {}) {
|
|
|
1340
1800
|
const claude = allClaude.filter((filePaths) => !filePaths.some((filePath) => filePath
|
|
1341
1801
|
.split(path.sep)
|
|
1342
1802
|
.some((segment) => segment.endsWith(CLAUDE_MEM_OBSERVER_PROJECT_SUFFIX))));
|
|
1343
|
-
|
|
1803
|
+
// ZCode has no sessions directory tree — one SQLite db per install. Resolve
|
|
1804
|
+
// it like the usage sync does (src/commands/sync.js): native candidate first
|
|
1805
|
+
// (ZCODE_HOME, home, win32 APPDATA fallback), then the WSL install when the
|
|
1806
|
+
// WSL mode probes it, picked with the same mode priority (default wsl-first)
|
|
1807
|
+
// so the browser reads the same installs the usage card counts. "both"
|
|
1808
|
+
// preserves both paths; the scanner merges shared session/event identities.
|
|
1809
|
+
const zcodePlatform = deps.platform || process.platform;
|
|
1810
|
+
const zcodeNativeDbPath = (deps.resolveZcodeNativeDbPath || resolveZcodeNativeDbPath)({ home, env, platform: zcodePlatform });
|
|
1811
|
+
// Same WSL gate as providerRoots(): discoverWslHome resolves \\wsl$
|
|
1812
|
+
// independently of `home`, so a custom/injected home must not splice the
|
|
1813
|
+
// machine's live WSL db into an isolated scan. `probeWsl` is the deliberate
|
|
1814
|
+
// opt-in (defaults to true only for the machine's own home).
|
|
1815
|
+
const zcodeProbeWsl = deps.probeWsl !== undefined
|
|
1816
|
+
? Boolean(deps.probeWsl)
|
|
1817
|
+
: path.resolve(home) === path.resolve((deps.homedir || os.homedir)());
|
|
1818
|
+
const zcodeWslHome = zcodePlatform === "win32" && zcodeProbeWsl && wsl.shouldProbeWsl(env)
|
|
1819
|
+
? (deps.discoverWslHome || wsl.discoverWslHome)(".zcode", { env })
|
|
1820
|
+
: null;
|
|
1821
|
+
const zcodeExisting = (dbPath) => (dbPath && fs.existsSync(dbPath) ? dbPath : null);
|
|
1822
|
+
const zcodeWslDbPath = zcodeWslHome ? path.join(zcodeWslHome, "cli", "db", "db.sqlite") : null;
|
|
1823
|
+
const zcodePaths = zcodePlatform === "win32"
|
|
1824
|
+
? wsl.resolveAllWin32Paths({
|
|
1825
|
+
nativeValue: zcodeExisting(zcodeNativeDbPath),
|
|
1826
|
+
wslValue: zcodeExisting(zcodeWslDbPath),
|
|
1827
|
+
env,
|
|
1828
|
+
platform: zcodePlatform,
|
|
1829
|
+
})
|
|
1830
|
+
: { native: zcodeExisting(zcodeNativeDbPath), wsl: null };
|
|
1831
|
+
const zcode = [...new Set(Object.values(zcodePaths).filter(Boolean))];
|
|
1832
|
+
return { claude, codex: groupCodexFiles([...codex, ...archived]), grok, zcode, incomplete };
|
|
1344
1833
|
}
|
|
1345
1834
|
|
|
1346
1835
|
// Warn once per unreadable directory per process: the dashboard polls this
|
|
@@ -1445,6 +1934,12 @@ function analyticsEntryStatKey(source, filePath) {
|
|
|
1445
1934
|
const primary = filePaths[0];
|
|
1446
1935
|
return `${sessionStat}|summary:${sessionFileStatKey(grokSummaryPathFor(primary)) || "missing"}|signals:${sessionFileStatKey(grokSignalsPathFor(primary)) || "missing"}`;
|
|
1447
1936
|
}
|
|
1937
|
+
if (source === "zcode") {
|
|
1938
|
+
// A WAL-mode db can absorb writes without touching the main file's mtime,
|
|
1939
|
+
// so the sidecar files are part of the db's identity for cache checks.
|
|
1940
|
+
return `${sessionStat}|sidecars:${filePaths.map((filePath) =>
|
|
1941
|
+
`wal:${sessionFileStatKey(`${filePath}-wal`) || "missing"}|shm:${sessionFileStatKey(`${filePath}-shm`) || "missing"}`).join("|")}`;
|
|
1942
|
+
}
|
|
1448
1943
|
return sessionStat;
|
|
1449
1944
|
}
|
|
1450
1945
|
|
|
@@ -1467,13 +1962,18 @@ async function writeAtomic(filePath, content) {
|
|
|
1467
1962
|
async function buildSessionAnalyticsInternal({ home = os.homedir(), force = false, cacheTtlMs = 5 * 60_000 } = {}) {
|
|
1468
1963
|
const sidecarPath = resolveSessionSidecarPath(home);
|
|
1469
1964
|
const metaPath = `${sidecarPath}.meta.json`;
|
|
1965
|
+
// A lock/permission failure need not alter the database's stat signature.
|
|
1966
|
+
// Keep a separate durable invalidation marker while preserving the complete
|
|
1967
|
+
// sidecar and metadata. TTL, signature and per-file reuse must all retry it.
|
|
1968
|
+
const incompleteZcodePath = `${metaPath}.zcode-incomplete`;
|
|
1969
|
+
const retryZcodeScan = fs.existsSync(incompleteZcodePath);
|
|
1470
1970
|
let previousMeta = null;
|
|
1471
1971
|
if (!force) {
|
|
1472
1972
|
try {
|
|
1473
1973
|
previousMeta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
|
|
1474
1974
|
const checkedAt = Date.parse(previousMeta.checked_at || previousMeta.generated_at || "");
|
|
1475
1975
|
if (
|
|
1476
|
-
previousMeta.version === SIDECAR_VERSION &&
|
|
1976
|
+
!retryZcodeScan && previousMeta.version === SIDECAR_VERSION &&
|
|
1477
1977
|
Number.isFinite(checkedAt) &&
|
|
1478
1978
|
Date.now() - checkedAt < Math.max(0, Number(cacheTtlMs) || 0)
|
|
1479
1979
|
) {
|
|
@@ -1506,8 +2006,10 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
1506
2006
|
...discovered.grok,
|
|
1507
2007
|
...discovered.grok.map(grokSummaryPathFor),
|
|
1508
2008
|
...discovered.grok.map(grokSignalsPathFor),
|
|
2009
|
+
// db + its WAL sidecars: the trio is one logical file for change detection.
|
|
2010
|
+
...(discovered.zcode || []).flatMap((dbPath) => [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]),
|
|
1509
2011
|
]);
|
|
1510
|
-
if (!force && previousMeta?.version === SIDECAR_VERSION && previousMeta.signature === signature) {
|
|
2012
|
+
if (!force && !retryZcodeScan && previousMeta?.version === SIDECAR_VERSION && previousMeta.signature === signature) {
|
|
1511
2013
|
await writeAtomic(metaPath, `${JSON.stringify({ ...previousMeta, checked_at: new Date().toISOString() })}\n`);
|
|
1512
2014
|
return readSidecar(sidecarPath);
|
|
1513
2015
|
}
|
|
@@ -1527,44 +2029,74 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
1527
2029
|
...discovered.claude.map((filePaths) => ({ source: "claude", filePath: filePaths, scan: scanClaudeSession })),
|
|
1528
2030
|
...discovered.codex.map((filePaths) => ({ source: "codex", filePath: filePaths, scan: scanCodexSession })),
|
|
1529
2031
|
...discovered.grok.map((filePath) => ({ source: "grok", filePath, scan: scanGrokSession })),
|
|
2032
|
+
...(discovered.zcode.length ? [{ source: "zcode", filePath: discovered.zcode, scan: scanZcodeSessions }] : []),
|
|
1530
2033
|
];
|
|
1531
2034
|
// Files we could not turn into a row (permission denied, half-written line,
|
|
1532
2035
|
// vanished mid-scan). Swallowing these silently made sessions disappear with
|
|
1533
2036
|
// no signal at all; count them so the API can say so.
|
|
1534
2037
|
let skippedFiles = 0;
|
|
2038
|
+
let incompleteZcodeScan = false;
|
|
1535
2039
|
for (const entry of entries) {
|
|
1536
2040
|
const cacheKey = sessionFileCacheKey(entry.source, entry.filePath);
|
|
1537
2041
|
const statKey = analyticsEntryStatKey(entry.source, entry.filePath);
|
|
1538
2042
|
if (!statKey) {
|
|
1539
2043
|
skippedFiles += 1;
|
|
2044
|
+
if (entry.source === "zcode") incompleteZcodeScan = true;
|
|
1540
2045
|
continue;
|
|
1541
2046
|
}
|
|
1542
|
-
let
|
|
1543
|
-
if (!force && previousFiles[cacheKey]?.stat_key === statKey) {
|
|
1544
|
-
|
|
2047
|
+
let rows = null;
|
|
2048
|
+
if (!force && !(entry.source === "zcode" && retryZcodeScan) && previousFiles[cacheKey]?.stat_key === statKey) {
|
|
2049
|
+
if (entry.source === "zcode") {
|
|
2050
|
+
// One db scans to many cached rows; recover them all from the previous
|
|
2051
|
+
// pass. An empty recovery means the sidecar lost its rows — rescan.
|
|
2052
|
+
const cached = previousRows.filter((row) => row?.source === "zcode");
|
|
2053
|
+
rows = cached.length ? cached : null;
|
|
2054
|
+
} else {
|
|
2055
|
+
const row = previousRowsByFile.get(cacheKey) || null;
|
|
2056
|
+
rows = row ? [row] : null;
|
|
2057
|
+
}
|
|
1545
2058
|
}
|
|
1546
|
-
if (!
|
|
2059
|
+
if (!rows) {
|
|
1547
2060
|
try {
|
|
1548
|
-
|
|
2061
|
+
const scanned = await entry.scan(entry.filePath);
|
|
2062
|
+
rows = Array.isArray(scanned) ? scanned.filter(Boolean) : (scanned ? [scanned] : []);
|
|
1549
2063
|
} catch (error) {
|
|
1550
2064
|
// One active/partial session must not poison the sidecar, but do not
|
|
1551
2065
|
// pretend it never existed either.
|
|
1552
2066
|
skippedFiles += 1;
|
|
2067
|
+
if (entry.source === "zcode") incompleteZcodeScan = true;
|
|
1553
2068
|
if (!process.env.NODE_TEST_CONTEXT) {
|
|
1554
2069
|
console.warn(`[session-analytics] skipped ${entry.source} session file: ${error?.message || error}`);
|
|
1555
2070
|
}
|
|
2071
|
+
rows = [];
|
|
1556
2072
|
}
|
|
1557
2073
|
}
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
2074
|
+
for (const row of rows) {
|
|
2075
|
+
repriceSessionRecord(row);
|
|
2076
|
+
// A one-way file hash enables incremental reuse without persisting or
|
|
2077
|
+
// exposing the user's local session path. ZCode's rows all share one db,
|
|
2078
|
+
// so key each by the db path plus its session id; a bare session id as a
|
|
2079
|
+
// separate path element would be path.resolve()d against the process cwd
|
|
2080
|
+
// and never match across runs.
|
|
2081
|
+
row._cache_key = entry.source === "zcode" && row.session_id
|
|
2082
|
+
? `${cacheKey}#${sessionHash("zcode", row.session_id)}`
|
|
2083
|
+
: cacheKey;
|
|
2084
|
+
sessions.push(row);
|
|
2085
|
+
}
|
|
2086
|
+
if (rows.length > 0) nextFiles[cacheKey] = { stat_key: statKey };
|
|
2087
|
+
}
|
|
2088
|
+
if (incompleteZcodeScan) {
|
|
2089
|
+
await writeAtomic(incompleteZcodePath, "1\n");
|
|
2090
|
+
// ZCode databases form one deduplicated inventory. If any read fails, retain its last
|
|
2091
|
+
// complete rows even on a forced refresh; skippedFiles still reports that
|
|
2092
|
+
// these rows could not be refreshed. Older cache schemas must be rebuilt.
|
|
2093
|
+
sessions.push(...readSidecar(sidecarPath).filter((row) =>
|
|
2094
|
+
row.source === "zcode" && row.version === SIDECAR_VERSION));
|
|
1565
2095
|
}
|
|
1566
2096
|
sessions.sort((a, b) => String(b.ended_at || "").localeCompare(String(a.ended_at || "")));
|
|
1567
|
-
|
|
2097
|
+
// A failed database scan is an incomplete inventory. Keep the last complete
|
|
2098
|
+
// snapshot and signature so an unchanged database is retried next build.
|
|
2099
|
+
if (incompleteDirs.length === 0 && !incompleteZcodeScan) {
|
|
1568
2100
|
const content = sessions.map(serializeSessionRecord).join("\n") + (sessions.length ? "\n" : "");
|
|
1569
2101
|
await writeAtomic(sidecarPath, content);
|
|
1570
2102
|
const generatedAt = new Date().toISOString();
|
|
@@ -1575,6 +2107,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
1575
2107
|
checked_at: generatedAt,
|
|
1576
2108
|
files: nextFiles,
|
|
1577
2109
|
})}\n`);
|
|
2110
|
+
await fsp.rm(incompleteZcodePath, { force: true });
|
|
1578
2111
|
}
|
|
1579
2112
|
// Non-enumerable so the array still behaves exactly like a plain row list
|
|
1580
2113
|
// for every existing caller (map/filter/JSON of the rows is unaffected).
|
|
@@ -1617,17 +2150,55 @@ function buildSessionAnalytics(options = {}) {
|
|
|
1617
2150
|
// exactly the long/resumed ones, which also sort to the top because the sort
|
|
1618
2151
|
// key is ended_at. On real data a 7-day window lost 6 sessions, the largest
|
|
1619
2152
|
// 224M tokens. Treat the window as an interval intersection instead.
|
|
1620
|
-
function
|
|
1621
|
-
const
|
|
1622
|
-
|
|
2153
|
+
function sessionUsageDay(timestamp, { timeZone, offsetMinutes } = {}) {
|
|
2154
|
+
const date = new Date(timestamp);
|
|
2155
|
+
if (!Number.isFinite(date.getTime())) return "";
|
|
2156
|
+
if (timeZone) {
|
|
2157
|
+
try {
|
|
2158
|
+
const parts = new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(date);
|
|
2159
|
+
const part = (name) => parts.find((p) => p.type === name)?.value;
|
|
2160
|
+
return `${part("year")}-${part("month")}-${part("day")}`;
|
|
2161
|
+
} catch { /* fall back to the caller's offset or UTC */ }
|
|
2162
|
+
}
|
|
2163
|
+
return new Date(date.getTime() + (Number.isFinite(offsetMinutes) ? offsetMinutes * 60_000 : 0)).toISOString().slice(0, 10);
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
function withinDayRange(row, from, to, context) {
|
|
2167
|
+
const startDay = sessionUsageDay(row?.started_at || row?.ended_at || "", context);
|
|
2168
|
+
const endDay = sessionUsageDay(row?.ended_at || row?.started_at || "", context);
|
|
1623
2169
|
if (from && (!endDay || endDay < from)) return false;
|
|
1624
2170
|
if (to && (!startDay || startDay > to)) return false;
|
|
1625
2171
|
return true;
|
|
1626
2172
|
}
|
|
1627
2173
|
|
|
1628
|
-
function
|
|
1629
|
-
|
|
1630
|
-
|
|
2174
|
+
function scopeSessionUsage(row, from, to, context) {
|
|
2175
|
+
if ((!from && !to) || !Array.isArray(row.usage_buckets)) return { ...row };
|
|
2176
|
+
const buckets = row.usage_buckets.filter((bucket) => {
|
|
2177
|
+
const day = sessionUsageDay(bucket.timestamp, context);
|
|
2178
|
+
return (!from || day >= from) && (!to || day <= to);
|
|
2179
|
+
});
|
|
2180
|
+
const tokens = emptyTotals();
|
|
2181
|
+
const byModel = new Map();
|
|
2182
|
+
for (const bucket of buckets) {
|
|
2183
|
+
addTotals(tokens, bucket);
|
|
2184
|
+
const model = byModel.get(bucket.model) || { model: bucket.model, ...emptyTotals(), usage_events: 0 };
|
|
2185
|
+
addTotals(model, bucket);
|
|
2186
|
+
mergeUsageMetadata(model, bucket);
|
|
2187
|
+
if (!bucket.usage_events) model.usage_events += 1;
|
|
2188
|
+
byModel.set(bucket.model, model);
|
|
2189
|
+
}
|
|
2190
|
+
return repriceSessionRecord({ ...row, tokens, total_tokens: tokens.total_tokens,
|
|
2191
|
+
model_usage: [...byModel.values()], usage_buckets: buckets, cost_usd: 0,
|
|
2192
|
+
lifetime_total_tokens: row.total_tokens, lifetime_cost_usd: row.cost_usd });
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
function summarizeSessions(sessions, { from = "", to = "", includeSessions = true, timeZone, offsetMinutes } = {}) {
|
|
2196
|
+
const context = { timeZone, offsetMinutes };
|
|
2197
|
+
const filtered = annotateThreadUsage((sessions || [])
|
|
2198
|
+
.filter((row) => withinDayRange(row, from, to, context))
|
|
2199
|
+
// Efficiency is a session-lifetime metric: both tokens and edit turns
|
|
2200
|
+
// must describe that same population. The browser scopes consumption
|
|
2201
|
+
// separately; do not divide in-range tokens by lifetime edit counts here.
|
|
1631
2202
|
.map((row) => ({ ...row })));
|
|
1632
2203
|
const byModel = new Map();
|
|
1633
2204
|
const subagents = new Map();
|
|
@@ -1714,7 +2285,7 @@ function summarizeSessions(sessions, { from = "", to = "", includeSessions = tru
|
|
|
1714
2285
|
if (key === primaryModel) agg.retries += finite(row.retry_turns);
|
|
1715
2286
|
byModel.set(key, agg);
|
|
1716
2287
|
}
|
|
1717
|
-
if (row.source === "codex" && row.parent_session_hash) {
|
|
2288
|
+
if ((row.source === "codex" || row.source === "zcode") && row.parent_session_hash) {
|
|
1718
2289
|
const name = row.agent_role || row.agent_nickname || "subagent";
|
|
1719
2290
|
const sub = subagents.get(name) || {
|
|
1720
2291
|
name,
|
|
@@ -1731,9 +2302,9 @@ function summarizeSessions(sessions, { from = "", to = "", includeSessions = tru
|
|
|
1731
2302
|
subagents.set(name, sub);
|
|
1732
2303
|
continue;
|
|
1733
2304
|
}
|
|
1734
|
-
// Codex
|
|
2305
|
+
// Codex and ZCode children are first-class rows. A spawn call in the parent is
|
|
1735
2306
|
// a control-plane event, not token usage, so do not mix in an estimate.
|
|
1736
|
-
if (row.source === "codex") continue;
|
|
2307
|
+
if (row.source === "codex" || row.source === "zcode") continue;
|
|
1737
2308
|
for (const [name, calls] of Object.entries(row.subagent_types || {})) {
|
|
1738
2309
|
const sub = subagents.get(name) || { name, calls: 0, sessions: 0, total_tokens: 0, cost_usd: 0 };
|
|
1739
2310
|
sub.calls += finite(calls);
|
|
@@ -1838,12 +2409,19 @@ function toSessionBrowserRow(row) {
|
|
|
1838
2409
|
parent_link_conflict: Boolean(row.parent_link_conflict),
|
|
1839
2410
|
title: row.title || null,
|
|
1840
2411
|
source: row.source,
|
|
2412
|
+
...(Number.isFinite(row.session_store_bytes) ? {
|
|
2413
|
+
session_store_bytes: row.session_store_bytes,
|
|
2414
|
+
session_store_metric: row.session_store_metric,
|
|
2415
|
+
bytes_per_1k_tokens: (row.lifetime_total_tokens ?? row.total_tokens) > 0
|
|
2416
|
+
? row.session_store_bytes * 1000 / (row.lifetime_total_tokens ?? row.total_tokens) : null,
|
|
2417
|
+
} : {}),
|
|
1841
2418
|
project_key: row.project_key,
|
|
1842
2419
|
// project_ref (the local cwd) only ever travels over the local API so the
|
|
1843
2420
|
// browser can show where a session ran and compose a resume command.
|
|
1844
2421
|
project_ref: row.project_ref || null,
|
|
1845
2422
|
model: row.model,
|
|
1846
2423
|
model_usage: modelUsageForAggregation(row),
|
|
2424
|
+
...(Array.isArray(row.usage_buckets) ? { usage_buckets: row.usage_buckets } : {}),
|
|
1847
2425
|
performance: normalizePerformance(row.performance),
|
|
1848
2426
|
started_at: row.started_at || null,
|
|
1849
2427
|
ended_at: row.ended_at || null,
|
|
@@ -1914,11 +2492,15 @@ function mergeSessionFragments(rows) {
|
|
|
1914
2492
|
cur.performance = mergePerformance(cur.performance, row.performance);
|
|
1915
2493
|
cur.subagent_calls = finite(cur.subagent_calls) + finite(row.subagent_calls);
|
|
1916
2494
|
addTotals(cur.tokens, row.tokens);
|
|
2495
|
+
if (Array.isArray(row.usage_buckets)) {
|
|
2496
|
+
cur.usage_buckets = [...(cur.usage_buckets || []), ...row.usage_buckets];
|
|
2497
|
+
}
|
|
1917
2498
|
cur.model_usage = normalizeModelUsageRows([
|
|
1918
2499
|
...(cur.model_usage || []),
|
|
1919
2500
|
...(row.model_usage || []),
|
|
1920
2501
|
]);
|
|
1921
2502
|
cur.total_tokens = finite(cur.total_tokens) + finite(row.total_tokens);
|
|
2503
|
+
if (Number.isFinite(row.session_store_bytes)) cur.session_store_bytes = finite(cur.session_store_bytes) + row.session_store_bytes;
|
|
1922
2504
|
cur.cost_usd = finite(cur.cost_usd) + finite(row.cost_usd);
|
|
1923
2505
|
cur.provider_cost_usd = finite(cur.provider_cost_usd) + finite(row.provider_cost_usd);
|
|
1924
2506
|
cur.usage_events = finite(cur.usage_events) + finite(row.usage_events);
|
|
@@ -1983,32 +2565,36 @@ function mergeSessionFragments(rows) {
|
|
|
1983
2565
|
return merged;
|
|
1984
2566
|
}
|
|
1985
2567
|
|
|
1986
|
-
// Attach exact Codex parent
|
|
1987
|
-
// per-session total_tokens field. Combined fields are derived
|
|
1988
|
-
// summing total_tokens across all rows still equals the global
|
|
1989
|
-
|
|
2568
|
+
// Attach exact Codex/ZCode parent→child usage without changing the
|
|
2569
|
+
// authoritative per-session total_tokens field. Combined fields are derived
|
|
2570
|
+
// views only, so summing total_tokens across all rows still equals the global
|
|
2571
|
+
// usage ledger. ZCode joins the same lineage pass: its subagent/workflow
|
|
2572
|
+
// children carry parent_session_id from the session table, so the browser can
|
|
2573
|
+
// nest them under their parent and count them as its subagents.
|
|
2574
|
+
function annotateThreadUsage(rows) {
|
|
1990
2575
|
const byId = new Map();
|
|
1991
2576
|
const childrenById = new Map();
|
|
1992
2577
|
const linkedParentById = new Map();
|
|
2578
|
+
const identity = (row, id = row.session_id) => `${row.source}\u0000${id}`;
|
|
1993
2579
|
|
|
1994
2580
|
for (const row of rows || []) {
|
|
1995
|
-
if (row?.source !== "codex" || typeof row.session_id !== "string" || !row.session_id) continue;
|
|
1996
|
-
const existing = byId.get(row
|
|
2581
|
+
if ((row?.source !== "codex" && row?.source !== "zcode") || typeof row.session_id !== "string" || !row.session_id) continue;
|
|
2582
|
+
const existing = byId.get(identity(row));
|
|
1997
2583
|
if (existing && existing !== row) {
|
|
1998
2584
|
existing.parent_link_conflict = true;
|
|
1999
2585
|
row.parent_link_conflict = true;
|
|
2000
2586
|
continue;
|
|
2001
2587
|
}
|
|
2002
|
-
byId.set(row
|
|
2588
|
+
byId.set(identity(row), row);
|
|
2003
2589
|
}
|
|
2004
2590
|
|
|
2005
2591
|
function hasParentCycle(start) {
|
|
2006
2592
|
const seen = new Set();
|
|
2007
2593
|
let current = start;
|
|
2008
|
-
while (current?.parent_session_id && byId.has(current.parent_session_id)) {
|
|
2009
|
-
if (seen.has(current
|
|
2010
|
-
seen.add(current
|
|
2011
|
-
current = byId.get(current.parent_session_id);
|
|
2594
|
+
while (current?.parent_session_id && byId.has(identity(current, current.parent_session_id))) {
|
|
2595
|
+
if (seen.has(identity(current)) || current.parent_session_id === current.session_id) return true;
|
|
2596
|
+
seen.add(identity(current));
|
|
2597
|
+
current = byId.get(identity(current, current.parent_session_id));
|
|
2012
2598
|
}
|
|
2013
2599
|
return false;
|
|
2014
2600
|
}
|
|
@@ -2016,48 +2602,49 @@ function annotateCodexThreadUsage(rows) {
|
|
|
2016
2602
|
for (const row of byId.values()) {
|
|
2017
2603
|
const parentId = typeof row.parent_session_id === "string" ? row.parent_session_id.trim() : "";
|
|
2018
2604
|
if (!parentId) continue;
|
|
2605
|
+
const parentKey = identity(row, parentId);
|
|
2019
2606
|
if (row.parent_link_conflict || hasParentCycle(row)) {
|
|
2020
2607
|
row.parent_link_conflict = true;
|
|
2021
2608
|
continue;
|
|
2022
2609
|
}
|
|
2023
|
-
if (!byId.has(
|
|
2610
|
+
if (!byId.has(parentKey)) {
|
|
2024
2611
|
row.orphaned_subagent = true;
|
|
2025
2612
|
continue;
|
|
2026
2613
|
}
|
|
2027
|
-
const children = childrenById.get(
|
|
2614
|
+
const children = childrenById.get(parentKey) || [];
|
|
2028
2615
|
children.push(row);
|
|
2029
|
-
childrenById.set(
|
|
2030
|
-
linkedParentById.set(row
|
|
2616
|
+
childrenById.set(parentKey, children);
|
|
2617
|
+
linkedParentById.set(identity(row), parentKey);
|
|
2031
2618
|
}
|
|
2032
2619
|
|
|
2033
2620
|
const memo = new Map();
|
|
2034
2621
|
function aggregate(row, visiting = new Set()) {
|
|
2035
|
-
if (memo.has(row
|
|
2036
|
-
if (visiting.has(row
|
|
2622
|
+
if (memo.has(identity(row))) return memo.get(identity(row));
|
|
2623
|
+
if (visiting.has(identity(row))) {
|
|
2037
2624
|
row.parent_link_conflict = true;
|
|
2038
2625
|
return { count: 0, tokens: 0, cost: 0 };
|
|
2039
2626
|
}
|
|
2040
2627
|
const nextVisiting = new Set(visiting);
|
|
2041
|
-
nextVisiting.add(row
|
|
2628
|
+
nextVisiting.add(identity(row));
|
|
2042
2629
|
let count = 0;
|
|
2043
2630
|
let tokens = 0;
|
|
2044
2631
|
let cost = 0;
|
|
2045
|
-
for (const child of childrenById.get(row
|
|
2632
|
+
for (const child of childrenById.get(identity(row)) || []) {
|
|
2046
2633
|
const nested = aggregate(child, nextVisiting);
|
|
2047
2634
|
count += 1 + nested.count;
|
|
2048
2635
|
tokens += finite(child.total_tokens) + nested.tokens;
|
|
2049
2636
|
cost += finite(child.cost_usd) + nested.cost;
|
|
2050
2637
|
}
|
|
2051
2638
|
const result = { count, tokens, cost };
|
|
2052
|
-
memo.set(row
|
|
2639
|
+
memo.set(identity(row), result);
|
|
2053
2640
|
return result;
|
|
2054
2641
|
}
|
|
2055
2642
|
|
|
2056
2643
|
function rootFor(row) {
|
|
2057
|
-
const seen = new Set([row
|
|
2644
|
+
const seen = new Set([identity(row)]);
|
|
2058
2645
|
let current = row;
|
|
2059
|
-
while (current && linkedParentById.has(current
|
|
2060
|
-
const linkedParentId = linkedParentById.get(current
|
|
2646
|
+
while (current && linkedParentById.has(identity(current))) {
|
|
2647
|
+
const linkedParentId = linkedParentById.get(identity(current));
|
|
2061
2648
|
if (seen.has(linkedParentId)) {
|
|
2062
2649
|
row.parent_link_conflict = true;
|
|
2063
2650
|
break;
|
|
@@ -2069,12 +2656,14 @@ function annotateCodexThreadUsage(rows) {
|
|
|
2069
2656
|
}
|
|
2070
2657
|
|
|
2071
2658
|
for (const row of rows || []) {
|
|
2659
|
+
const threadAware = (row.source === "codex" || row.source === "zcode")
|
|
2660
|
+
&& typeof row.session_id === "string" && row.session_id;
|
|
2072
2661
|
row.own_total_tokens = finite(row.total_tokens);
|
|
2073
2662
|
row.own_cost_usd = finite(row.cost_usd);
|
|
2074
|
-
row.direct_subagent_count =
|
|
2075
|
-
? (childrenById.get(row
|
|
2663
|
+
row.direct_subagent_count = threadAware
|
|
2664
|
+
? (childrenById.get(identity(row)) || []).length
|
|
2076
2665
|
: 0;
|
|
2077
|
-
const nested =
|
|
2666
|
+
const nested = threadAware
|
|
2078
2667
|
? aggregate(row)
|
|
2079
2668
|
: { count: 0, tokens: 0, cost: 0 };
|
|
2080
2669
|
row.descendant_subagent_count = nested.count;
|
|
@@ -2082,8 +2671,8 @@ function annotateCodexThreadUsage(rows) {
|
|
|
2082
2671
|
row.subagent_cost_usd = nested.cost;
|
|
2083
2672
|
row.combined_total_tokens = finite(row.total_tokens) + nested.tokens;
|
|
2084
2673
|
row.combined_cost_usd = finite(row.cost_usd) + nested.cost;
|
|
2085
|
-
if (
|
|
2086
|
-
const parent = byId.get(linkedParentById.get(row
|
|
2674
|
+
if (threadAware) {
|
|
2675
|
+
const parent = byId.get(linkedParentById.get(identity(row)));
|
|
2087
2676
|
row.parent_session_hash = parent?.session_hash || null;
|
|
2088
2677
|
row.root_session_hash = rootFor(row).session_hash || row.session_hash;
|
|
2089
2678
|
}
|
|
@@ -2095,15 +2684,18 @@ function annotateCodexThreadUsage(rows) {
|
|
|
2095
2684
|
// summarizeSessions (cloud/CSV safe), this retains session_id + project_ref so
|
|
2096
2685
|
// the UI can offer one-click resume. Callers must only expose it over the
|
|
2097
2686
|
// local API.
|
|
2098
|
-
function listSessionsForBrowser(sessions, { from = "", to = "", limit = 0 } = {}) {
|
|
2099
|
-
const
|
|
2687
|
+
function listSessionsForBrowser(sessions, { from = "", to = "", limit = 0, timeZone, offsetMinutes } = {}) {
|
|
2688
|
+
const context = { timeZone, offsetMinutes };
|
|
2689
|
+
const filtered = annotateThreadUsage(mergeSessionFragments(sessions)
|
|
2100
2690
|
// Only sessions that actually spent tokens are worth listing. This drops
|
|
2101
2691
|
// two kinds of noise: non-session logs under ~/.claude/projects
|
|
2102
2692
|
// (skill-injections.jsonl, journal.jsonl, …), and sessions abandoned before
|
|
2103
2693
|
// the model replied. Both render as "unknown · 0 tokens · $0.00" rows with
|
|
2104
2694
|
// no model, no cost and nothing to analyze.
|
|
2105
2695
|
.filter((row) => finite(row.total_tokens) > 0)
|
|
2106
|
-
.filter((row) => withinDayRange(row, from, to))
|
|
2696
|
+
.filter((row) => withinDayRange(row, from, to, context))
|
|
2697
|
+
.map((row) => scopeSessionUsage(row, from, to, context))
|
|
2698
|
+
.filter((row) => !Array.isArray(row.usage_buckets) || finite(row.total_tokens) > 0));
|
|
2107
2699
|
filtered.sort((a, b) => String(b.ended_at || "").localeCompare(String(a.ended_at || "")));
|
|
2108
2700
|
const cap = Number(limit) > 0 ? Number(limit) : 0;
|
|
2109
2701
|
const limited = cap > 0 ? filtered.slice(0, cap) : filtered;
|
|
@@ -2137,6 +2729,7 @@ module.exports = {
|
|
|
2137
2729
|
scanClaudeSession,
|
|
2138
2730
|
scanCodexSession,
|
|
2139
2731
|
scanGrokSession,
|
|
2732
|
+
scanZcodeSessions,
|
|
2140
2733
|
buildSessionAnalytics,
|
|
2141
2734
|
summarizeSessions,
|
|
2142
2735
|
listSessionsForBrowser,
|