tokentracker-cli 1.3.5 → 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/dashboard/dist/assets/{AchievementBadge-Dazs3pEa.js → AchievementBadge-CHDt55IP.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-LWnxZvmw.js → AchievementsPage-DpbRHmb8.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-BLqczmU9.js → AchievementsSection-Ck5CiX4P.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-B_n4aj2S.js → BadgeDetailModal-BVPqlh0X.js} +1 -1
- package/dashboard/dist/assets/{BotAnimated-KDWI_YcZ.js → BotAnimated-CVsv6kxT.js} +1 -1
- package/dashboard/dist/assets/{Card-BYoxd4OC.js → Card-C9FjyAwh.js} +1 -1
- package/dashboard/dist/assets/{CloudCheckoutPage-BR2_B03P.js → CloudCheckoutPage-ChkjId9l.js} +1 -1
- package/dashboard/dist/assets/{CloudPage-DL5RkJKK.js → CloudPage-D1nrt3MG.js} +1 -1
- package/dashboard/dist/assets/{CloudUsageExport-C_DqZKo1.js → CloudUsageExport-jXdoiD0e.js} +1 -1
- package/dashboard/dist/assets/{CommandPalette-DkCaIQI4.js → CommandPalette-DFnEeZzc.js} +1 -1
- package/dashboard/dist/assets/{CommunityStatsModal-pX9OePi4.js → CommunityStatsModal-CZDS8gPA.js} +1 -1
- package/dashboard/dist/assets/{ConfirmModal-tkgZwz4u.js → ConfirmModal-DRhgBoGE.js} +1 -1
- package/dashboard/dist/assets/{Controls-MTUhGWy8.js → Controls-BmT6-x_L.js} +1 -1
- package/dashboard/dist/assets/{DashboardPage-SOx-A7lH.js → DashboardPage-DfwwXZBo.js} +1 -1
- package/dashboard/dist/assets/{DateRangePopover-B-BMOm3O.js → DateRangePopover-DuqNLatr.js} +1 -1
- package/dashboard/dist/assets/{DevicePage-DSK9lm7v.js → DevicePage-BtI0bWyg.js} +1 -1
- package/dashboard/dist/assets/{DialogClose-uVUNu4rg.js → DialogClose-DjnC3P4V.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-yy_QHg7O.js +1 -0
- package/dashboard/dist/assets/{DialogTitle-B-VifiA3.js → DialogTitle-B9NNSz5p.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-B6lrj4uo.js → FadeIn-fsVQmJGa.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-Cx5zOhXa.js → HeaderGithubStar-Cg-f1iZc.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-DY1ENf69.js → HoverTooltip-DRIRlzXH.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-D6TEq64d.js → IpCheckPage-DaAubaJQ.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-OdZUfq--.js → LandingPage-DjBGIjHk.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardPage-DCK5xcAt.js → LeaderboardPage-BkCXqhz7.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardProBadge-CBXJ1u_m.js → LeaderboardProBadge-Ds-wiWcN.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardProfileModal-Ccdzb7QK.js → LeaderboardProfileModal-X_rfSQT5.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfilePage-CFXlZ0fL.js → LeaderboardProfilePage-rpl1Ozwj.js} +1 -1
- package/dashboard/dist/assets/{LimitsPage-5krjm-g9.js → LimitsPage-BUvDBmgD.js} +1 -1
- package/dashboard/dist/assets/{LocalOnlyNotice-Rk1B8PaZ.js → LocalOnlyNotice-LBH1DBrs.js} +1 -1
- package/dashboard/dist/assets/{LoginCard-DTTIm8vk.js → LoginCard-B0vRJEcg.js} +1 -1
- package/dashboard/dist/assets/{LoginModal-BNcDdjfr.js → LoginModal-DdzgP43f.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-CQgXP7Ee.js → LoginPage-B4VCQxzn.js} +1 -1
- package/dashboard/dist/assets/{PetPage-Cw_kAPRs.js → PetPage-Cmf6aXc9.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-NYiGE8A2.js → PopoverPopup-BzfELnIC.js} +1 -1
- package/dashboard/dist/assets/{ProviderIcon-Bp7iFJ_m.js → ProviderIcon-ClHXNkQ0.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-Cf9kAWDW.js → ResetPasswordPage-BeUpNt_6.js} +1 -1
- package/dashboard/dist/assets/{Select-kIcvQFAo.js → Select-BHEXdYHu.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-BpIsAg3b.js → SelectItemText-aaQK_QIt.js} +1 -1
- package/dashboard/dist/assets/{SelfHostPage-Ch2vvSwI.js → SelfHostPage-ClNhKnlP.js} +1 -1
- package/dashboard/dist/assets/{ServiceStatusPage-hoYBvGbu.js → ServiceStatusPage-Byd_s6FS.js} +1 -1
- package/dashboard/dist/assets/{SessionsPage-BzRB6q-Z.js → SessionsPage-u3_b4PO7.js} +2 -2
- package/dashboard/dist/assets/{SettingsPage-DRl2uX-i.js → SettingsPage-DweCAbsy.js} +2 -2
- package/dashboard/dist/assets/{SkillsPage-CkqThTZL.js → SkillsPage-Bb-PVfux.js} +1 -1
- package/dashboard/dist/assets/{TokenGalaxy-eLG-UIZY.js → TokenGalaxy-CubA3PXu.js} +1 -1
- package/dashboard/dist/assets/{TooltipPopup-bnMJyilQ.js → TooltipPopup-DMliWhoK.js} +1 -1
- package/dashboard/dist/assets/{TrendMonitor-X7-aGzIv.js → TrendMonitor-as8idaOy.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-CGznIzrW.js → WidgetsPage-DDPR4v0R.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-p31-uUoF.js → WrappedPage-zZzgYz6s.js} +1 -1
- package/dashboard/dist/assets/{__vite-browser-external-2g_4s3ot.js → __vite-browser-external-DWN9Fb-H.js} +2 -2
- package/dashboard/dist/assets/{agent-logos-2TVT3fIE.js → agent-logos-CoNhCm9-.js} +1 -1
- package/dashboard/dist/assets/{arrow-left-DL0hAp_7.js → arrow-left-C5hDp7fc.js} +1 -1
- package/dashboard/dist/assets/{arrow-right-DFCpnV_9.js → arrow-right-DEvMmSiA.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-DLvfE0Wu.js → arrow-up-right-DxY0neJ4.js} +1 -1
- package/dashboard/dist/assets/{calendar-days-B0aDoKSG.js → calendar-days-bzh7-lab.js} +1 -1
- package/dashboard/dist/assets/{check-C_cfrxiz.js → check-CSZvcC3X.js} +1 -1
- package/dashboard/dist/assets/{chevron-down-Ck8ml43e.js → chevron-down-CsiGcG0G.js} +1 -1
- package/dashboard/dist/assets/{cloud-checkout-PYnwNfdH.js → cloud-checkout-z_eDVwm6.js} +1 -1
- package/dashboard/dist/assets/{composite-INW-ELWX.js → composite-Cq0CmNAv.js} +1 -1
- package/dashboard/dist/assets/{download-B7uokeIo.js → download-DqLpubIF.js} +1 -1
- package/dashboard/dist/assets/{external-link-CI3crg4K.js → external-link-B8E8tbB3.js} +1 -1
- package/dashboard/dist/assets/{flame-DCzvFKV1.js → flame-CrJDlnro.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-CSTEYK2m.js → format-tokens-DPIGGOlt.js} +1 -1
- package/dashboard/dist/assets/{gift-dEHDFMc6.js → gift-CCp4Hh50.js} +1 -1
- package/dashboard/dist/assets/{host-mode-r2SW9Mdv.js → host-mode-xzpMLcwP.js} +1 -1
- package/dashboard/dist/assets/{icons-q-3zvEkh.js → icons-CbB9Y-eV.js} +1 -1
- package/dashboard/dist/assets/{index-CyHYj9XI.js → index-BiK5-tQp.js} +1 -1
- package/dashboard/dist/assets/{index-BV7qwUw4.js → index-DX7nYrHb.js} +1 -1
- package/dashboard/dist/assets/{index-BbrsgHf4.js → index-zuC7FU-t.js} +1 -1
- package/dashboard/dist/assets/{info-BBdY9hV4.js → info-B3NKYwWG.js} +1 -1
- package/dashboard/dist/assets/{link-2-CLPePfXs.js → link-2-BRc8d8M9.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-ETrjzfmn.js → loader-circle-Cm5Nsgp-.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-DHqnp2ol.js → modifiers.esm-CYLYBECB.js} +1 -1
- package/dashboard/dist/assets/{monitor-smartphone-D1kdzrdb.js → monitor-smartphone--IZ4D84F.js} +1 -1
- package/dashboard/dist/assets/{play-Cxp-ronD.js → play-CUrMOLtq.js} +1 -1
- package/dashboard/dist/assets/{plus-4n94VzDK.js → plus-0hrCDl4r.js} +1 -1
- package/dashboard/dist/assets/{provider-display-CRlbeO7t.js → provider-display-CNK0FN3Q.js} +1 -1
- package/dashboard/dist/assets/{react-DlHjGQFN.js → react-CCNSVHEA.js} +1 -1
- package/dashboard/dist/assets/{safePolygon-CVZ27a2_.js → safePolygon-DsolkMUX.js} +1 -1
- package/dashboard/dist/assets/{search-BDPr56_h.js → search-DYmYb9xY.js} +1 -1
- package/dashboard/dist/assets/{server-Bm7fHvVa.js → server-CYcqzdSW.js} +1 -1
- package/dashboard/dist/assets/{shield-check-DtT8ACzI.js → shield-check-5zABLIr6.js} +1 -1
- package/dashboard/dist/assets/{skills-api-Dc1-2qu6.js → skills-api-CD-ojBGj.js} +1 -1
- package/dashboard/dist/assets/{sortable.esm-B-qN-Xk2.js → sortable.esm-BCn2Ms6R.js} +1 -1
- package/dashboard/dist/assets/{store-gHeBU8dQ.js → store-B22kwLs0.js} +1 -1
- package/dashboard/dist/assets/{terminal-D4VZbvhg.js → terminal-CTv-oTU2.js} +1 -1
- package/dashboard/dist/assets/{trash-BZtLAFQy.js → trash-C5MAh3GO.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-BdqndrbB.js → use-community-stats-BrgrxUD2.js} +1 -1
- package/dashboard/dist/assets/{use-latest-request-guard-DhGg1cDS.js → use-latest-request-guard-B1_rbvgH.js} +1 -1
- package/dashboard/dist/assets/{use-limits-display-prefs-D3HOcRFv.js → use-limits-display-prefs-BOk5f-HM.js} +1 -1
- package/dashboard/dist/assets/{use-native-settings-BniKwA_n.js → use-native-settings-B2gc_m9W.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list-b09H7EHq.js → use-ordered-list-N3sGpJ0u.js} +1 -1
- package/dashboard/dist/assets/{use-pet-settings-CPZdlq_e.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-CIdxjmI8.js → use-transform-BsaLmw46.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-eJlU65yY.js → use-usage-limits-wL7v-S0r.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-CM22CmAq.js → useCurrency-CbCnjf-N.js} +1 -1
- package/dashboard/dist/assets/{usePositioner-B3l2ulTw.js → usePositioner-MepYlL0A.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-D7nzgxKw.js → useTokenFormat-Bq-rwuKa.js} +1 -1
- package/dashboard/dist/assets/{zap-Daxct1WV.js → zap-1ah5e11g.js} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/dashboard/dist/ip-check.html +1 -1
- package/dashboard/dist/leaderboard.html +1 -1
- package/dashboard/dist/share.html +1 -1
- package/package.json +1 -1
- package/src/cli.js +1 -1
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +49 -16
- package/src/lib/session-analytics.js +546 -52
- package/src/lib/session-performance.js +24 -1
- package/dashboard/dist/assets/DialogDescription-DBqGGWNj.js +0 -1
- package/dashboard/dist/assets/use-reduced-motion-D1gcKJUo.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",
|
|
@@ -1162,6 +1173,408 @@ async function scanGrokSession(filePath) {
|
|
|
1162
1173
|
});
|
|
1163
1174
|
}
|
|
1164
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
|
+
|
|
1165
1578
|
// Multi-root discovery: on Windows a native install and a WSL one both count.
|
|
1166
1579
|
// `sync` already walks both Claude homes (src/commands/sync.js), so without this
|
|
1167
1580
|
// the WSL work lands in the token totals but is invisible in the session browser
|
|
@@ -1387,7 +1800,36 @@ async function discoverSessionFiles(home, env = process.env, deps = {}) {
|
|
|
1387
1800
|
const claude = allClaude.filter((filePaths) => !filePaths.some((filePath) => filePath
|
|
1388
1801
|
.split(path.sep)
|
|
1389
1802
|
.some((segment) => segment.endsWith(CLAUDE_MEM_OBSERVER_PROJECT_SUFFIX))));
|
|
1390
|
-
|
|
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 };
|
|
1391
1833
|
}
|
|
1392
1834
|
|
|
1393
1835
|
// Warn once per unreadable directory per process: the dashboard polls this
|
|
@@ -1492,6 +1934,12 @@ function analyticsEntryStatKey(source, filePath) {
|
|
|
1492
1934
|
const primary = filePaths[0];
|
|
1493
1935
|
return `${sessionStat}|summary:${sessionFileStatKey(grokSummaryPathFor(primary)) || "missing"}|signals:${sessionFileStatKey(grokSignalsPathFor(primary)) || "missing"}`;
|
|
1494
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
|
+
}
|
|
1495
1943
|
return sessionStat;
|
|
1496
1944
|
}
|
|
1497
1945
|
|
|
@@ -1514,13 +1962,18 @@ async function writeAtomic(filePath, content) {
|
|
|
1514
1962
|
async function buildSessionAnalyticsInternal({ home = os.homedir(), force = false, cacheTtlMs = 5 * 60_000 } = {}) {
|
|
1515
1963
|
const sidecarPath = resolveSessionSidecarPath(home);
|
|
1516
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);
|
|
1517
1970
|
let previousMeta = null;
|
|
1518
1971
|
if (!force) {
|
|
1519
1972
|
try {
|
|
1520
1973
|
previousMeta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
|
|
1521
1974
|
const checkedAt = Date.parse(previousMeta.checked_at || previousMeta.generated_at || "");
|
|
1522
1975
|
if (
|
|
1523
|
-
previousMeta.version === SIDECAR_VERSION &&
|
|
1976
|
+
!retryZcodeScan && previousMeta.version === SIDECAR_VERSION &&
|
|
1524
1977
|
Number.isFinite(checkedAt) &&
|
|
1525
1978
|
Date.now() - checkedAt < Math.max(0, Number(cacheTtlMs) || 0)
|
|
1526
1979
|
) {
|
|
@@ -1553,8 +2006,10 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
1553
2006
|
...discovered.grok,
|
|
1554
2007
|
...discovered.grok.map(grokSummaryPathFor),
|
|
1555
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`]),
|
|
1556
2011
|
]);
|
|
1557
|
-
if (!force && previousMeta?.version === SIDECAR_VERSION && previousMeta.signature === signature) {
|
|
2012
|
+
if (!force && !retryZcodeScan && previousMeta?.version === SIDECAR_VERSION && previousMeta.signature === signature) {
|
|
1558
2013
|
await writeAtomic(metaPath, `${JSON.stringify({ ...previousMeta, checked_at: new Date().toISOString() })}\n`);
|
|
1559
2014
|
return readSidecar(sidecarPath);
|
|
1560
2015
|
}
|
|
@@ -1574,44 +2029,74 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
1574
2029
|
...discovered.claude.map((filePaths) => ({ source: "claude", filePath: filePaths, scan: scanClaudeSession })),
|
|
1575
2030
|
...discovered.codex.map((filePaths) => ({ source: "codex", filePath: filePaths, scan: scanCodexSession })),
|
|
1576
2031
|
...discovered.grok.map((filePath) => ({ source: "grok", filePath, scan: scanGrokSession })),
|
|
2032
|
+
...(discovered.zcode.length ? [{ source: "zcode", filePath: discovered.zcode, scan: scanZcodeSessions }] : []),
|
|
1577
2033
|
];
|
|
1578
2034
|
// Files we could not turn into a row (permission denied, half-written line,
|
|
1579
2035
|
// vanished mid-scan). Swallowing these silently made sessions disappear with
|
|
1580
2036
|
// no signal at all; count them so the API can say so.
|
|
1581
2037
|
let skippedFiles = 0;
|
|
2038
|
+
let incompleteZcodeScan = false;
|
|
1582
2039
|
for (const entry of entries) {
|
|
1583
2040
|
const cacheKey = sessionFileCacheKey(entry.source, entry.filePath);
|
|
1584
2041
|
const statKey = analyticsEntryStatKey(entry.source, entry.filePath);
|
|
1585
2042
|
if (!statKey) {
|
|
1586
2043
|
skippedFiles += 1;
|
|
2044
|
+
if (entry.source === "zcode") incompleteZcodeScan = true;
|
|
1587
2045
|
continue;
|
|
1588
2046
|
}
|
|
1589
|
-
let
|
|
1590
|
-
if (!force && previousFiles[cacheKey]?.stat_key === statKey) {
|
|
1591
|
-
|
|
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
|
+
}
|
|
1592
2058
|
}
|
|
1593
|
-
if (!
|
|
2059
|
+
if (!rows) {
|
|
1594
2060
|
try {
|
|
1595
|
-
|
|
2061
|
+
const scanned = await entry.scan(entry.filePath);
|
|
2062
|
+
rows = Array.isArray(scanned) ? scanned.filter(Boolean) : (scanned ? [scanned] : []);
|
|
1596
2063
|
} catch (error) {
|
|
1597
2064
|
// One active/partial session must not poison the sidecar, but do not
|
|
1598
2065
|
// pretend it never existed either.
|
|
1599
2066
|
skippedFiles += 1;
|
|
2067
|
+
if (entry.source === "zcode") incompleteZcodeScan = true;
|
|
1600
2068
|
if (!process.env.NODE_TEST_CONTEXT) {
|
|
1601
2069
|
console.warn(`[session-analytics] skipped ${entry.source} session file: ${error?.message || error}`);
|
|
1602
2070
|
}
|
|
2071
|
+
rows = [];
|
|
1603
2072
|
}
|
|
1604
2073
|
}
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
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));
|
|
1612
2095
|
}
|
|
1613
2096
|
sessions.sort((a, b) => String(b.ended_at || "").localeCompare(String(a.ended_at || "")));
|
|
1614
|
-
|
|
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) {
|
|
1615
2100
|
const content = sessions.map(serializeSessionRecord).join("\n") + (sessions.length ? "\n" : "");
|
|
1616
2101
|
await writeAtomic(sidecarPath, content);
|
|
1617
2102
|
const generatedAt = new Date().toISOString();
|
|
@@ -1622,6 +2107,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
|
|
|
1622
2107
|
checked_at: generatedAt,
|
|
1623
2108
|
files: nextFiles,
|
|
1624
2109
|
})}\n`);
|
|
2110
|
+
await fsp.rm(incompleteZcodePath, { force: true });
|
|
1625
2111
|
}
|
|
1626
2112
|
// Non-enumerable so the array still behaves exactly like a plain row list
|
|
1627
2113
|
// for every existing caller (map/filter/JSON of the rows is unaffected).
|
|
@@ -1708,7 +2194,7 @@ function scopeSessionUsage(row, from, to, context) {
|
|
|
1708
2194
|
|
|
1709
2195
|
function summarizeSessions(sessions, { from = "", to = "", includeSessions = true, timeZone, offsetMinutes } = {}) {
|
|
1710
2196
|
const context = { timeZone, offsetMinutes };
|
|
1711
|
-
const filtered =
|
|
2197
|
+
const filtered = annotateThreadUsage((sessions || [])
|
|
1712
2198
|
.filter((row) => withinDayRange(row, from, to, context))
|
|
1713
2199
|
// Efficiency is a session-lifetime metric: both tokens and edit turns
|
|
1714
2200
|
// must describe that same population. The browser scopes consumption
|
|
@@ -1799,7 +2285,7 @@ function summarizeSessions(sessions, { from = "", to = "", includeSessions = tru
|
|
|
1799
2285
|
if (key === primaryModel) agg.retries += finite(row.retry_turns);
|
|
1800
2286
|
byModel.set(key, agg);
|
|
1801
2287
|
}
|
|
1802
|
-
if (row.source === "codex" && row.parent_session_hash) {
|
|
2288
|
+
if ((row.source === "codex" || row.source === "zcode") && row.parent_session_hash) {
|
|
1803
2289
|
const name = row.agent_role || row.agent_nickname || "subagent";
|
|
1804
2290
|
const sub = subagents.get(name) || {
|
|
1805
2291
|
name,
|
|
@@ -1816,9 +2302,9 @@ function summarizeSessions(sessions, { from = "", to = "", includeSessions = tru
|
|
|
1816
2302
|
subagents.set(name, sub);
|
|
1817
2303
|
continue;
|
|
1818
2304
|
}
|
|
1819
|
-
// Codex
|
|
2305
|
+
// Codex and ZCode children are first-class rows. A spawn call in the parent is
|
|
1820
2306
|
// a control-plane event, not token usage, so do not mix in an estimate.
|
|
1821
|
-
if (row.source === "codex") continue;
|
|
2307
|
+
if (row.source === "codex" || row.source === "zcode") continue;
|
|
1822
2308
|
for (const [name, calls] of Object.entries(row.subagent_types || {})) {
|
|
1823
2309
|
const sub = subagents.get(name) || { name, calls: 0, sessions: 0, total_tokens: 0, cost_usd: 0 };
|
|
1824
2310
|
sub.calls += finite(calls);
|
|
@@ -2079,32 +2565,36 @@ function mergeSessionFragments(rows) {
|
|
|
2079
2565
|
return merged;
|
|
2080
2566
|
}
|
|
2081
2567
|
|
|
2082
|
-
// Attach exact Codex parent
|
|
2083
|
-
// per-session total_tokens field. Combined fields are derived
|
|
2084
|
-
// summing total_tokens across all rows still equals the global
|
|
2085
|
-
|
|
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) {
|
|
2086
2575
|
const byId = new Map();
|
|
2087
2576
|
const childrenById = new Map();
|
|
2088
2577
|
const linkedParentById = new Map();
|
|
2578
|
+
const identity = (row, id = row.session_id) => `${row.source}\u0000${id}`;
|
|
2089
2579
|
|
|
2090
2580
|
for (const row of rows || []) {
|
|
2091
|
-
if (row?.source !== "codex" || typeof row.session_id !== "string" || !row.session_id) continue;
|
|
2092
|
-
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));
|
|
2093
2583
|
if (existing && existing !== row) {
|
|
2094
2584
|
existing.parent_link_conflict = true;
|
|
2095
2585
|
row.parent_link_conflict = true;
|
|
2096
2586
|
continue;
|
|
2097
2587
|
}
|
|
2098
|
-
byId.set(row
|
|
2588
|
+
byId.set(identity(row), row);
|
|
2099
2589
|
}
|
|
2100
2590
|
|
|
2101
2591
|
function hasParentCycle(start) {
|
|
2102
2592
|
const seen = new Set();
|
|
2103
2593
|
let current = start;
|
|
2104
|
-
while (current?.parent_session_id && byId.has(current.parent_session_id)) {
|
|
2105
|
-
if (seen.has(current
|
|
2106
|
-
seen.add(current
|
|
2107
|
-
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));
|
|
2108
2598
|
}
|
|
2109
2599
|
return false;
|
|
2110
2600
|
}
|
|
@@ -2112,48 +2602,49 @@ function annotateCodexThreadUsage(rows) {
|
|
|
2112
2602
|
for (const row of byId.values()) {
|
|
2113
2603
|
const parentId = typeof row.parent_session_id === "string" ? row.parent_session_id.trim() : "";
|
|
2114
2604
|
if (!parentId) continue;
|
|
2605
|
+
const parentKey = identity(row, parentId);
|
|
2115
2606
|
if (row.parent_link_conflict || hasParentCycle(row)) {
|
|
2116
2607
|
row.parent_link_conflict = true;
|
|
2117
2608
|
continue;
|
|
2118
2609
|
}
|
|
2119
|
-
if (!byId.has(
|
|
2610
|
+
if (!byId.has(parentKey)) {
|
|
2120
2611
|
row.orphaned_subagent = true;
|
|
2121
2612
|
continue;
|
|
2122
2613
|
}
|
|
2123
|
-
const children = childrenById.get(
|
|
2614
|
+
const children = childrenById.get(parentKey) || [];
|
|
2124
2615
|
children.push(row);
|
|
2125
|
-
childrenById.set(
|
|
2126
|
-
linkedParentById.set(row
|
|
2616
|
+
childrenById.set(parentKey, children);
|
|
2617
|
+
linkedParentById.set(identity(row), parentKey);
|
|
2127
2618
|
}
|
|
2128
2619
|
|
|
2129
2620
|
const memo = new Map();
|
|
2130
2621
|
function aggregate(row, visiting = new Set()) {
|
|
2131
|
-
if (memo.has(row
|
|
2132
|
-
if (visiting.has(row
|
|
2622
|
+
if (memo.has(identity(row))) return memo.get(identity(row));
|
|
2623
|
+
if (visiting.has(identity(row))) {
|
|
2133
2624
|
row.parent_link_conflict = true;
|
|
2134
2625
|
return { count: 0, tokens: 0, cost: 0 };
|
|
2135
2626
|
}
|
|
2136
2627
|
const nextVisiting = new Set(visiting);
|
|
2137
|
-
nextVisiting.add(row
|
|
2628
|
+
nextVisiting.add(identity(row));
|
|
2138
2629
|
let count = 0;
|
|
2139
2630
|
let tokens = 0;
|
|
2140
2631
|
let cost = 0;
|
|
2141
|
-
for (const child of childrenById.get(row
|
|
2632
|
+
for (const child of childrenById.get(identity(row)) || []) {
|
|
2142
2633
|
const nested = aggregate(child, nextVisiting);
|
|
2143
2634
|
count += 1 + nested.count;
|
|
2144
2635
|
tokens += finite(child.total_tokens) + nested.tokens;
|
|
2145
2636
|
cost += finite(child.cost_usd) + nested.cost;
|
|
2146
2637
|
}
|
|
2147
2638
|
const result = { count, tokens, cost };
|
|
2148
|
-
memo.set(row
|
|
2639
|
+
memo.set(identity(row), result);
|
|
2149
2640
|
return result;
|
|
2150
2641
|
}
|
|
2151
2642
|
|
|
2152
2643
|
function rootFor(row) {
|
|
2153
|
-
const seen = new Set([row
|
|
2644
|
+
const seen = new Set([identity(row)]);
|
|
2154
2645
|
let current = row;
|
|
2155
|
-
while (current && linkedParentById.has(current
|
|
2156
|
-
const linkedParentId = linkedParentById.get(current
|
|
2646
|
+
while (current && linkedParentById.has(identity(current))) {
|
|
2647
|
+
const linkedParentId = linkedParentById.get(identity(current));
|
|
2157
2648
|
if (seen.has(linkedParentId)) {
|
|
2158
2649
|
row.parent_link_conflict = true;
|
|
2159
2650
|
break;
|
|
@@ -2165,12 +2656,14 @@ function annotateCodexThreadUsage(rows) {
|
|
|
2165
2656
|
}
|
|
2166
2657
|
|
|
2167
2658
|
for (const row of rows || []) {
|
|
2659
|
+
const threadAware = (row.source === "codex" || row.source === "zcode")
|
|
2660
|
+
&& typeof row.session_id === "string" && row.session_id;
|
|
2168
2661
|
row.own_total_tokens = finite(row.total_tokens);
|
|
2169
2662
|
row.own_cost_usd = finite(row.cost_usd);
|
|
2170
|
-
row.direct_subagent_count =
|
|
2171
|
-
? (childrenById.get(row
|
|
2663
|
+
row.direct_subagent_count = threadAware
|
|
2664
|
+
? (childrenById.get(identity(row)) || []).length
|
|
2172
2665
|
: 0;
|
|
2173
|
-
const nested =
|
|
2666
|
+
const nested = threadAware
|
|
2174
2667
|
? aggregate(row)
|
|
2175
2668
|
: { count: 0, tokens: 0, cost: 0 };
|
|
2176
2669
|
row.descendant_subagent_count = nested.count;
|
|
@@ -2178,8 +2671,8 @@ function annotateCodexThreadUsage(rows) {
|
|
|
2178
2671
|
row.subagent_cost_usd = nested.cost;
|
|
2179
2672
|
row.combined_total_tokens = finite(row.total_tokens) + nested.tokens;
|
|
2180
2673
|
row.combined_cost_usd = finite(row.cost_usd) + nested.cost;
|
|
2181
|
-
if (
|
|
2182
|
-
const parent = byId.get(linkedParentById.get(row
|
|
2674
|
+
if (threadAware) {
|
|
2675
|
+
const parent = byId.get(linkedParentById.get(identity(row)));
|
|
2183
2676
|
row.parent_session_hash = parent?.session_hash || null;
|
|
2184
2677
|
row.root_session_hash = rootFor(row).session_hash || row.session_hash;
|
|
2185
2678
|
}
|
|
@@ -2193,7 +2686,7 @@ function annotateCodexThreadUsage(rows) {
|
|
|
2193
2686
|
// local API.
|
|
2194
2687
|
function listSessionsForBrowser(sessions, { from = "", to = "", limit = 0, timeZone, offsetMinutes } = {}) {
|
|
2195
2688
|
const context = { timeZone, offsetMinutes };
|
|
2196
|
-
const filtered =
|
|
2689
|
+
const filtered = annotateThreadUsage(mergeSessionFragments(sessions)
|
|
2197
2690
|
// Only sessions that actually spent tokens are worth listing. This drops
|
|
2198
2691
|
// two kinds of noise: non-session logs under ~/.claude/projects
|
|
2199
2692
|
// (skill-injections.jsonl, journal.jsonl, …), and sessions abandoned before
|
|
@@ -2236,6 +2729,7 @@ module.exports = {
|
|
|
2236
2729
|
scanClaudeSession,
|
|
2237
2730
|
scanCodexSession,
|
|
2238
2731
|
scanGrokSession,
|
|
2732
|
+
scanZcodeSessions,
|
|
2239
2733
|
buildSessionAnalytics,
|
|
2240
2734
|
summarizeSessions,
|
|
2241
2735
|
listSessionsForBrowser,
|