tokentracker-cli 0.87.15 → 0.87.17
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 +2 -2
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +3 -3
- package/README.zh-CN.md +2 -2
- package/dashboard/dist/assets/{AchievementBadge-Ca-tQO2m.js → AchievementBadge-BsEQ2tmj.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-l8piG098.js → AchievementsPage-DaIE18Oj.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-DILM1j6f.js → AchievementsSection-D5Chjra6.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-CP4kIXJ6.js → BadgeDetailModal-DiF5CC4Q.js} +1 -1
- package/dashboard/dist/assets/{Card-vJSP5AIR.js → Card-Bh1l9XVa.js} +1 -1
- package/dashboard/dist/assets/{ClawdAnimated-DRXRgleI.js → ClawdAnimated-B2tb_uy9.js} +1 -1
- package/dashboard/dist/assets/{CommandPalette-CFYDM40w.js → CommandPalette-BDYmI6KM.js} +1 -1
- package/dashboard/dist/assets/{CommunityStatsModal-DeBswg2S.js → CommunityStatsModal-DZDixdxG.js} +1 -1
- package/dashboard/dist/assets/{ConfirmModal-DnHQ6ZjR.js → ConfirmModal-B3HhWPG1.js} +1 -1
- package/dashboard/dist/assets/{Controls-CEgdStDg.js → Controls-eSVS0G3S.js} +1 -1
- package/dashboard/dist/assets/{DashboardPage-CRA9C12j.js → DashboardPage-DMyBPJ9W.js} +1 -1
- package/dashboard/dist/assets/{DevicePage-DJHYCHvk.js → DevicePage-BhpTKx-c.js} +1 -1
- package/dashboard/dist/assets/{DialogClose-Dfv009JY.js → DialogClose-Dt2M164c.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-D0EQV4SO.js +1 -0
- package/dashboard/dist/assets/{DialogTitle-B50QLs-X.js → DialogTitle-CBQEeY1z.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-C0gR94fP.js → FadeIn-9wsSzh_a.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-C640OuU1.js → HeaderGithubStar-BOg2yeJ0.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-DX83f-xN.js → HoverTooltip-OaMkhUbs.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-BSFyNVPV.js → IpCheckPage-BFaVui2O.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-SsOTjgMK.js → LandingPage-CGybB6xR.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardAvatar-B2195RbN.js → LeaderboardAvatar-DFR_SDle.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardPage-B2HVZ1LA.js → LeaderboardPage-Dx3nyCZc.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardProfileModal-CV07_l8k.js → LeaderboardProfileModal-B9nLJTGv.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfilePage-Dx3je17Y.js → LeaderboardProfilePage-B4vNvdiq.js} +1 -1
- package/dashboard/dist/assets/LimitsPage-D-J5R4qu.js +4 -0
- package/dashboard/dist/assets/{LocalOnlyNotice-CIcgZ_DK.js → LocalOnlyNotice-CeJ1Dv_8.js} +1 -1
- package/dashboard/dist/assets/{LoginCard-RoSvQEcf.js → LoginCard-CHQRkWQs.js} +1 -1
- package/dashboard/dist/assets/{LoginModal-CyyqwoxT.js → LoginModal-B5559j1K.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-Df1TNN5I.js → LoginPage-C1P2Quq6.js} +1 -1
- package/dashboard/dist/assets/{PetPage-DNzJwiHw.js → PetPage-Wt-HcAHU.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-DQeWO0iF.js → PopoverPopup-CVI4w0E6.js} +1 -1
- package/dashboard/dist/assets/{ProviderIcon-lzUcnmCH.js → ProviderIcon-BswKVGR0.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-zMccm6ur.js → ResetPasswordPage-sRWSceeH.js} +1 -1
- package/dashboard/dist/assets/{SegmentedControl-CJFGvTrI.js → SegmentedControl-bXs9Ly1S.js} +1 -1
- package/dashboard/dist/assets/{Select-C3Omtbu7.js → Select-BxZQJSA8.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-yhAb3Grm.js → SelectItemText-BhC7_m4l.js} +1 -1
- package/dashboard/dist/assets/{ServiceStatusPage-DKO2U7tP.js → ServiceStatusPage-DnoQGIzM.js} +1 -1
- package/dashboard/dist/assets/{SessionsPage-Db7QQKkq.js → SessionsPage-D_rk7urK.js} +1 -1
- package/dashboard/dist/assets/{SettingsPage-Wt0c9Tom.js → SettingsPage-d1uhJJUI.js} +1 -1
- package/dashboard/dist/assets/{SkillsPage-CiIK-x9u.js → SkillsPage-Dmv3vfV2.js} +1 -1
- package/dashboard/dist/assets/{TokenGalaxy-lenaRGrK.js → TokenGalaxy-BRozBDv9.js} +1 -1
- package/dashboard/dist/assets/{ToolbarRootContext-DM5LAbiN.js → ToolbarRootContext-BxAIwde2.js} +1 -1
- package/dashboard/dist/assets/{TrendMonitor-DsjkB17f.js → TrendMonitor-bTygbWe4.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-DRHgj8mv.js → WidgetsPage-Bdgv5NIE.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-CnqvO_pe.js → WrappedPage-tLVxl_4c.js} +1 -1
- package/dashboard/dist/assets/{__vite-browser-external-Dt0UC4VW.js → __vite-browser-external-DFfWg5Zv.js} +14 -11
- package/dashboard/dist/assets/{agent-logos-B5opZwZN.js → agent-logos-DVtP3hhF.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-DthPMeHa.js → arrow-up-right-a--zz4P2.js} +1 -1
- package/dashboard/dist/assets/{check-BjhrWH3T.js → check-Cb87RDI8.js} +1 -1
- package/dashboard/dist/assets/{chevron-right-BllZqKsq.js → chevron-right-BaXnYJmR.js} +1 -1
- package/dashboard/dist/assets/{copy-DPdzUcUh.js → copy-BP5bVEyA.js} +1 -1
- package/dashboard/dist/assets/{download-BSl1NoxY.js → download-DqquZt0D.js} +1 -1
- package/dashboard/dist/assets/{external-link-DpSJMzDV.js → external-link-iXDmG0bb.js} +1 -1
- package/dashboard/dist/assets/{flame-BuP9lXyr.js → flame-DHqiSC5J.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-DxpuHAMo.js → format-tokens-DsygRhxH.js} +1 -1
- package/dashboard/dist/assets/{icons-N_skbXnV.js → icons-D6xBLJA4.js} +1 -1
- package/dashboard/dist/assets/{index-CkdHt2G8.js → index-DSrgwJkM.js} +1 -1
- package/dashboard/dist/assets/{index-VgEi64Pa.js → index-HfUVkowX.js} +1 -1
- package/dashboard/dist/assets/{index-BJbM1KfA.js → index-cM3RnbJG.js} +1 -1
- package/dashboard/dist/assets/{info-B7vTy0RN.js → info-Cui6vFPM.js} +1 -1
- package/dashboard/dist/assets/limits-providers-CdzvnLpk.js +1 -0
- package/dashboard/dist/assets/{link-2-BL9btf_U.js → link-2-DX3nm7aU.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-B3ZUl9Xl.js → loader-circle-C8Sw1OT7.js} +1 -1
- package/dashboard/dist/assets/{maximize-2-B3oqZR-c.js → maximize-2-Czi9wWkF.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-CJv-4uLD.js → modifiers.esm-BmnoKiX1.js} +1 -1
- package/dashboard/dist/assets/{monitor-smartphone-DpSeKStM.js → monitor-smartphone-B0J0HWPL.js} +1 -1
- package/dashboard/dist/assets/{provider-display-ChgG1v12.js → provider-display-C6qI5Ghp.js} +1 -1
- package/dashboard/dist/assets/{react-CiQpGAt7.js → react-BVu5drox.js} +1 -1
- package/dashboard/dist/assets/{search-CR5Y7wkH.js → search-C1-spbaq.js} +1 -1
- package/dashboard/dist/assets/{skills-api-CYV3Aau7.js → skills-api-C3VUeo0l.js} +1 -1
- package/dashboard/dist/assets/{sortable.esm-Cp2tEWCE.js → sortable.esm-vred0beO.js} +1 -1
- package/dashboard/dist/assets/{store-D579LpDz.js → store-B4CtFHMN.js} +1 -1
- package/dashboard/dist/assets/{terminal-B1Afo2zN.js → terminal-CCly6A7O.js} +1 -1
- package/dashboard/dist/assets/{trash-2-O7jz3Z3p.js → trash-2-Doikp-3o.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-DpHf0WZG.js → use-community-stats-4JEUXgQD.js} +1 -1
- package/dashboard/dist/assets/{use-limits-display-prefs-BVCtTqo4.js → use-limits-display-prefs-CvmEm5O5.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list-C7mhlVII.js → use-ordered-list-Cwd6mjTH.js} +1 -1
- package/dashboard/dist/assets/use-reduced-motion-DqkEunos.js +1 -0
- package/dashboard/dist/assets/{use-session-efficiency-pref-C3HNT8gg.js → use-session-efficiency-pref-DSE0hMDn.js} +1 -1
- package/dashboard/dist/assets/{use-transform-Cn4pqfbJ.js → use-transform-CHAOJvjo.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-BAF6oNaO.js → use-usage-limits-DqjvXdmC.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-DmofuCKt.js → useCurrency-C6VcRFbY.js} +1 -1
- package/dashboard/dist/assets/{useScrollLock-2HXHWZMK.js → useScrollLock-BtQ9MIFV.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-CSI-l9pP.js → useTokenFormat-DVtSl1vw.js} +1 -1
- package/dashboard/dist/assets/{zap-BcxNvHjJ.js → zap-Bqvqpei6.js} +1 -1
- package/dashboard/dist/index.html +4 -4
- 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/commands/serve.js +11 -3
- package/src/commands/status.js +34 -0
- package/src/commands/sync.js +95 -1
- package/src/lib/diagnostics.js +10 -0
- package/src/lib/fs.js +79 -1
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/qoder-limits.js +89 -34
- package/src/lib/rollout.js +226 -43
- package/src/lib/session-analytics.js +99 -5
- package/src/lib/usage-limits.js +93 -14
- package/dashboard/dist/assets/DialogDescription-BcNmkFVH.js +0 -1
- package/dashboard/dist/assets/LimitsPage-uQNPuFCp.js +0 -4
- package/dashboard/dist/assets/limits-providers-DXlRXQck.js +0 -1
- package/dashboard/dist/assets/use-reduced-motion-CWJDDawC.js +0 -1
package/src/lib/rollout.js
CHANGED
|
@@ -1195,6 +1195,7 @@ async function parseOpencodeIncremental({
|
|
|
1195
1195
|
const publicRepoCache = projectEnabled ? new Map() : null;
|
|
1196
1196
|
const opencodeState = normalizeOpencodeState(cursors?.opencode);
|
|
1197
1197
|
const messageIndex = opencodeState.messages;
|
|
1198
|
+
const fingerprintIndex = buildOpencodeFingerprintIndex(messageIndex);
|
|
1198
1199
|
const touchedBuckets = new Set();
|
|
1199
1200
|
const defaultSource = normalizeSourceInput(source) || "opencode";
|
|
1200
1201
|
|
|
@@ -1255,6 +1256,7 @@ async function parseOpencodeIncremental({
|
|
|
1255
1256
|
const result = await parseOpencodeMessageFile({
|
|
1256
1257
|
filePath,
|
|
1257
1258
|
messageIndex,
|
|
1259
|
+
fingerprintIndex,
|
|
1258
1260
|
fallbackTotals,
|
|
1259
1261
|
fallbackMessageKey,
|
|
1260
1262
|
hourlyState,
|
|
@@ -1279,10 +1281,13 @@ async function parseOpencodeIncremental({
|
|
|
1279
1281
|
eventsAggregated += result.eventsAggregated;
|
|
1280
1282
|
|
|
1281
1283
|
if (result.messageKey && result.shouldUpdate) {
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1284
|
+
recordOpencodeMessage({
|
|
1285
|
+
messageIndex,
|
|
1286
|
+
fingerprintIndex,
|
|
1287
|
+
messageKey: result.messageKey,
|
|
1288
|
+
totals: result.lastTotals,
|
|
1289
|
+
fingerprint: result.fingerprint || null,
|
|
1290
|
+
});
|
|
1286
1291
|
}
|
|
1287
1292
|
|
|
1288
1293
|
if (cb) {
|
|
@@ -2554,6 +2559,7 @@ async function parseGeminiFile({
|
|
|
2554
2559
|
async function parseOpencodeMessageFile({
|
|
2555
2560
|
filePath,
|
|
2556
2561
|
messageIndex,
|
|
2562
|
+
fingerprintIndex,
|
|
2557
2563
|
fallbackTotals,
|
|
2558
2564
|
fallbackMessageKey,
|
|
2559
2565
|
hourlyState,
|
|
@@ -2608,9 +2614,22 @@ async function parseOpencodeMessageFile({
|
|
|
2608
2614
|
return { messageKey, lastTotals, eventsAggregated: 0, shouldUpdate: false };
|
|
2609
2615
|
}
|
|
2610
2616
|
|
|
2617
|
+
// `Session.fork` re-materialises the parent's prefix under new message ids in
|
|
2618
|
+
// a new session — count it once (issue #426, see deriveOpencodeMessageFingerprint).
|
|
2619
|
+
const fingerprint = deriveOpencodeMessageFingerprint({ msg, totals: currentTotals, source });
|
|
2620
|
+
if (!lastTotals && isOpencodeForkCopy(fingerprintIndex, fingerprint, messageKey)) {
|
|
2621
|
+
return { messageKey, lastTotals: null, fingerprint, eventsAggregated: 0, shouldUpdate: false };
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2611
2624
|
const delta = diffGeminiTotals(currentTotals, lastTotals);
|
|
2612
2625
|
if (!delta || isAllZeroUsage(delta)) {
|
|
2613
|
-
return {
|
|
2626
|
+
return {
|
|
2627
|
+
messageKey,
|
|
2628
|
+
lastTotals: currentTotals,
|
|
2629
|
+
fingerprint,
|
|
2630
|
+
eventsAggregated: 0,
|
|
2631
|
+
shouldUpdate: true,
|
|
2632
|
+
};
|
|
2614
2633
|
}
|
|
2615
2634
|
delta.conversation_count = 1;
|
|
2616
2635
|
|
|
@@ -2650,7 +2669,13 @@ async function parseOpencodeMessageFile({
|
|
|
2650
2669
|
addTotals(projectBucket.totals, delta);
|
|
2651
2670
|
projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
|
|
2652
2671
|
}
|
|
2653
|
-
return {
|
|
2672
|
+
return {
|
|
2673
|
+
messageKey,
|
|
2674
|
+
lastTotals: currentTotals,
|
|
2675
|
+
fingerprint,
|
|
2676
|
+
eventsAggregated: 1,
|
|
2677
|
+
shouldUpdate: true,
|
|
2678
|
+
};
|
|
2654
2679
|
}
|
|
2655
2680
|
|
|
2656
2681
|
async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets }) {
|
|
@@ -3193,6 +3218,110 @@ function deriveOpencodeMessageKey(msg, fallback) {
|
|
|
3193
3218
|
return fallback;
|
|
3194
3219
|
}
|
|
3195
3220
|
|
|
3221
|
+
// ── OpenCode fork-copy dedup (issue #426) ──────────────────────────────────
|
|
3222
|
+
//
|
|
3223
|
+
// `Session.fork` copies every parent message up to the fork point into a BRAND
|
|
3224
|
+
// NEW session. Verified against the shipped opencode binary: the copy is
|
|
3225
|
+
// `{ ...parentMessage.info, sessionID: newSession.id, id: newMessageId }` — so
|
|
3226
|
+
// `time.created`, `time.completed`, `modelID`, `providerID` and the entire
|
|
3227
|
+
// `tokens` payload survive verbatim and only the two identity fields change.
|
|
3228
|
+
// The forked session is created WITHOUT a parentID (confirmed empirically:
|
|
3229
|
+
// POST /session/:id/fork returns parentID=null), so the copies cannot be linked
|
|
3230
|
+
// to their origin by ancestry — content is the only available discriminator.
|
|
3231
|
+
// Because our dedup key is `sessionID|messageID`, every copied prefix used to be
|
|
3232
|
+
// counted a second time (a real fork of a 170-message session re-added 20.3M
|
|
3233
|
+
// tokens; the reporter measured ~2.0B across ~20 forks).
|
|
3234
|
+
//
|
|
3235
|
+
// The fingerprint is deliberately narrow — issue #187 is the standing reminder
|
|
3236
|
+
// that a loose dedup predicate deletes real usage. A collision requires the SAME
|
|
3237
|
+
// millisecond for BOTH created and completed, byte-equal values in all five
|
|
3238
|
+
// token columns, the same model, the same provider AND the same source. On top
|
|
3239
|
+
// of that we only ever drop a match that lives in a DIFFERENT session, since
|
|
3240
|
+
// forking always mints a new one — two turns inside one session are never
|
|
3241
|
+
// treated as copies of each other.
|
|
3242
|
+
function deriveOpencodeMessageFingerprint({ msg, totals, source }) {
|
|
3243
|
+
if (!totals || isAllZeroUsage(totals)) return null;
|
|
3244
|
+
const created = coerceEpochMs(msg?.time?.created) || 0;
|
|
3245
|
+
const completed = coerceEpochMs(msg?.time?.completed) || 0;
|
|
3246
|
+
if (!created && !completed) return null;
|
|
3247
|
+
const model = normalizeModelInput(msg?.modelID || msg?.model || msg?.modelId) || DEFAULT_MODEL;
|
|
3248
|
+
const provider = normalizeMessageKeyPart(msg?.providerID || msg?.provider || msg?.providerId);
|
|
3249
|
+
const raw = [
|
|
3250
|
+
normalizeSourceInput(source) || "opencode",
|
|
3251
|
+
created,
|
|
3252
|
+
completed,
|
|
3253
|
+
totals.input_tokens,
|
|
3254
|
+
totals.output_tokens,
|
|
3255
|
+
totals.cached_input_tokens,
|
|
3256
|
+
totals.cache_creation_input_tokens,
|
|
3257
|
+
totals.reasoning_output_tokens,
|
|
3258
|
+
model,
|
|
3259
|
+
provider,
|
|
3260
|
+
].join("");
|
|
3261
|
+
// Hashed rather than stored raw: the fingerprint is persisted per message in
|
|
3262
|
+
// cursors.json, and heavy OpenCode users carry tens of thousands of entries.
|
|
3263
|
+
return crypto.createHash("sha256").update(raw).digest("base64url").slice(0, 22);
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
// `fingerprint -> messageKey` for the messages already counted in this cursor
|
|
3267
|
+
// namespace. Rebuilt per parse run; entries written before this version carry no
|
|
3268
|
+
// fingerprint and simply do not participate (no retroactive rewrite of history).
|
|
3269
|
+
function buildOpencodeFingerprintIndex(messageIndex) {
|
|
3270
|
+
const byFingerprint = new Map();
|
|
3271
|
+
if (!messageIndex || typeof messageIndex !== "object") return byFingerprint;
|
|
3272
|
+
for (const [key, entry] of Object.entries(messageIndex)) {
|
|
3273
|
+
const fingerprint = entry && typeof entry.fingerprint === "string" ? entry.fingerprint : null;
|
|
3274
|
+
if (fingerprint && !byFingerprint.has(fingerprint)) byFingerprint.set(fingerprint, key);
|
|
3275
|
+
}
|
|
3276
|
+
return byFingerprint;
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
function opencodeMessageKeySession(messageKey) {
|
|
3280
|
+
if (typeof messageKey !== "string") return null;
|
|
3281
|
+
const idx = messageKey.indexOf("|");
|
|
3282
|
+
return idx > 0 ? messageKey.slice(0, idx) : null;
|
|
3283
|
+
}
|
|
3284
|
+
|
|
3285
|
+
// True only when an already-counted message in ANOTHER session carries the exact
|
|
3286
|
+
// same fingerprint — i.e. this row is a fork copy. Keys without a resolvable
|
|
3287
|
+
// session (the JSON reader's file-path fallback) never dedup: unproven identity
|
|
3288
|
+
// must not delete usage.
|
|
3289
|
+
function isOpencodeForkCopy(fingerprintIndex, fingerprint, messageKey) {
|
|
3290
|
+
if (!fingerprint || !fingerprintIndex) return false;
|
|
3291
|
+
const owner = fingerprintIndex.get(fingerprint);
|
|
3292
|
+
if (!owner || owner === messageKey) return false;
|
|
3293
|
+
const ownerSession = opencodeMessageKeySession(owner);
|
|
3294
|
+
const session = opencodeMessageKeySession(messageKey);
|
|
3295
|
+
if (!ownerSession || !session) return false;
|
|
3296
|
+
return ownerSession !== session;
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
// Persist a message's snapshot + fingerprint and claim the fingerprint for it.
|
|
3300
|
+
// Writes only when something actually changed so a steady-state sync leaves the
|
|
3301
|
+
// cursor untouched. A falsy `fingerprint` means "unknown" and preserves whatever
|
|
3302
|
+
// the entry already carried; a new one releases the old claim so a stale
|
|
3303
|
+
// mid-stream snapshot cannot shadow an unrelated message later.
|
|
3304
|
+
function recordOpencodeMessage({ messageIndex, fingerprintIndex, messageKey, totals, fingerprint }) {
|
|
3305
|
+
if (!messageIndex || !messageKey) return;
|
|
3306
|
+
const prev = messageIndex[messageKey];
|
|
3307
|
+
const prevTotals = prev && typeof prev.lastTotals === "object" ? prev.lastTotals : null;
|
|
3308
|
+
const prevFingerprint = prev && typeof prev.fingerprint === "string" ? prev.fingerprint : null;
|
|
3309
|
+
const nextFingerprint = fingerprint || prevFingerprint;
|
|
3310
|
+
if (sameGeminiTotals(totals, prevTotals) && prevFingerprint === nextFingerprint) return;
|
|
3311
|
+
|
|
3312
|
+
if (fingerprintIndex && prevFingerprint && prevFingerprint !== nextFingerprint) {
|
|
3313
|
+
if (fingerprintIndex.get(prevFingerprint) === messageKey) {
|
|
3314
|
+
fingerprintIndex.delete(prevFingerprint);
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
const entry = { lastTotals: totals, updatedAt: new Date().toISOString() };
|
|
3318
|
+
if (nextFingerprint) entry.fingerprint = nextFingerprint;
|
|
3319
|
+
messageIndex[messageKey] = entry;
|
|
3320
|
+
if (fingerprintIndex && nextFingerprint && !fingerprintIndex.has(nextFingerprint)) {
|
|
3321
|
+
fingerprintIndex.set(nextFingerprint, messageKey);
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3196
3325
|
function getHourlyBucket(state, source, model, hourStart) {
|
|
3197
3326
|
const buckets = state.buckets;
|
|
3198
3327
|
const normalizedSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
|
|
@@ -4219,6 +4348,7 @@ async function parseOpencodeDbIncremental({
|
|
|
4219
4348
|
const cursorNamespace = typeof cursorKey === "string" && cursorKey.length > 0 ? cursorKey : "opencode";
|
|
4220
4349
|
const opencodeState = normalizeOpencodeState(cursors?.[cursorNamespace]);
|
|
4221
4350
|
const messageIndex = opencodeState.messages;
|
|
4351
|
+
const fingerprintIndex = buildOpencodeFingerprintIndex(messageIndex);
|
|
4222
4352
|
const touchedBuckets = new Set();
|
|
4223
4353
|
const defaultSource = normalizeSourceInput(source) || "opencode";
|
|
4224
4354
|
|
|
@@ -4247,15 +4377,30 @@ async function parseOpencodeDbIncremental({
|
|
|
4247
4377
|
continue;
|
|
4248
4378
|
}
|
|
4249
4379
|
|
|
4380
|
+
// A fork copy of an already-counted turn contributes nothing — skip it
|
|
4381
|
+
// outright so it never claims an index entry of its own (issue #426).
|
|
4382
|
+
const fingerprint = deriveOpencodeMessageFingerprint({
|
|
4383
|
+
msg,
|
|
4384
|
+
totals: currentTotals,
|
|
4385
|
+
source: defaultSource,
|
|
4386
|
+
});
|
|
4387
|
+
if (!lastTotals && isOpencodeForkCopy(fingerprintIndex, fingerprint, messageKey)) {
|
|
4388
|
+
messagesProcessed += 1;
|
|
4389
|
+
continue;
|
|
4390
|
+
}
|
|
4391
|
+
|
|
4250
4392
|
const delta = diffGeminiTotals(currentTotals, lastTotals);
|
|
4251
4393
|
if (!delta || isAllZeroUsage(delta)) {
|
|
4252
|
-
//
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4394
|
+
// Refresh the index even without a delta: normalization may have changed,
|
|
4395
|
+
// and pre-#426 entries need their fingerprint backfilled so a fork taken
|
|
4396
|
+
// from an old session is still recognised.
|
|
4397
|
+
recordOpencodeMessage({
|
|
4398
|
+
messageIndex,
|
|
4399
|
+
fingerprintIndex,
|
|
4400
|
+
messageKey,
|
|
4401
|
+
totals: currentTotals,
|
|
4402
|
+
fingerprint,
|
|
4403
|
+
});
|
|
4259
4404
|
messagesProcessed += 1;
|
|
4260
4405
|
if (cb) {
|
|
4261
4406
|
cb({
|
|
@@ -4312,10 +4457,13 @@ async function parseOpencodeDbIncremental({
|
|
|
4312
4457
|
}
|
|
4313
4458
|
}
|
|
4314
4459
|
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4460
|
+
recordOpencodeMessage({
|
|
4461
|
+
messageIndex,
|
|
4462
|
+
fingerprintIndex,
|
|
4463
|
+
messageKey,
|
|
4464
|
+
totals: currentTotals,
|
|
4465
|
+
fingerprint,
|
|
4466
|
+
});
|
|
4319
4467
|
messagesProcessed += 1;
|
|
4320
4468
|
eventsAggregated += 1;
|
|
4321
4469
|
|
|
@@ -4372,22 +4520,26 @@ function resolveQoderDbPath({
|
|
|
4372
4520
|
home = os.homedir(),
|
|
4373
4521
|
env = process.env,
|
|
4374
4522
|
platform = process.platform,
|
|
4523
|
+
appDir = "Qoder",
|
|
4524
|
+
envPrefix = "QODER",
|
|
4375
4525
|
} = {}) {
|
|
4376
|
-
|
|
4377
|
-
|
|
4526
|
+
const dbPathKey = `${envPrefix}_DB_PATH`;
|
|
4527
|
+
const homeKey = `${envPrefix}_HOME`;
|
|
4528
|
+
if (typeof env[dbPathKey] === "string" && env[dbPathKey].trim()) {
|
|
4529
|
+
return path.resolve(env[dbPathKey].trim());
|
|
4378
4530
|
}
|
|
4379
4531
|
let root;
|
|
4380
|
-
if (typeof env
|
|
4381
|
-
root = path.resolve(env.
|
|
4532
|
+
if (typeof env[homeKey] === "string" && env[homeKey].trim()) {
|
|
4533
|
+
root = path.resolve(env[homeKey].trim());
|
|
4382
4534
|
} else if (platform === "darwin") {
|
|
4383
|
-
root = path.join(home, "Library", "Application Support",
|
|
4535
|
+
root = path.join(home, "Library", "Application Support", appDir);
|
|
4384
4536
|
} else if (platform === "win32") {
|
|
4385
4537
|
root = path.join(
|
|
4386
4538
|
env.APPDATA || path.join(home, "AppData", "Roaming"),
|
|
4387
|
-
|
|
4539
|
+
appDir,
|
|
4388
4540
|
);
|
|
4389
4541
|
} else {
|
|
4390
|
-
root = path.join(home, ".config",
|
|
4542
|
+
root = path.join(home, ".config", appDir);
|
|
4391
4543
|
}
|
|
4392
4544
|
return path.join(root, "SharedClientCache", "cache", "db", "local.db");
|
|
4393
4545
|
}
|
|
@@ -4396,17 +4548,21 @@ function resolveQoderDbPaths({
|
|
|
4396
4548
|
home = os.homedir(),
|
|
4397
4549
|
env = process.env,
|
|
4398
4550
|
platform = process.platform,
|
|
4551
|
+
appDir = "Qoder",
|
|
4552
|
+
envPrefix = "QODER",
|
|
4399
4553
|
deps = {},
|
|
4400
4554
|
} = {}) {
|
|
4401
|
-
const
|
|
4402
|
-
|
|
4555
|
+
const dbPathKey = `${envPrefix}_DB_PATH`;
|
|
4556
|
+
const homeKey = `${envPrefix}_HOME`;
|
|
4557
|
+
const nativeValue = resolveQoderDbPath({ home, env, platform, appDir, envPrefix });
|
|
4558
|
+
if (platform !== "win32" || env[dbPathKey] || env[homeKey]) {
|
|
4403
4559
|
return { native: nativeValue, wsl: null };
|
|
4404
4560
|
}
|
|
4405
4561
|
const existsSync = deps.existsSync || fssync.existsSync;
|
|
4406
4562
|
const native = wsl.shouldProbeNative(env) && existsSync(nativeValue) ? nativeValue : null;
|
|
4407
4563
|
const discoverWslHome = deps.discoverWslHome || wsl.discoverWslHome;
|
|
4408
4564
|
const wslRoot = wsl.shouldProbeWsl(env)
|
|
4409
|
-
? discoverWslHome(
|
|
4565
|
+
? discoverWslHome(`.config/${appDir}`, { ...deps, env })
|
|
4410
4566
|
: null;
|
|
4411
4567
|
const wslValue = wslRoot
|
|
4412
4568
|
? path.join(wslRoot, "SharedClientCache", "cache", "db", "local.db")
|
|
@@ -4420,6 +4576,23 @@ function resolveQoderDbPaths({
|
|
|
4420
4576
|
});
|
|
4421
4577
|
}
|
|
4422
4578
|
|
|
4579
|
+
// Qoder CN keeps its own data directory (Application Support/QoderCN on macOS,
|
|
4580
|
+
// AppData/Roaming/QoderCN on Windows, .config/QoderCN on Linux), so it needs
|
|
4581
|
+
// its own path resolution. The token schema is identical to the international
|
|
4582
|
+
// edition — the same QODER_USAGE_SQL and parser are reused with a distinct
|
|
4583
|
+
// source/cursor namespace to avoid rowid collisions between the two DBs. CN
|
|
4584
|
+
// honors its own env overrides (QODER_CN_DB_PATH / QODER_CN_HOME) so that
|
|
4585
|
+
// QODER_HOME/QODER_DB_PATH, which point at the international install, never
|
|
4586
|
+
// redirect the CN resolver onto the same database (that would double-count).
|
|
4587
|
+
function resolveQoderCnDbPaths({
|
|
4588
|
+
home = os.homedir(),
|
|
4589
|
+
env = process.env,
|
|
4590
|
+
platform = process.platform,
|
|
4591
|
+
deps = {},
|
|
4592
|
+
} = {}) {
|
|
4593
|
+
return resolveQoderDbPaths({ home, env, platform, appDir: "QoderCN", envPrefix: "QODER_CN", deps });
|
|
4594
|
+
}
|
|
4595
|
+
|
|
4423
4596
|
async function readQoderDbMessages(dbPath, sqliteOptions = {}) {
|
|
4424
4597
|
if (!dbPath || !fssync.existsSync(dbPath)) return [];
|
|
4425
4598
|
return readSqliteJsonRowsAsync(dbPath, QODER_USAGE_SQL, {
|
|
@@ -4517,11 +4690,13 @@ async function parseQoderDbIncremental({
|
|
|
4517
4690
|
projectQueuePath,
|
|
4518
4691
|
onProgress,
|
|
4519
4692
|
publicRepoResolver,
|
|
4693
|
+
sourceKey = "qoder",
|
|
4694
|
+
cursorKey = "qoder",
|
|
4520
4695
|
} = {}) {
|
|
4521
4696
|
await ensureDir(path.dirname(queuePath));
|
|
4522
4697
|
const rows = Array.isArray(dbMessages) ? dbMessages : [];
|
|
4523
4698
|
const hourlyState = normalizeHourlyState(cursors?.hourly);
|
|
4524
|
-
const qoderState = normalizeQoderState(cursors?.
|
|
4699
|
+
const qoderState = normalizeQoderState(cursors?.[cursorKey]);
|
|
4525
4700
|
const touchedBuckets = new Set();
|
|
4526
4701
|
const projectEnabled = typeof projectQueuePath === "string" && projectQueuePath.length > 0;
|
|
4527
4702
|
const projectState = projectEnabled ? normalizeProjectState(cursors?.projectHourly) : null;
|
|
@@ -4606,40 +4781,40 @@ async function parseQoderDbIncremental({
|
|
|
4606
4781
|
if (previousTotals && previous.bucketStart && previous.model) {
|
|
4607
4782
|
const oldBucket = getHourlyBucket(
|
|
4608
4783
|
hourlyState,
|
|
4609
|
-
|
|
4784
|
+
sourceKey,
|
|
4610
4785
|
previous.model,
|
|
4611
4786
|
previous.bucketStart,
|
|
4612
4787
|
);
|
|
4613
4788
|
subtractTotals(oldBucket.totals, previousTotals);
|
|
4614
|
-
touchedBuckets.add(bucketKey(
|
|
4789
|
+
touchedBuckets.add(bucketKey(sourceKey, previous.model, previous.bucketStart));
|
|
4615
4790
|
if (projectEnabled && previous.projectKey) {
|
|
4616
4791
|
const oldProjectBucket = getProjectBucket(
|
|
4617
4792
|
projectState,
|
|
4618
4793
|
previous.projectKey,
|
|
4619
|
-
|
|
4794
|
+
sourceKey,
|
|
4620
4795
|
previous.bucketStart,
|
|
4621
4796
|
previous.projectRef || null,
|
|
4622
4797
|
);
|
|
4623
4798
|
subtractTotals(oldProjectBucket.totals, previousTotals);
|
|
4624
4799
|
projectTouchedBuckets.add(
|
|
4625
|
-
projectBucketKey(previous.projectKey,
|
|
4800
|
+
projectBucketKey(previous.projectKey, sourceKey, previous.bucketStart),
|
|
4626
4801
|
);
|
|
4627
4802
|
}
|
|
4628
4803
|
}
|
|
4629
4804
|
|
|
4630
|
-
const bucket = getHourlyBucket(hourlyState,
|
|
4805
|
+
const bucket = getHourlyBucket(hourlyState, sourceKey, model, bucketStart);
|
|
4631
4806
|
addTotals(bucket.totals, currentTotals);
|
|
4632
|
-
touchedBuckets.add(bucketKey(
|
|
4807
|
+
touchedBuckets.add(bucketKey(sourceKey, model, bucketStart));
|
|
4633
4808
|
if (projectEnabled && projectKey) {
|
|
4634
4809
|
const projectBucket = getProjectBucket(
|
|
4635
4810
|
projectState,
|
|
4636
4811
|
projectKey,
|
|
4637
|
-
|
|
4812
|
+
sourceKey,
|
|
4638
4813
|
bucketStart,
|
|
4639
4814
|
projectRef,
|
|
4640
4815
|
);
|
|
4641
4816
|
addTotals(projectBucket.totals, currentTotals);
|
|
4642
|
-
projectTouchedBuckets.add(projectBucketKey(projectKey,
|
|
4817
|
+
projectTouchedBuckets.add(projectBucketKey(projectKey, sourceKey, bucketStart));
|
|
4643
4818
|
}
|
|
4644
4819
|
qoderState.messages[messageKey] = {
|
|
4645
4820
|
totals: currentTotals,
|
|
@@ -4682,7 +4857,7 @@ async function parseQoderDbIncremental({
|
|
|
4682
4857
|
hourlyState.updatedAt = updatedAt;
|
|
4683
4858
|
qoderState.updatedAt = updatedAt;
|
|
4684
4859
|
cursors.hourly = hourlyState;
|
|
4685
|
-
cursors
|
|
4860
|
+
cursors[cursorKey] = qoderState;
|
|
4686
4861
|
if (projectState) {
|
|
4687
4862
|
projectState.updatedAt = updatedAt;
|
|
4688
4863
|
cursors.projectHourly = projectState;
|
|
@@ -12055,18 +12230,25 @@ function resolvePiSessionFiles(env = process.env) {
|
|
|
12055
12230
|
const sessionsDir = path.join(agentDir, "sessions");
|
|
12056
12231
|
if (!fssync.existsSync(sessionsDir)) return [];
|
|
12057
12232
|
const files = [];
|
|
12233
|
+
const walk = (dir) => {
|
|
12234
|
+
let entries;
|
|
12235
|
+
try { entries = fssync.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
12236
|
+
for (const entry of entries) {
|
|
12237
|
+
const full = path.join(dir, entry.name);
|
|
12238
|
+
if (entry.isDirectory()) {
|
|
12239
|
+
walk(full);
|
|
12240
|
+
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
12241
|
+
files.push(full);
|
|
12242
|
+
}
|
|
12243
|
+
}
|
|
12244
|
+
};
|
|
12058
12245
|
try {
|
|
12059
12246
|
for (const cwdDir of fssync.readdirSync(sessionsDir)) {
|
|
12060
12247
|
const cwdPath = path.join(sessionsDir, cwdDir);
|
|
12061
12248
|
let stat;
|
|
12062
12249
|
try { stat = fssync.statSync(cwdPath); } catch { continue; }
|
|
12063
12250
|
if (!stat.isDirectory()) continue;
|
|
12064
|
-
|
|
12065
|
-
try { entries = fssync.readdirSync(cwdPath); } catch { continue; }
|
|
12066
|
-
for (const entry of entries) {
|
|
12067
|
-
if (!entry.endsWith(".jsonl")) continue;
|
|
12068
|
-
files.push(path.join(cwdPath, entry));
|
|
12069
|
-
}
|
|
12251
|
+
walk(cwdPath);
|
|
12070
12252
|
}
|
|
12071
12253
|
} catch {
|
|
12072
12254
|
// ignore — return what we have
|
|
@@ -15816,6 +15998,7 @@ module.exports = {
|
|
|
15816
15998
|
readZcodeDbMessages,
|
|
15817
15999
|
resolveQoderDbPath,
|
|
15818
16000
|
resolveQoderDbPaths,
|
|
16001
|
+
resolveQoderCnDbPaths,
|
|
15819
16002
|
readQoderDbMessages,
|
|
15820
16003
|
resolveKiroBasePath,
|
|
15821
16004
|
resolveKiroDbPath,
|
|
@@ -31,6 +31,7 @@ const readline = require("node:readline");
|
|
|
31
31
|
const { listClaudeProjectFiles, listRolloutFilesDeep, claudeMessageDedupKey } = require("./rollout");
|
|
32
32
|
const { parseCodexRolloutFile } = require("./codex-rollout-parser");
|
|
33
33
|
const { computeRowCost } = require("./pricing");
|
|
34
|
+
const wsl = require("./wsl-probe");
|
|
34
35
|
|
|
35
36
|
// Bump the sidecar when derived metrics change so cached rows are rebuilt
|
|
36
37
|
// instead of leaving the dashboard on the previous (over-counted) heuristic.
|
|
@@ -761,14 +762,105 @@ async function scanGrokSession(filePath) {
|
|
|
761
762
|
});
|
|
762
763
|
}
|
|
763
764
|
|
|
764
|
-
|
|
765
|
+
// Multi-root discovery: on Windows a native install and a WSL one both count.
|
|
766
|
+
// `sync` already walks both Claude homes (src/commands/sync.js), so without this
|
|
767
|
+
// the WSL work lands in the token totals but is invisible in the session browser
|
|
768
|
+
// and its project list. TOKENTRACKER_WSL_MODE gates which sides are probed;
|
|
769
|
+
// duplicated files synced between environments collapse via the Codex session-id
|
|
770
|
+
// pass below and claudeMessageDedupKey downstream.
|
|
771
|
+
function providerRoots(home, providerDir, env, deps = {}) {
|
|
772
|
+
const platform = deps.platform || process.platform;
|
|
773
|
+
const homedir = deps.homedir || os.homedir;
|
|
774
|
+
const discoverWslHome = deps.discoverWslHome || wsl.discoverWslHome;
|
|
775
|
+
const roots = [];
|
|
776
|
+
if (platform !== "win32" || wsl.shouldProbeNative(env)) {
|
|
777
|
+
roots.push(path.join(home, providerDir));
|
|
778
|
+
}
|
|
779
|
+
// Only the machine's own home has a WSL sibling worth probing.
|
|
780
|
+
// discoverWslHome resolves \\wsl$ independently of `home`, so probing for an
|
|
781
|
+
// injected home (tests, a custom HOME) would splice the machine's live WSL
|
|
782
|
+
// sessions into what the caller expects to be an isolated tree.
|
|
783
|
+
//
|
|
784
|
+
// This defaults rather than hard-codes: the whole reason to thread a
|
|
785
|
+
// non-default home is to scan a tree that is not os.homedir(), and such a
|
|
786
|
+
// caller would otherwise lose WSL discovery silently, with nothing failing.
|
|
787
|
+
// `probeWsl` is the deliberate opt-in for that case.
|
|
788
|
+
const probeWsl = deps.probeWsl !== undefined
|
|
789
|
+
? Boolean(deps.probeWsl)
|
|
790
|
+
: path.resolve(home) === path.resolve(homedir());
|
|
791
|
+
if (platform === "win32" && probeWsl && wsl.shouldProbeWsl(env)) {
|
|
792
|
+
const wslRoot = discoverWslHome(providerDir, { env });
|
|
793
|
+
if (wslRoot) roots.push(wslRoot);
|
|
794
|
+
}
|
|
795
|
+
return [...new Set(roots)];
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Collapse the same Claude session discovered under more than one root.
|
|
799
|
+
//
|
|
800
|
+
// Codex gets this from its session-id pass below; Claude only ever had per-file
|
|
801
|
+
// message dedup (`claudeMessageDedupKey` inside `scanClaudeSession`), which
|
|
802
|
+
// cannot see a second copy of the same file under a different path spelling.
|
|
803
|
+
// Every discovered path becomes its own row keyed by the resolved file path, so
|
|
804
|
+
// a WSL `$HOME` pointing at the Windows profile — the same files reachable as
|
|
805
|
+
// both `C:\Users\dev\.claude\...` and `\\wsl$\Ubuntu\home\dev\.claude\...` —
|
|
806
|
+
// duplicated sessions in the browser, the project list and the CSV export.
|
|
807
|
+
//
|
|
808
|
+
// Cross-root only, on purpose: a single root is passed through verbatim, so
|
|
809
|
+
// single-install machines are unaffected, and two same-named files inside one
|
|
810
|
+
// tree stay distinct (unproven identity must not delete a session). A basename
|
|
811
|
+
// without a session UUID never participates either.
|
|
812
|
+
function dedupeClaudeFilesAcrossRoots(groups) {
|
|
813
|
+
const rootGroups = (groups || []).filter((group) => Array.isArray(group) && group.length > 0);
|
|
814
|
+
if (rootGroups.length <= 1) return [...new Set(rootGroups[0] || [])];
|
|
815
|
+
|
|
816
|
+
const mtimeOf = (filePath) => {
|
|
817
|
+
try { return fs.statSync(filePath).mtimeMs; } catch { return -Infinity; }
|
|
818
|
+
};
|
|
819
|
+
// Preserve root order for everything kept, so native precedence is stable.
|
|
820
|
+
// Claims are compared only against EARLIER roots: two same-UUID files inside
|
|
821
|
+
// one tree are siblings, not copies, and must both survive.
|
|
822
|
+
const ordered = [];
|
|
823
|
+
const winnerBySession = new Map();
|
|
824
|
+
for (const group of rootGroups) {
|
|
825
|
+
const claimedHere = new Map();
|
|
826
|
+
for (const filePath of group) {
|
|
827
|
+
const id = path.basename(filePath).match(/^([0-9a-f-]{36})\.jsonl$/i)?.[1] || null;
|
|
828
|
+
if (!id || claimedHere.has(id)) {
|
|
829
|
+
// No session identity, or a sibling within this same root.
|
|
830
|
+
if (!ordered.includes(filePath)) ordered.push(filePath);
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
claimedHere.set(id, filePath);
|
|
834
|
+
const previous = winnerBySession.get(id);
|
|
835
|
+
if (previous === undefined) {
|
|
836
|
+
winnerBySession.set(id, filePath);
|
|
837
|
+
ordered.push(filePath);
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
if (previous === filePath) continue;
|
|
841
|
+
// Same session in an earlier root: keep the newer file, mirroring Codex.
|
|
842
|
+
if (mtimeOf(filePath) > mtimeOf(previous)) {
|
|
843
|
+
winnerBySession.set(id, filePath);
|
|
844
|
+
ordered[ordered.indexOf(previous)] = filePath;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return [...new Set(ordered)];
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
async function discoverSessionFiles(home, env = process.env, deps = {}) {
|
|
765
852
|
const grokHome = resolveGrokHome(home);
|
|
766
|
-
const
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
853
|
+
const claudeRoots = providerRoots(home, ".claude", env, deps);
|
|
854
|
+
const codexRoots = providerRoots(home, ".codex", env, deps);
|
|
855
|
+
const [claudeGroups, codexGroups, archivedGroups, grok] = await Promise.all([
|
|
856
|
+
Promise.all(claudeRoots.map((r) => listClaudeProjectFiles(path.join(r, "projects")))),
|
|
857
|
+
Promise.all(codexRoots.map((r) => listRolloutFilesDeep(path.join(r, "sessions")))),
|
|
858
|
+
Promise.all(codexRoots.map((r) => listRolloutFilesDeep(path.join(r, "archived_sessions")))),
|
|
770
859
|
listGrokSessionFiles(path.join(grokHome, "sessions")),
|
|
771
860
|
]);
|
|
861
|
+
const allClaude = dedupeClaudeFilesAcrossRoots(claudeGroups);
|
|
862
|
+
const codex = [...new Set(codexGroups.flat())];
|
|
863
|
+
const archived = [...new Set(archivedGroups.flat())];
|
|
772
864
|
// Claude Memory stores thousands of background observer transcripts beside
|
|
773
865
|
// real Claude Code sessions. They contain <synthetic>/haiku bookkeeping and
|
|
774
866
|
// no user coding outcome, so scanning them both slows the card dramatically
|
|
@@ -1262,4 +1354,6 @@ module.exports = {
|
|
|
1262
1354
|
listSessionsForBrowser,
|
|
1263
1355
|
resumeCommandFor,
|
|
1264
1356
|
sessionsToCsv,
|
|
1357
|
+
providerRoots,
|
|
1358
|
+
dedupeClaudeFilesAcrossRoots,
|
|
1265
1359
|
};
|