tokentracker-cli 0.84.9 → 0.86.0

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.
Files changed (109) hide show
  1. package/README.de.md +5 -4
  2. package/README.ja.md +5 -4
  3. package/README.ko.md +5 -4
  4. package/README.md +8 -7
  5. package/README.zh-CN.md +5 -4
  6. package/dashboard/dist/assets/{AchievementBadge-BgBFpxmU.js → AchievementBadge-BgbH_mX4.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-DHPD3Fnu.js → AchievementsPage-CQqkh1JS.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-DouWVD-3.js → AchievementsSection-Dmkxpn0s.js} +1 -1
  9. package/dashboard/dist/assets/{BadgeDetailModal-Df9eQiA3.js → BadgeDetailModal-7OQf37lk.js} +1 -1
  10. package/dashboard/dist/assets/{Card-CQ9wBnz6.js → Card-1PmWZRTK.js} +1 -1
  11. package/dashboard/dist/assets/{ClawdAnimated-XIO-I-5v.js → ClawdAnimated-CMJFagpX.js} +1 -1
  12. package/dashboard/dist/assets/{CommandPalette-Cy1cJ91B.js → CommandPalette-D4OrwF1w.js} +1 -1
  13. package/dashboard/dist/assets/{CommunityStatsModal-CwKG4LQM.js → CommunityStatsModal-BDC9EcwZ.js} +1 -1
  14. package/dashboard/dist/assets/{ConfirmModal-BNcWqick.js → ConfirmModal-22DL5C_9.js} +1 -1
  15. package/dashboard/dist/assets/DashboardPage-CEXWzDtt.js +1 -0
  16. package/dashboard/dist/assets/{DevicePage-pJ2jNkZV.js → DevicePage-8uhHB7kX.js} +1 -1
  17. package/dashboard/dist/assets/{DialogClose-C2SOtkJN.js → DialogClose-BcJx6VK-.js} +1 -1
  18. package/dashboard/dist/assets/DialogDescription-D6zxtOM5.js +1 -0
  19. package/dashboard/dist/assets/{DialogTitle-CGSbB8G7.js → DialogTitle-gRzunAqv.js} +1 -1
  20. package/dashboard/dist/assets/{FadeIn-CZSiw7me.js → FadeIn-4rlPdBVe.js} +1 -1
  21. package/dashboard/dist/assets/{HeaderGithubStar-ZmQhX_kR.js → HeaderGithubStar-CR003aO3.js} +1 -1
  22. package/dashboard/dist/assets/{HoverTooltip-D_YdG369.js → HoverTooltip-Dgz-dM8Q.js} +1 -1
  23. package/dashboard/dist/assets/{IpCheckPage-du886dF0.js → IpCheckPage-B4PJRJP_.js} +1 -1
  24. package/dashboard/dist/assets/{LandingPage-BuAak8lU.js → LandingPage-3JQe-jOZ.js} +2 -2
  25. package/dashboard/dist/assets/{LeaderboardAvatar-nU0ARYnN.js → LeaderboardAvatar-CL9LxiEf.js} +1 -1
  26. package/dashboard/dist/assets/{LeaderboardPage-CngwCvC7.js → LeaderboardPage-DhIzM2Z2.js} +2 -2
  27. package/dashboard/dist/assets/{LeaderboardProfileModal-CXZJ0e-T.js → LeaderboardProfileModal-bbf5uJR5.js} +3 -3
  28. package/dashboard/dist/assets/{LeaderboardProfilePage-CZve6s6b.js → LeaderboardProfilePage-CzuPxscw.js} +1 -1
  29. package/dashboard/dist/assets/{LimitsPage-CWvoJPk8.js → LimitsPage-B59rb2gg.js} +1 -1
  30. package/dashboard/dist/assets/{LocalOnlyNotice-DyUAAyyE.js → LocalOnlyNotice-BkK-6n1R.js} +1 -1
  31. package/dashboard/dist/assets/{LoginCard-eEiRfbdv.js → LoginCard-BFGCzEjE.js} +1 -1
  32. package/dashboard/dist/assets/{LoginModal-C16WdIQm.js → LoginModal-yhkyscFn.js} +1 -1
  33. package/dashboard/dist/assets/{LoginPage-B2WqpE1t.js → LoginPage-DwU5s0F7.js} +1 -1
  34. package/dashboard/dist/assets/{PetPage-DtQWrGmI.js → PetPage-BPscYR6z.js} +1 -1
  35. package/dashboard/dist/assets/{PopoverPopup-CRhqtFLB.js → PopoverPopup-E7PyGTTB.js} +1 -1
  36. package/dashboard/dist/assets/{ProviderIcon-DWNH_wYu.js → ProviderIcon-CvmnzwKU.js} +1 -1
  37. package/dashboard/dist/assets/{ResetPasswordPage-DzCczcWZ.js → ResetPasswordPage-DD6qX616.js} +1 -1
  38. package/dashboard/dist/assets/{SegmentedControl-CV9ccq6R.js → SegmentedControl-DjGBL_l1.js} +1 -1
  39. package/dashboard/dist/assets/{Select-BrogNNk0.js → Select-B2MGmsi2.js} +1 -1
  40. package/dashboard/dist/assets/{SelectItemText-Bpu3swt0.js → SelectItemText-BaTwA_ZF.js} +1 -1
  41. package/dashboard/dist/assets/{ServiceStatusPage-D_FIrMJP.js → ServiceStatusPage-0K0y8A6m.js} +1 -1
  42. package/dashboard/dist/assets/{SessionsPage-C8TvTy1o.js → SessionsPage-CxrtRm3B.js} +2 -2
  43. package/dashboard/dist/assets/{SettingsPage-Bd-tzJAR.js → SettingsPage-CyvSWvDP.js} +1 -1
  44. package/dashboard/dist/assets/{SkillsPage-CKGU9wQ4.js → SkillsPage-BVOU8t3t.js} +1 -1
  45. package/dashboard/dist/assets/{TokenGalaxy-DhHW6L-U.js → TokenGalaxy-w7bwGloC.js} +1 -1
  46. package/dashboard/dist/assets/{ToolbarRootContext-DQo-4pPB.js → ToolbarRootContext-BAzpDZej.js} +1 -1
  47. package/dashboard/dist/assets/{TrendMonitor-Ejl8d45g.js → TrendMonitor-DyT7Q4xp.js} +1 -1
  48. package/dashboard/dist/assets/{WidgetsPage-CY7f4wCD.js → WidgetsPage-eDDtopQT.js} +1 -1
  49. package/dashboard/dist/assets/{WrappedPage-B4-B2v-v.js → WrappedPage-CKkbdWHF.js} +1 -1
  50. package/dashboard/dist/assets/{__vite-browser-external-BNUFXCfp.js → __vite-browser-external-SU8sCTLB.js} +23 -16
  51. package/dashboard/dist/assets/{agent-logos-BRzutmeR.js → agent-logos-bkh9XZJd.js} +1 -1
  52. package/dashboard/dist/assets/{arrow-up-right-BcrbPk8c.js → arrow-up-right-D16zCIw8.js} +1 -1
  53. package/dashboard/dist/assets/{check-BMxeFPfQ.js → check-BFL17JWQ.js} +1 -1
  54. package/dashboard/dist/assets/{chevron-right-DOstgiRf.js → chevron-right-CUJgrtkp.js} +1 -1
  55. package/dashboard/dist/assets/{copy-BsKO524t.js → copy-CRQiCjgs.js} +1 -1
  56. package/dashboard/dist/assets/{download-dadqxej7.js → download-Cd12nDrQ.js} +1 -1
  57. package/dashboard/dist/assets/{external-link-CTu3BMeU.js → external-link-CvAfnsG-.js} +1 -1
  58. package/dashboard/dist/assets/{flame-B_IvWcMn.js → flame-CCi681Yl.js} +1 -1
  59. package/dashboard/dist/assets/{format-tokens-DIT6hLSb.js → format-tokens-BAVt8uTZ.js} +1 -1
  60. package/dashboard/dist/assets/{icons-mWku76bu.js → icons-CbKC96gR.js} +1 -1
  61. package/dashboard/dist/assets/{index-D5-w-aGy.js → index--TDvaL1y.js} +1 -1
  62. package/dashboard/dist/assets/{index-D-gQV-hF.js → index-B5b6PCh5.js} +1 -1
  63. package/dashboard/dist/assets/{index-CboN9E82.js → index-B9LWn-mo.js} +1 -1
  64. package/dashboard/dist/assets/{info-x6l352SW.js → info-BPFpD--M.js} +1 -1
  65. package/dashboard/dist/assets/{limits-providers-Bo12VlLJ.js → limits-providers-Cyedt-WJ.js} +1 -1
  66. package/dashboard/dist/assets/{link-2-eo_xPxH1.js → link-2-BkEPPdfC.js} +1 -1
  67. package/dashboard/dist/assets/{loader-circle-C4n1iGBv.js → loader-circle-CzASiylJ.js} +1 -1
  68. package/dashboard/dist/assets/{maximize-2-pR8xwsI_.js → maximize-2-CkwnHUJn.js} +1 -1
  69. package/dashboard/dist/assets/{provider-display-ErggQWu4.js → provider-display-BXBBedC-.js} +2 -2
  70. package/dashboard/dist/assets/{react-DYKg02Aq.js → react-BXSctDQj.js} +1 -1
  71. package/dashboard/dist/assets/{search-B_jbUwPS.js → search-_qGma9tM.js} +1 -1
  72. package/dashboard/dist/assets/{skills-api-DY5ruGmw.js → skills-api-DdmJjnJ9.js} +1 -1
  73. package/dashboard/dist/assets/{store-Cu8i1y26.js → store-owYCN530.js} +1 -1
  74. package/dashboard/dist/assets/{terminal-D3j6X68s.js → terminal-Clzw-KEF.js} +1 -1
  75. package/dashboard/dist/assets/{trash-2-CFpwgJw5.js → trash-2-C5Giwp_E.js} +1 -1
  76. package/dashboard/dist/assets/{use-community-stats-C0fKq66G.js → use-community-stats-Dk8UjLEr.js} +1 -1
  77. package/dashboard/dist/assets/{use-limits-display-prefs-TOp4UAnI.js → use-limits-display-prefs-CArM595Q.js} +1 -1
  78. package/dashboard/dist/assets/{use-native-settings-BZie_7z1.js → use-native-settings-Cpd69kiF.js} +1 -1
  79. package/dashboard/dist/assets/{use-ordered-list-Bq92YyUp.js → use-ordered-list--CzwhBTx.js} +1 -1
  80. package/dashboard/dist/assets/use-reduced-motion-CcILnpU1.js +1 -0
  81. package/dashboard/dist/assets/{use-session-efficiency-pref-BcpytkES.js → use-session-efficiency-pref-DUXO8cCg.js} +1 -1
  82. package/dashboard/dist/assets/{use-transform-Dmmrne2z.js → use-transform-Cg3kuMj_.js} +1 -1
  83. package/dashboard/dist/assets/{use-usage-limits-Cw0Z9Tew.js → use-usage-limits-DY9_mV28.js} +1 -1
  84. package/dashboard/dist/assets/{useCurrency-DyyonINj.js → useCurrency-BIe86hMn.js} +1 -1
  85. package/dashboard/dist/assets/{useScrollLock-DvWw86lJ.js → useScrollLock-7-hejkNC.js} +1 -1
  86. package/dashboard/dist/assets/{useTokenFormat-CBkvGfJ1.js → useTokenFormat-DEtLbRgQ.js} +1 -1
  87. package/dashboard/dist/assets/{zap-CnYA39Ug.js → zap-DUxY1d-O.js} +1 -1
  88. package/dashboard/dist/index.html +12 -10
  89. package/dashboard/dist/ip-check.html +1 -1
  90. package/dashboard/dist/leaderboard.html +1 -1
  91. package/dashboard/dist/llms.txt +3 -2
  92. package/dashboard/dist/share.html +1 -1
  93. package/package.json +2 -2
  94. package/src/commands/init.js +1 -0
  95. package/src/commands/status.js +129 -9
  96. package/src/commands/sync.js +265 -54
  97. package/src/lib/claude-categorizer.js +36 -9
  98. package/src/lib/diagnostics.js +86 -13
  99. package/src/lib/doctor.js +17 -0
  100. package/src/lib/grok-context-breakdown.js +794 -0
  101. package/src/lib/install-resolver.js +10 -2
  102. package/src/lib/local-api.js +21 -3
  103. package/src/lib/multi-install-parser.js +9 -1
  104. package/src/lib/pricing/seed-snapshot.json +1 -1
  105. package/src/lib/rollout.js +788 -44
  106. package/src/lib/session-analytics.js +332 -9
  107. package/dashboard/dist/assets/DashboardPage-Dns_1yXL.js +0 -1
  108. package/dashboard/dist/assets/DialogDescription-Bhfqqmll.js +0 -1
  109. package/dashboard/dist/assets/use-reduced-motion-ZiVeOkvb.js +0 -1
@@ -714,6 +714,30 @@ function formatLocalDate(value) {
714
714
  return `${year}-${month}-${day}`;
715
715
  }
716
716
 
717
+ // Identity hash for cross-environment duplicate session files (native and
718
+ // \\wsl$ views of a synced ~/.claude). sha256 of the first 64KB plus the
719
+ // file size: Claude session headers carry sessionId/cwd so the prefix is
720
+ // discriminating, and folding in the size keeps a grown file distinct from
721
+ // a stale copy. Deliberately excludes inode (unstable across the 9p bridge)
722
+ // and mtime (rewritten by sync tools).
723
+ const CLAUDE_FILE_ID_PREFIX_BYTES = 64 * 1024;
724
+ async function claudeFileIdentityHash(filePath, st) {
725
+ const length = Math.min(CLAUDE_FILE_ID_PREFIX_BYTES, st.size);
726
+ if (length <= 0) return null;
727
+ let fh = null;
728
+ try {
729
+ fh = await fs.open(filePath, "r");
730
+ const buf = Buffer.alloc(length);
731
+ const { bytesRead } = await fh.read(buf, 0, length, 0);
732
+ const hash = crypto.createHash("sha256").update(buf.subarray(0, bytesRead)).digest("hex");
733
+ return `${hash}:${st.size}`;
734
+ } catch (_e) {
735
+ return null;
736
+ } finally {
737
+ if (fh) await fh.close().catch(() => { });
738
+ }
739
+ }
740
+
717
741
  async function parseClaudeIncremental({
718
742
  projectFiles,
719
743
  cursors,
@@ -747,6 +771,22 @@ async function parseClaudeIncremental({
747
771
  cursors.files = {};
748
772
  }
749
773
 
774
+ // Cross-environment file dedup (#307): when the scan list mixes native and
775
+ // \\wsl$ paths, a session file synced between the environments shows up
776
+ // twice under different paths. Identify never-parsed files by content hash
777
+ // and skip the duplicate wholesale — the message-hash layer alone is capped
778
+ // at 100k entries, and eviction would let bulk history double count.
779
+ let sawUncFile = false;
780
+ let sawLocalFile = false;
781
+ for (const entry of files) {
782
+ const p = typeof entry === "string" ? entry : entry?.path;
783
+ if (typeof p !== "string" || !p) continue;
784
+ if (isUncPath(p)) sawUncFile = true;
785
+ else sawLocalFile = true;
786
+ if (sawUncFile && sawLocalFile) break;
787
+ }
788
+ const seenFileIds = sawUncFile && sawLocalFile ? new Map() : null;
789
+
750
790
  for (let idx = 0; idx < files.length; idx++) {
751
791
  const entry = files[idx];
752
792
  const filePath = typeof entry === "string" ? entry : entry?.path;
@@ -766,6 +806,59 @@ async function parseClaudeIncremental({
766
806
  const truncated = sameInode && prevOffset > st.size;
767
807
  const startOffset = sameInode && !truncated ? prevOffset : 0;
768
808
 
809
+ let fileId = null;
810
+ if (seenFileIds) {
811
+ const cachedFileId =
812
+ sameInode && typeof prev?.fileId === "string" && prev?.fileIdSize === st.size
813
+ ? prev.fileId
814
+ : null;
815
+ fileId = cachedFileId || (await claudeFileIdentityHash(filePath, st));
816
+ if (fileId) {
817
+ // Cache on the live cursor object so idle files don't re-hash on
818
+ // every sync (the idle short-circuit below never rewrites cursors).
819
+ if (prev && (prev.fileId !== fileId || prev.fileIdSize !== st.size)) {
820
+ prev.fileId = fileId;
821
+ prev.fileIdSize = st.size;
822
+ }
823
+ const primary = seenFileIds.get(fileId);
824
+ if (primary === undefined) {
825
+ seenFileIds.set(fileId, filePath);
826
+ } else if (!prev && primary !== filePath) {
827
+ // Never-parsed duplicate of a file already accounted this run (the
828
+ // native copy sorts first). Mark it caught-up so future syncs only
829
+ // look at genuinely new tail bytes — message hashes still guard the
830
+ // tail if the copies diverge later.
831
+ cursors.files[key] = {
832
+ inode,
833
+ offset: st.size,
834
+ updatedAt: new Date().toISOString(),
835
+ fileId,
836
+ fileIdSize: st.size,
837
+ duplicateOf: primary,
838
+ ...(projectEnabled
839
+ ? {
840
+ claudeCwd: null,
841
+ projectFileContext: buildProjectFileContext(null),
842
+ projectRef: null,
843
+ projectKey: null,
844
+ }
845
+ : {}),
846
+ };
847
+ if (cb) {
848
+ cb({
849
+ index: idx + 1,
850
+ total: totalFiles,
851
+ filePath,
852
+ filesProcessed,
853
+ eventsAggregated,
854
+ bucketsQueued: touchedBuckets.size,
855
+ });
856
+ }
857
+ continue;
858
+ }
859
+ }
860
+ }
861
+
769
862
  // Claude's launch cwd is fixed for a session file's lifetime, so once
770
863
  // resolved (found, or confirmed absent) from content it never needs
771
864
  // re-extracting. Cache it — and the resolved project's git-config
@@ -850,6 +943,7 @@ async function parseClaudeIncremental({
850
943
  inode,
851
944
  offset: result.endOffset,
852
945
  updatedAt: new Date().toISOString(),
946
+ ...(fileId ? { fileId, fileIdSize: st.size } : {}),
853
947
  ...(projectEnabled
854
948
  ? {
855
949
  claudeCwd: nextCwd === undefined ? null : nextCwd,
@@ -1205,6 +1299,85 @@ async function parseOpencodeIncremental({
1205
1299
  return { filesProcessed, eventsAggregated, bucketsQueued, projectBucketsQueued };
1206
1300
  }
1207
1301
 
1302
+ // Passive discovery of OpenClaw session transcripts (issue #264). OpenClaw was
1303
+ // previously counted ONLY when its session plugin fired an `agent_end` hook and
1304
+ // pointed sync at one specific <sessionId>.jsonl. That hook silently fails for a
1305
+ // whole class of real usage — messages arriving through a channel (e.g. the
1306
+ // WeChat ClawBot) whose sessionKey the plugin can't map back to a sessions.json
1307
+ // entry, gateways where the plugin never loaded, or newer installs that keep
1308
+ // runtime rows in SQLite and only leave archived transcripts on disk. In all of
1309
+ // those cases usage exists but shows as 0. Scanning the on-disk transcripts on
1310
+ // every full sync (like every other passive provider) closes that gap; the
1311
+ // event-identity dedup in parseOpenclawSessionFile makes a plugin trigger and a
1312
+ // passive scan of the same file idempotent, so the two paths never double-count.
1313
+ // Mirrors OpenClaw's own precedence (OPENCLAW_HOME > state dir override), but
1314
+ // deliberately keeps os.homedir() — NOT env.HOME — as the base. OpenClaw itself
1315
+ // prefers env.HOME, yet every OpenClaw cursor we have ever written was keyed off
1316
+ // an os.homedir()-derived path; switching the base would make Git Bash / MSYS
1317
+ // (HOME=/c/Users/x vs C:\Users\x) miss its own cursor and re-count the whole
1318
+ // transcript history.
1319
+ function resolveOpenclawHome(env = process.env) {
1320
+ const override =
1321
+ (typeof env.TOKENTRACKER_OPENCLAW_HOME === "string" && env.TOKENTRACKER_OPENCLAW_HOME.trim()) ||
1322
+ (typeof env.OPENCLAW_HOME === "string" && env.OPENCLAW_HOME.trim()) ||
1323
+ (typeof env.OPENCLAW_STATE_DIR === "string" && env.OPENCLAW_STATE_DIR.trim()) ||
1324
+ "";
1325
+ return override || path.join(os.homedir(), ".openclaw");
1326
+ }
1327
+
1328
+ // On Windows the recommended OpenClaw install provisions an app-owned WSL
1329
+ // distro and runs the gateway inside it, so %USERPROFILE%\.openclaw is empty
1330
+ // while the real state dir sits on the distro's ext4 home (issue #264).
1331
+ function resolveOpenclawHomes(env = process.env, deps = {}) {
1332
+ const roots = [resolveOpenclawHome(env)];
1333
+ const platform = deps.platform || process.platform;
1334
+ const overridden =
1335
+ env.TOKENTRACKER_OPENCLAW_HOME || env.OPENCLAW_HOME || env.OPENCLAW_STATE_DIR;
1336
+ if (platform === "win32" && !overridden) {
1337
+ const discoverWslHome = deps.discoverWslHome || wsl.discoverWslHome;
1338
+ const wslRoot = wsl.shouldProbeWsl(env) ? discoverWslHome(".openclaw", { ...deps, env }) : null;
1339
+ if (wslRoot) roots.push(wslRoot);
1340
+ }
1341
+ return roots;
1342
+ }
1343
+
1344
+ // Transcript artifacts are not always a bare `<sessionId>.jsonl`: resets and
1345
+ // deletes leave `<sessionId>.jsonl.reset.<iso>` / `.deleted.<ts>` siblings, and
1346
+ // the SQLite migration moves still-hot transcripts into
1347
+ // `session-sqlite-import-archive/`. Match the same `*.jsonl*` shape other
1348
+ // OpenClaw readers use so those are not silently dropped.
1349
+ const OPENCLAW_TRANSCRIPT_SUBDIRS = ["sessions", "session-sqlite-import-archive"];
1350
+
1351
+ function isOpenclawTranscriptName(name) {
1352
+ return typeof name === "string" && name.includes(".jsonl") && !name.endsWith(".json");
1353
+ }
1354
+
1355
+ async function resolveOpenclawSessionFiles(env = process.env, deps = {}) {
1356
+ const out = [];
1357
+ const seen = new Set();
1358
+ for (const openclawHome of resolveOpenclawHomes(env, deps)) {
1359
+ const agentsDir = path.join(openclawHome, "agents");
1360
+ const agents = await safeReadDir(agentsDir);
1361
+ for (const agent of agents) {
1362
+ if (!agent.isDirectory()) continue;
1363
+ for (const subdir of OPENCLAW_TRANSCRIPT_SUBDIRS) {
1364
+ const dir = path.join(agentsDir, agent.name, subdir);
1365
+ const entries = await safeReadDir(dir);
1366
+ for (const entry of entries) {
1367
+ if (!entry.isFile() || !isOpenclawTranscriptName(entry.name)) continue;
1368
+ const full = path.join(dir, entry.name);
1369
+ const key = openclawCursorKey(full);
1370
+ if (seen.has(key)) continue;
1371
+ seen.add(key);
1372
+ out.push(full);
1373
+ }
1374
+ }
1375
+ }
1376
+ }
1377
+ out.sort((a, b) => a.localeCompare(b));
1378
+ return out;
1379
+ }
1380
+
1208
1381
  async function parseOpenclawIncremental({
1209
1382
  sessionFiles,
1210
1383
  cursors,
@@ -1368,6 +1541,20 @@ async function parseOpenclawIncremental({
1368
1541
  usageFingerprint: accepted.usageFingerprint,
1369
1542
  updatedAt: new Date().toISOString(),
1370
1543
  usageEvents: accepted.usageEvents,
1544
+ // Sticky: once a transcript has yielded real per-event usage, the
1545
+ // sessions.json totals fallback must defer to it to avoid double
1546
+ // counting the same tokens twice (see applyOpenclawTotalsFallback).
1547
+ //
1548
+ // The usageEvents check is what makes this work for EXISTING installs:
1549
+ // cursors written before this flag existed carry no `hasRealUsage`, and a
1550
+ // steady-state sync of an unchanged transcript aggregates 0 new events —
1551
+ // so keying off eventsAggregated alone would leave the flag false forever
1552
+ // and let the fallback keep double counting for exactly the users who
1553
+ // already have OpenClaw history.
1554
+ hasRealUsage:
1555
+ Boolean(prev?.hasRealUsage) ||
1556
+ accepted.result.eventsAggregated > 0 ||
1557
+ Object.keys(accepted.usageEvents || {}).length > 0,
1371
1558
  };
1372
1559
  cursorKeys.set(key, key);
1373
1560
 
@@ -2075,7 +2262,10 @@ async function parseClaudeFile({
2075
2262
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
2076
2263
 
2077
2264
  let eventsAggregated = 0;
2078
- const isMainSession = !filePath.includes("/subagents/");
2265
+ // Separator-agnostic: WSL installs are scanned over \\wsl$ UNC paths where
2266
+ // path.join produces backslashes, and a forward-slash-only check would
2267
+ // misclassify subagent transcripts as main sessions (#307).
2268
+ const isMainSession = !/[\\/]subagents[\\/]/.test(filePath);
2079
2269
  for await (const line of rl) {
2080
2270
  if (!line) continue;
2081
2271
 
@@ -2095,13 +2285,25 @@ async function parseClaudeFile({
2095
2285
  typeof content === "string" ||
2096
2286
  (Array.isArray(content) && content.some((b) => b?.type === "text"));
2097
2287
  if (hasText) {
2098
- const userTs = typeof userObj?.timestamp === "string" ? userObj.timestamp : null;
2099
- const userBucketStart = userTs ? toUtcHalfHourStart(userTs) : null;
2100
- if (userBucketStart) {
2101
- const userModel = DEFAULT_MODEL;
2102
- const userBucket = getHourlyBucket(hourlyState, source, userModel, userBucketStart);
2103
- userBucket.totals.conversation_count += 1;
2104
- touchedBuckets.add(bucketKey(source, userModel, userBucketStart));
2288
+ // Dedup by the line's uuid so a synced copy of the session file
2289
+ // (WSL mirror of a divergent native file, inode-reset re-read)
2290
+ // cannot re-count the conversation. Usage rows get the same
2291
+ // guarantee from claudeMessageDedupKey; user lines have no
2292
+ // message.id, so the line uuid is the identity.
2293
+ const userKey =
2294
+ seenMessageHashes && typeof userObj?.uuid === "string" && userObj.uuid
2295
+ ? `u:${userObj.uuid}`
2296
+ : null;
2297
+ if (!userKey || !seenMessageHashes.has(userKey)) {
2298
+ if (userKey) seenMessageHashes.add(userKey);
2299
+ const userTs = typeof userObj?.timestamp === "string" ? userObj.timestamp : null;
2300
+ const userBucketStart = userTs ? toUtcHalfHourStart(userTs) : null;
2301
+ if (userBucketStart) {
2302
+ const userModel = DEFAULT_MODEL;
2303
+ const userBucket = getHourlyBucket(hourlyState, source, userModel, userBucketStart);
2304
+ userBucket.totals.conversation_count += 1;
2305
+ touchedBuckets.add(bucketKey(source, userModel, userBucketStart));
2306
+ }
2105
2307
  }
2106
2308
  }
2107
2309
  }
@@ -4420,6 +4622,356 @@ async function parseQoderDbIncremental({
4420
4622
  return { messagesProcessed, eventsAggregated, bucketsQueued, projectBucketsQueued };
4421
4623
  }
4422
4624
 
4625
+ // ── Claude Science (Anthropic's local research workbench, issue #246) ──
4626
+ //
4627
+ // Claude Science keeps all of its state in a SQLite database named
4628
+ // `operon-cli.db`. There is no native Windows build — on Windows the app runs
4629
+ // INSIDE WSL, so the DB lives on the distro's ext4 home, not under %USERPROFILE%
4630
+ // (see resolveClaudeScienceDbPaths).
4631
+ //
4632
+ // Per-frame token usage lives on the `frames` table. A row is one agent
4633
+ // session/sub-session and records its OWN counters; Claude Science's own
4634
+ // per-conversation rollup (`sumTokensForRoot`) plainly sums every row under a
4635
+ // root, so summing across frames never double-counts a parent's children.
4636
+ //
4637
+ // TWO load-bearing quirks, both verified against real frames by reproducing
4638
+ // Claude Science's own cost function to 6 significant figures:
4639
+ //
4640
+ // 1. `input_tokens` is OpenAI-style — it ALREADY INCLUDES cache reads and
4641
+ // writes. Claude Science's accumulator adds `totalInputTokens`
4642
+ // (= cacheRead + cacheWrite + uncached), not the Anthropic API's
4643
+ // `input_tokens`. Treating the column as pure non-cached input prices cache
4644
+ // reads at the full input rate and inflates cost ~5.6x, while also counting
4645
+ // the cache columns twice in total_tokens (~1.97x). Subtract them back out.
4646
+ // 2. `aux_*` are real, billable usage from background helper calls (rolling
4647
+ // compaction, compaction summaries, artifact provenance extraction,
4648
+ // biosecurity screening, memory extraction). They are DISJOINT from the
4649
+ // headline columns — the summarizer call is issued with
4650
+ // `skip_cost_accumulation: true` so it bypasses the main accumulator — and
4651
+ // Claude Science's own rollup adds them in. Dropping them under-counts by
4652
+ // ~11.5%. `aux_input_tokens` carries the same cache-inclusive convention.
4653
+ //
4654
+ // Known approximation: aux tokens get priced at the frame's headline model
4655
+ // rate, but the helper calls actually run on a cheaper model, and Claude
4656
+ // Science does not persist which one (nor a per-aux cache split — the
4657
+ // aux_cache_* columns come back NULL even when aux_cost implies cache
4658
+ // reads). Measured on real frames, that overprices the aux slice ~2.4x,
4659
+ // i.e. ~20% high on the frame total. Counting the tokens and accepting a
4660
+ // bounded cost skew beats dropping 11.5% of usage outright, but if Claude
4661
+ // Science ever records the aux model, price that slice separately.
4662
+ //
4663
+ // `token_class_usage` is deliberately NOT read: it is an attribution *slice* of
4664
+ // the same tokens (assistant_prose / tool_calls / thinking / …), so its classes
4665
+ // sum back to the columns above and adding it would double count.
4666
+ // The cache and aux columns arrived in later drizzle migrations, so build the
4667
+ // SELECT from PRAGMA table_info rather than naming them blindly — an older
4668
+ // operon-cli.db must degrade to the columns it has instead of erroring the
4669
+ // whole provider out.
4670
+ //
4671
+ // The usage predicate doubles as the seed-frame filter: a fresh install ships
4672
+ // four Anthropic demo frames whose token columns are all NULL. Their
4673
+ // `context_data` JSON still carries the demo run's real numbers (~23.6M tokens
4674
+ // / $28.5), which is exactly why this parser reads columns and never that blob.
4675
+ const CLAUDE_SCIENCE_TOKEN_COLUMNS = [
4676
+ "input_tokens",
4677
+ "output_tokens",
4678
+ "cache_read_tokens",
4679
+ "cache_write_tokens",
4680
+ "aux_input_tokens",
4681
+ "aux_output_tokens",
4682
+ "aux_cache_read_tokens",
4683
+ "aux_cache_write_tokens",
4684
+ ];
4685
+
4686
+ function buildClaudeScienceFramesQuery(columnNames) {
4687
+ const columns = new Set(columnNames);
4688
+ const optional = (col) => (columns.has(col) ? col : `NULL AS ${col}`);
4689
+ const present = CLAUDE_SCIENCE_TOKEN_COLUMNS.filter((col) => columns.has(col));
4690
+ const where = present.length
4691
+ ? `WHERE ${present.map((col) => `COALESCE(${col}, 0) <> 0`).join("\n OR ")}`
4692
+ : "WHERE 0";
4693
+ return `
4694
+ SELECT
4695
+ id,
4696
+ parent_frame_id,
4697
+ model,
4698
+ ${CLAUDE_SCIENCE_TOKEN_COLUMNS.map(optional).join(",\n ")},
4699
+ created_at,
4700
+ ${optional("updated_at")},
4701
+ ${optional("completed_at")}
4702
+ FROM frames
4703
+ ${where}
4704
+ ORDER BY created_at, id
4705
+ `;
4706
+ }
4707
+
4708
+ // `operon.db` is the pre-rename filename; both are still probed.
4709
+ const CLAUDE_SCIENCE_DB_FILENAMES = ["operon-cli.db", "operon.db"];
4710
+
4711
+ function resolveClaudeScienceRoot({ home = os.homedir(), env = process.env } = {}) {
4712
+ const override =
4713
+ typeof env.CLAUDE_SCIENCE_HOME === "string" && env.CLAUDE_SCIENCE_HOME.trim()
4714
+ ? path.resolve(env.CLAUDE_SCIENCE_HOME.trim())
4715
+ : null;
4716
+ return override || path.join(home, ".claude-science");
4717
+ }
4718
+
4719
+ function claudeScienceDbsUnderRoot(root) {
4720
+ const out = [];
4721
+ if (!root) return out;
4722
+ for (const name of CLAUDE_SCIENCE_DB_FILENAMES) {
4723
+ const candidate = path.join(root, name);
4724
+ if (fssync.existsSync(candidate)) out.push(candidate);
4725
+ }
4726
+ // Multi-org installs keep one DB per org under orgs/<slug>/.
4727
+ const orgsDir = path.join(root, "orgs");
4728
+ let orgs = [];
4729
+ try {
4730
+ orgs = fssync.readdirSync(orgsDir, { withFileTypes: true });
4731
+ } catch (_e) {
4732
+ orgs = [];
4733
+ }
4734
+ for (const org of orgs) {
4735
+ if (!org.isDirectory()) continue;
4736
+ for (const name of CLAUDE_SCIENCE_DB_FILENAMES) {
4737
+ const candidate = path.join(orgsDir, org.name, name);
4738
+ if (fssync.existsSync(candidate)) out.push(candidate);
4739
+ }
4740
+ }
4741
+ return out;
4742
+ }
4743
+
4744
+ // Every Claude Science DB on this machine. There is no native Windows build —
4745
+ // Windows users run the app inside WSL, so on win32 the distro home is probed
4746
+ // too (same pattern as the other WSL-aware providers). Returns [] when nothing
4747
+ // is installed.
4748
+ function resolveClaudeScienceDbPaths({ home = os.homedir(), env = process.env, deps = {} } = {}) {
4749
+ const explicit =
4750
+ typeof env.CLAUDE_SCIENCE_DB_PATH === "string" && env.CLAUDE_SCIENCE_DB_PATH.trim()
4751
+ ? path.resolve(env.CLAUDE_SCIENCE_DB_PATH.trim())
4752
+ : null;
4753
+ if (explicit) return fssync.existsSync(explicit) ? [explicit] : [];
4754
+
4755
+ const roots = [resolveClaudeScienceRoot({ home, env })];
4756
+ const platform = deps.platform || process.platform;
4757
+ if (platform === "win32" && !env.CLAUDE_SCIENCE_HOME) {
4758
+ const discoverWslHome = deps.discoverWslHome || wsl.discoverWslHome;
4759
+ const wslRoot = wsl.shouldProbeWsl(env)
4760
+ ? discoverWslHome(".claude-science", { ...deps, env })
4761
+ : null;
4762
+ if (wslRoot) roots.push(wslRoot);
4763
+ }
4764
+
4765
+ const seen = new Set();
4766
+ const out = [];
4767
+ for (const root of roots) {
4768
+ for (const dbPath of claudeScienceDbsUnderRoot(root)) {
4769
+ if (seen.has(dbPath)) continue;
4770
+ seen.add(dbPath);
4771
+ out.push(dbPath);
4772
+ }
4773
+ }
4774
+ return out;
4775
+ }
4776
+
4777
+ // Kept for the default single-install path (and its existing callers/tests):
4778
+ // the conventional location, whether or not it exists yet.
4779
+ function resolveClaudeScienceDbPath({ home = os.homedir(), env = process.env } = {}) {
4780
+ const explicit =
4781
+ typeof env.CLAUDE_SCIENCE_DB_PATH === "string" && env.CLAUDE_SCIENCE_DB_PATH.trim()
4782
+ ? env.CLAUDE_SCIENCE_DB_PATH.trim()
4783
+ : null;
4784
+ if (explicit) return path.resolve(explicit);
4785
+ return path.join(resolveClaudeScienceRoot({ home, env }), "operon-cli.db");
4786
+ }
4787
+
4788
+ async function readClaudeScienceFrames(dbPath, sqliteOptions = {}) {
4789
+ if (!dbPath || !fssync.existsSync(dbPath)) return [];
4790
+ const options = { label: "Claude Science", readOnly: true, ...sqliteOptions };
4791
+
4792
+ // On Windows the DB lives inside WSL and is read over a \\wsl.localhost\ UNC
4793
+ // path. operon-cli.db runs in WAL mode (its -wal/-shm sidecars must be read
4794
+ // consistently with the main file), and SQLite over the 9p/UNC bridge can
4795
+ // fail to open the WAL or read a torn state. Snapshot to a local temp copy
4796
+ // first — same guard every other WSL-aware provider uses — and run BOTH the
4797
+ // PRAGMA probe and the frames read against that one consistent copy.
4798
+ let snapshot = null;
4799
+ let effectiveDbPath = dbPath;
4800
+ if (isUncPath(dbPath)) {
4801
+ try {
4802
+ snapshot = snapshotSqliteDb(dbPath);
4803
+ effectiveDbPath = snapshot.path;
4804
+ } catch (_e) {
4805
+ // Snapshot failed (permissions, transient I/O) — fall through to a direct
4806
+ // read so the non-UNC case never regresses.
4807
+ }
4808
+ }
4809
+
4810
+ try {
4811
+ const pragmaRows = await readSqliteJsonRowsAsync(
4812
+ effectiveDbPath,
4813
+ "PRAGMA table_info(frames)",
4814
+ options,
4815
+ );
4816
+ const columnNames = pragmaRows.map((row) => row?.name).filter(Boolean);
4817
+ // No `frames` table at all (wrong DB / pre-frames build) — not an error.
4818
+ if (columnNames.length === 0) return [];
4819
+ return await readSqliteJsonRowsAsync(
4820
+ effectiveDbPath,
4821
+ buildClaudeScienceFramesQuery(columnNames),
4822
+ options,
4823
+ );
4824
+ } finally {
4825
+ if (snapshot) snapshot.cleanup();
4826
+ }
4827
+ }
4828
+
4829
+ // `input` here is cache-inclusive (see the header note), so peel the cache
4830
+ // columns back off to recover pure non-cached input. Main and aux counters are
4831
+ // clamped independently — mixing them before the subtraction would let one
4832
+ // group's cache mask the other's shortfall.
4833
+ function claudeScienceUncachedInput(input, cacheRead, cacheWrite) {
4834
+ return Math.max(0, toNonNegativeInt(input) - toNonNegativeInt(cacheRead) - toNonNegativeInt(cacheWrite));
4835
+ }
4836
+
4837
+ function normalizeClaudeScienceTokens(row) {
4838
+ const uncachedInput =
4839
+ claudeScienceUncachedInput(row?.input_tokens, row?.cache_read_tokens, row?.cache_write_tokens) +
4840
+ claudeScienceUncachedInput(
4841
+ row?.aux_input_tokens,
4842
+ row?.aux_cache_read_tokens,
4843
+ row?.aux_cache_write_tokens,
4844
+ );
4845
+ const output = toNonNegativeInt(row?.output_tokens) + toNonNegativeInt(row?.aux_output_tokens);
4846
+ const cacheRead =
4847
+ toNonNegativeInt(row?.cache_read_tokens) + toNonNegativeInt(row?.aux_cache_read_tokens);
4848
+ const cacheWrite =
4849
+ toNonNegativeInt(row?.cache_write_tokens) + toNonNegativeInt(row?.aux_cache_write_tokens);
4850
+ const total = uncachedInput + output + cacheRead + cacheWrite;
4851
+ if (total <= 0) return null;
4852
+ return {
4853
+ input_tokens: uncachedInput,
4854
+ cached_input_tokens: cacheRead,
4855
+ cache_creation_input_tokens: cacheWrite,
4856
+ output_tokens: output,
4857
+ reasoning_output_tokens: 0,
4858
+ total_tokens: total,
4859
+ billable_total_tokens: total,
4860
+ };
4861
+ }
4862
+
4863
+ function claudeScienceFrameTimestampMs(row) {
4864
+ return (
4865
+ coerceEpochMs(row?.completed_at) ||
4866
+ coerceEpochMs(row?.updated_at) ||
4867
+ coerceEpochMs(row?.created_at) ||
4868
+ // Tolerate ISO-8601 text timestamps in case a build stores them as strings.
4869
+ parseIsoTimestampMs(row?.completed_at) ||
4870
+ parseIsoTimestampMs(row?.updated_at) ||
4871
+ parseIsoTimestampMs(row?.created_at)
4872
+ );
4873
+ }
4874
+
4875
+ function parseIsoTimestampMs(value) {
4876
+ if (typeof value !== "string" || !value.trim()) return 0;
4877
+ const ms = Date.parse(value.trim());
4878
+ return Number.isFinite(ms) && ms > 0 ? ms : 0;
4879
+ }
4880
+
4881
+ // Idempotent, subtract-on-change aggregation keyed by frame id (mirrors the
4882
+ // Qoder DB parser): frames are re-read in full on every sync, and a frame whose
4883
+ // counters grew between syncs has its previous contribution removed from its old
4884
+ // bucket before the new totals are added, so repeated syncs never inflate.
4885
+ async function parseClaudeScienceIncremental({
4886
+ dbRows,
4887
+ cursors,
4888
+ queuePath,
4889
+ onProgress,
4890
+ } = {}) {
4891
+ await ensureDir(path.dirname(queuePath));
4892
+ const rows = Array.isArray(dbRows) ? dbRows : [];
4893
+ const hourlyState = normalizeHourlyState(cursors?.hourly);
4894
+ const state = normalizeQoderState(cursors?.claudeScience);
4895
+ const touchedBuckets = new Set();
4896
+ const cb = typeof onProgress === "function" ? onProgress : null;
4897
+ let recordsProcessed = 0;
4898
+ let eventsAggregated = 0;
4899
+
4900
+ for (let index = 0; index < rows.length; index++) {
4901
+ const row = rows[index];
4902
+ const frameId = normalizeMessageKeyPart(row?.id == null ? "" : String(row.id));
4903
+ const base = normalizeClaudeScienceTokens(row);
4904
+ const timestampMs = claudeScienceFrameTimestampMs(row);
4905
+ const bucketStart = timestampMs
4906
+ ? toUtcHalfHourStart(new Date(timestampMs).toISOString())
4907
+ : null;
4908
+ if (!frameId || !base || !bucketStart) {
4909
+ recordsProcessed += 1;
4910
+ continue;
4911
+ }
4912
+
4913
+ // A root frame (no parent) is one user-facing conversation; nested child
4914
+ // frames are sub-agent turns of the same conversation and must not inflate
4915
+ // the conversation count.
4916
+ const isRoot = !normalizeMessageKeyPart(
4917
+ row?.parent_frame_id == null ? "" : String(row.parent_frame_id),
4918
+ );
4919
+ const currentTotals = { ...base, conversation_count: isRoot ? 1 : 0 };
4920
+ const model = normalizeModelInput(row?.model) || "claude-science";
4921
+
4922
+ const previous = state.messages[frameId];
4923
+ const previousTotals =
4924
+ previous?.totals && typeof previous.totals === "object" ? previous.totals : null;
4925
+ const unchanged =
4926
+ previousTotals &&
4927
+ totalsKey(previousTotals) === totalsKey(currentTotals) &&
4928
+ previous.bucketStart === bucketStart &&
4929
+ previous.model === model;
4930
+
4931
+ if (!unchanged) {
4932
+ if (previousTotals && previous.bucketStart && previous.model) {
4933
+ const oldBucket = getHourlyBucket(
4934
+ hourlyState,
4935
+ "claude-science",
4936
+ previous.model,
4937
+ previous.bucketStart,
4938
+ );
4939
+ subtractTotals(oldBucket.totals, previousTotals);
4940
+ touchedBuckets.add(bucketKey("claude-science", previous.model, previous.bucketStart));
4941
+ }
4942
+ const bucket = getHourlyBucket(hourlyState, "claude-science", model, bucketStart);
4943
+ addTotals(bucket.totals, currentTotals);
4944
+ touchedBuckets.add(bucketKey("claude-science", model, bucketStart));
4945
+ state.messages[frameId] = {
4946
+ totals: currentTotals,
4947
+ bucketStart,
4948
+ model,
4949
+ updatedAt: new Date().toISOString(),
4950
+ };
4951
+ eventsAggregated += 1;
4952
+ }
4953
+
4954
+ recordsProcessed += 1;
4955
+ if (cb) {
4956
+ cb({
4957
+ index: index + 1,
4958
+ total: rows.length,
4959
+ recordsProcessed,
4960
+ eventsAggregated,
4961
+ bucketsQueued: touchedBuckets.size,
4962
+ });
4963
+ }
4964
+ }
4965
+
4966
+ const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
4967
+ const updatedAt = new Date().toISOString();
4968
+ hourlyState.updatedAt = updatedAt;
4969
+ state.updatedAt = updatedAt;
4970
+ cursors.hourly = hourlyState;
4971
+ cursors.claudeScience = state;
4972
+ return { recordsProcessed, eventsAggregated, bucketsQueued };
4973
+ }
4974
+
4423
4975
  // Resolve project context from DB message (no file path available)
4424
4976
  async function resolveProjectContextForDb({
4425
4977
  msg,
@@ -4554,37 +5106,60 @@ async function parseCursorApiIncremental({
4554
5106
  // Kiro token tracking (reads from devdata.sqlite or tokens_generated.jsonl)
4555
5107
  // ---------------------------------------------------------------------------
4556
5108
 
4557
- function resolveKiroBasePath() {
5109
+ // Kiro IDE (VS Code fork) globalStorage lives under the editor config root,
5110
+ // which differs per platform: %APPDATA% on Windows, ~/.config on Linux/WSL,
5111
+ // ~/Library/Application Support on macOS.
5112
+ function resolveKiroBasePath(env = process.env) {
4558
5113
  const home = require("node:os").homedir();
4559
- return path.join(
4560
- home,
4561
- "Library",
4562
- "Application Support",
4563
- "Kiro",
4564
- "User",
4565
- "globalStorage",
4566
- "kiro.kiroagent",
4567
- );
5114
+ const suffix = ["Kiro", "User", "globalStorage", "kiro.kiroagent"];
5115
+ if (process.platform === "win32") {
5116
+ const appData = typeof env.APPDATA === "string" && env.APPDATA.trim().length > 0
5117
+ ? env.APPDATA.trim()
5118
+ : path.join(home, "AppData", "Roaming");
5119
+ return path.join(appData, ...suffix);
5120
+ }
5121
+ if (process.platform === "linux") {
5122
+ const configHome = typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim().length > 0
5123
+ ? env.XDG_CONFIG_HOME.trim()
5124
+ : path.join(home, ".config");
5125
+ return path.join(configHome, ...suffix);
5126
+ }
5127
+ return path.join(home, "Library", "Application Support", ...suffix);
4568
5128
  }
4569
5129
 
4570
- function resolveKiroDbPath() {
4571
- return path.join(resolveKiroBasePath(), "dev_data", "devdata.sqlite");
5130
+ function resolveKiroDbPath(basePath) {
5131
+ return path.join(basePath || resolveKiroBasePath(), "dev_data", "devdata.sqlite");
4572
5132
  }
4573
5133
 
4574
- function resolveKiroJsonlPath() {
4575
- return path.join(resolveKiroBasePath(), "dev_data", "tokens_generated.jsonl");
5134
+ function resolveKiroJsonlPath(basePath) {
5135
+ return path.join(basePath || resolveKiroBasePath(), "dev_data", "tokens_generated.jsonl");
4576
5136
  }
4577
5137
 
4578
5138
  function readKiroDbTokens(dbPath, sinceId, sqliteOptions = {}) {
4579
5139
  if (!dbPath || !fssync.existsSync(dbPath)) return [];
4580
5140
  const minId = Number.isFinite(sinceId) && sinceId > 0 ? sinceId : 0;
4581
5141
  const sql = `SELECT id, model, provider, tokens_prompt, tokens_generated, timestamp FROM tokens_generated WHERE id > ${minId} ORDER BY id ASC`;
4582
- return readSqliteJsonRows(dbPath, sql, {
4583
- label: "Kiro",
4584
- maxBuffer: 10 * 1024 * 1024,
4585
- timeout: 15_000,
4586
- ...sqliteOptions,
4587
- });
5142
+
5143
+ // WSL installs are read over the \\wsl$ UNC bridge; sqlite3 cannot safely
5144
+ // open WAL databases there, so copy db (+ sidecars) to tmp first.
5145
+ let snapshot = null;
5146
+ let effectiveDbPath = dbPath;
5147
+ if (isUncPath(dbPath)) {
5148
+ try {
5149
+ snapshot = snapshotSqliteDb(dbPath);
5150
+ effectiveDbPath = snapshot.path;
5151
+ } catch (_e) { }
5152
+ }
5153
+ try {
5154
+ return readSqliteJsonRows(effectiveDbPath, sql, {
5155
+ label: "Kiro",
5156
+ maxBuffer: 10 * 1024 * 1024,
5157
+ timeout: 15_000,
5158
+ ...sqliteOptions,
5159
+ });
5160
+ } finally {
5161
+ if (snapshot) snapshot.cleanup();
5162
+ }
4588
5163
  }
4589
5164
 
4590
5165
  // Read Kiro token data from JSONL fallback (tokens_generated.jsonl).
@@ -4726,7 +5301,7 @@ function normalizeKiroModelName(raw) {
4726
5301
  return name || null;
4727
5302
  }
4728
5303
 
4729
- async function parseKiroIncremental({ dbPath, jsonlPath, cursors, queuePath, onProgress, sqliteOptions } = {}) {
5304
+ async function parseKiroIncremental({ basePath, dbPath, jsonlPath, cursors, queuePath, onProgress, sqliteOptions } = {}) {
4730
5305
  await ensureDir(path.dirname(queuePath));
4731
5306
  const kiroState = cursors.kiro && typeof cursors.kiro === "object" ? cursors.kiro : {};
4732
5307
  const lastDbId = typeof kiroState.lastDbId === "number"
@@ -4735,8 +5310,8 @@ async function parseKiroIncremental({ dbPath, jsonlPath, cursors, queuePath, onP
4735
5310
  const jsonlState = kiroState.jsonl && typeof kiroState.jsonl === "object" ? kiroState.jsonl : {};
4736
5311
  const lastJsonlLine = typeof jsonlState.lastLine === "number" ? jsonlState.lastLine : 0;
4737
5312
 
4738
- const resolvedDbPath = dbPath || resolveKiroDbPath();
4739
- const resolvedJsonlPath = jsonlPath || resolveKiroJsonlPath();
5313
+ const resolvedDbPath = dbPath || resolveKiroDbPath(basePath);
5314
+ const resolvedJsonlPath = jsonlPath || resolveKiroJsonlPath(basePath);
4740
5315
 
4741
5316
  // Try SQLite first, fall back to JSONL.
4742
5317
  let rows = [];
@@ -4784,9 +5359,12 @@ async function parseKiroIncremental({ dbPath, jsonlPath, cursors, queuePath, onP
4784
5359
  return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
4785
5360
  }
4786
5361
 
4787
- // Build model timeline from .chat files for model name resolution
4788
- const basePath = resolveKiroBasePath();
4789
- const modelTimeline = buildKiroModelTimeline(basePath);
5362
+ // Build model timeline from .chat files for model name resolution.
5363
+ // Must come from the SAME install as the rows being parsed (dual-install:
5364
+ // a WSL install's rows must not be resolved against native .chat files).
5365
+ const timelineBase = basePath
5366
+ || (dbPath ? path.dirname(path.dirname(dbPath)) : resolveKiroBasePath());
5367
+ const modelTimeline = buildKiroModelTimeline(timelineBase);
4790
5368
 
4791
5369
  const hourlyState = normalizeHourlyState(cursors?.hourly);
4792
5370
  const touchedBuckets = new Set();
@@ -5075,6 +5653,74 @@ function hermesInstallOwnsCursor(hermesPath, flatState, sqliteOptions) {
5075
5653
  return allDbs.some((db) => sqliteDbContainsIds(db, "sessions", sample, sqliteOptions));
5076
5654
  }
5077
5655
 
5656
+ function kiroInstallOwnsCursor(dbPath, flatState, sqliteOptions) {
5657
+ // The flat cursor tracks the max AUTOINCREMENT id it consumed. An install
5658
+ // whose tokens_generated table never reached that id cannot be the flat
5659
+ // cursor's host. Both installs containing the id (short/equal histories)
5660
+ // yields two hits upstream → caller seeds every namespace (safe).
5661
+ const lastDbId = typeof flatState?.lastDbId === "number" && Number.isInteger(flatState.lastDbId) && flatState.lastDbId > 0
5662
+ ? flatState.lastDbId
5663
+ : (typeof flatState?.lastId === "number" && Number.isInteger(flatState.lastId) && flatState.lastId > 0
5664
+ ? flatState.lastId
5665
+ : 0);
5666
+ if (!lastDbId) return false;
5667
+ return sqliteDbContainsIds(dbPath, "tokens_generated", [String(lastDbId)], sqliteOptions);
5668
+ }
5669
+
5670
+ // Kiro CLI request_ids live inside the conversations_v2 JSON payload, not in
5671
+ // an id column — probe via json_each instead of sqliteDbContainsIds.
5672
+ function kiroCliDbContainsRequestIds(dbPath, requestIds, sqliteOptions = {}) {
5673
+ if (!dbPath || !Array.isArray(requestIds) || requestIds.length === 0) return false;
5674
+ if (!fssync.existsSync(dbPath)) return false;
5675
+ const inList = requestIds.map(sqliteStringLiteral).join(",");
5676
+ // Alias both tables: conversations_v2.value and json_each's own `value`
5677
+ // column collide — a bare `value` is "ambiguous column name" and sqlite
5678
+ // errors out (which readSqliteJsonRows swallows into []).
5679
+ const sql =
5680
+ "SELECT 1 FROM conversations_v2 AS c, " +
5681
+ "json_each(json_extract(c.value, '$.user_turn_metadata.requests')) AS je " +
5682
+ `WHERE json_extract(je.value, '$.request_id') IN (${inList}) LIMIT 1`;
5683
+
5684
+ let snapshot = null;
5685
+ let effectiveDbPath = dbPath;
5686
+ if (isUncPath(dbPath)) {
5687
+ try {
5688
+ snapshot = snapshotSqliteDb(dbPath);
5689
+ effectiveDbPath = snapshot.path;
5690
+ } catch (_e) { }
5691
+ }
5692
+ try {
5693
+ const rows = readSqliteJsonRows(effectiveDbPath, sql, {
5694
+ label: "InstallProbe",
5695
+ maxBuffer: 1024 * 1024,
5696
+ timeout: 10_000,
5697
+ ...sqliteOptions,
5698
+ });
5699
+ return Array.isArray(rows) && rows.length > 0;
5700
+ } catch (_e) {
5701
+ return false;
5702
+ } finally {
5703
+ if (snapshot) snapshot.cleanup();
5704
+ }
5705
+ }
5706
+
5707
+ function kiroCliInstallOwnsCursor(dbPath, flatState, sqliteOptions) {
5708
+ // Only SQLite-origin request_id UUIDs are probe evidence: session-file
5709
+ // entries synthesize `${sessionId}:${loopRand}` keys or carry a session_id
5710
+ // tag, and neither appears in conversations_v2. Filter BEFORE sampling so
5711
+ // recent session-file churn cannot blind the probe to older SQLite ids.
5712
+ const requests =
5713
+ flatState?.requests && typeof flatState.requests === "object" ? flatState.requests : {};
5714
+ const sqliteKeyed = {};
5715
+ for (const [k, v] of Object.entries(requests)) {
5716
+ if (k.includes(":")) continue;
5717
+ if (v && typeof v === "object" && typeof v.session_id === "string" && v.session_id) continue;
5718
+ sqliteKeyed[k] = v;
5719
+ }
5720
+ const sample = sampleRecentKeys(sqliteKeyed);
5721
+ return kiroCliDbContainsRequestIds(dbPath, sample, sqliteOptions);
5722
+ }
5723
+
5078
5724
  function hasLegacyHermesDefaultState(hermesState) {
5079
5725
  return (
5080
5726
  typeof hermesState.lastStartedAt === "number" ||
@@ -5307,6 +5953,22 @@ const KIRO_CLI_CREDITS_SIDECAR = "kiro-credits.json";
5307
5953
  function resolveKiroCliDbPath(env = process.env) {
5308
5954
  if (env.KIRO_CLI_DB_PATH) return env.KIRO_CLI_DB_PATH;
5309
5955
  const home = env.HOME || require("node:os").homedir();
5956
+ if (process.platform === "win32") {
5957
+ // Speculative: Kiro CLI descends from Amazon Q CLI, which ships
5958
+ // macOS/Linux only — Windows users run it inside WSL (handled by the
5959
+ // dual-install path in sync). Kept as the conventional %LOCALAPPDATA%
5960
+ // location so a future native build is picked up; harmless when absent.
5961
+ const localAppData = typeof env.LOCALAPPDATA === "string" && env.LOCALAPPDATA.trim().length > 0
5962
+ ? env.LOCALAPPDATA.trim()
5963
+ : path.join(home, "AppData", "Local");
5964
+ return path.join(localAppData, "kiro-cli", "data.sqlite3");
5965
+ }
5966
+ if (process.platform === "linux") {
5967
+ const dataHome = typeof env.XDG_DATA_HOME === "string" && env.XDG_DATA_HOME.trim().length > 0
5968
+ ? env.XDG_DATA_HOME.trim()
5969
+ : path.join(home, ".local", "share");
5970
+ return path.join(dataHome, "kiro-cli", "data.sqlite3");
5971
+ }
5310
5972
  return path.join(home, "Library", "Application Support", "kiro-cli", "data.sqlite3");
5311
5973
  }
5312
5974
 
@@ -5870,6 +6532,7 @@ async function readKiroCliV2SessionTurns(messagesPath) {
5870
6532
 
5871
6533
  async function writeKiroCliCreditsSidecar({
5872
6534
  queuePath,
6535
+ installKey,
5873
6536
  totalCredits,
5874
6537
  recordCount,
5875
6538
  sessionCount,
@@ -5882,15 +6545,64 @@ async function writeKiroCliCreditsSidecar({
5882
6545
  KIRO_CLI_CREDITS_SIDECAR,
5883
6546
  );
5884
6547
  try {
5885
- await writeJson(sidecarPath, {
5886
- version: 1,
5887
- source: "kiro-cli-usage-summary",
6548
+ // Dual-install (#306): keep one entry per install and aggregate at the
6549
+ // top level, so a second install's write never clobbers the first. The
6550
+ // top-level fields keep the v1 shape readKiroCreditsSummary consumes.
6551
+ let installs = {};
6552
+ try {
6553
+ const prev = JSON.parse(await fs.readFile(sidecarPath, "utf8"));
6554
+ if (prev?.version === 1 && prev.installs && typeof prev.installs === "object") {
6555
+ installs = prev.installs;
6556
+ }
6557
+ } catch {
6558
+ // first write or pre-dual-install sidecar — start fresh
6559
+ }
6560
+ // Expire entries whose install stopped syncing (deleted WSL distro,
6561
+ // distro rename / UNC alias flip leaving a duplicate key, mode switched
6562
+ // to *-only). Without this the read-modify-write merge would inflate the
6563
+ // aggregate with phantom installs forever; a 30-day grace keeps entries
6564
+ // alive across transient wsl.exe probe failures.
6565
+ const staleMs = 30 * 24 * 60 * 60 * 1000;
6566
+ for (const [k, entry] of Object.entries(installs)) {
6567
+ const ts = Date.parse(entry?.updated_at || "");
6568
+ if (!Number.isFinite(ts) || Date.now() - ts > staleMs) delete installs[k];
6569
+ }
6570
+ const key = typeof installKey === "string" && installKey ? installKey : "default";
6571
+ installs[key] = {
5888
6572
  total_credits: Number(totalCredits.toFixed(12)),
5889
6573
  record_count: recordCount,
5890
6574
  session_count: sessionCount,
5891
6575
  file_count: fileCount,
5892
6576
  latest_at: latestAt,
5893
6577
  updated_at: new Date().toISOString(),
6578
+ };
6579
+ let aggCredits = 0;
6580
+ let aggRecords = 0;
6581
+ let aggSessions = 0;
6582
+ let aggFiles = 0;
6583
+ let aggLatestAt = null;
6584
+ for (const entry of Object.values(installs)) {
6585
+ aggCredits += Number(entry.total_credits) || 0;
6586
+ aggRecords += Number(entry.record_count) || 0;
6587
+ aggSessions += Number(entry.session_count) || 0;
6588
+ aggFiles += Number(entry.file_count) || 0;
6589
+ if (
6590
+ typeof entry.latest_at === "string" &&
6591
+ (!aggLatestAt || Date.parse(entry.latest_at) > Date.parse(aggLatestAt))
6592
+ ) {
6593
+ aggLatestAt = entry.latest_at;
6594
+ }
6595
+ }
6596
+ await writeJson(sidecarPath, {
6597
+ version: 1,
6598
+ source: "kiro-cli-usage-summary",
6599
+ total_credits: Number(aggCredits.toFixed(12)),
6600
+ record_count: aggRecords,
6601
+ session_count: aggSessions,
6602
+ file_count: aggFiles,
6603
+ latest_at: aggLatestAt,
6604
+ updated_at: new Date().toISOString(),
6605
+ installs,
5894
6606
  });
5895
6607
  await chmod600IfPossible(sidecarPath);
5896
6608
  } catch {
@@ -5953,13 +6665,29 @@ function readKiroCliRequests(dbPath, env = process.env, sqliteOptions = {}) {
5953
6665
  "json_extract(value, '$.user_turn_metadata.requests') AS requests_json " +
5954
6666
  "FROM conversations_v2 " +
5955
6667
  "WHERE json_extract(value, '$.user_turn_metadata.requests') IS NOT NULL";
5956
- const rows = readSqliteJsonRows(dbPath, sql, {
5957
- label: "Kiro CLI",
5958
- env,
5959
- maxBuffer: 128 * 1024 * 1024,
5960
- timeout: 120_000,
5961
- ...sqliteOptions,
5962
- });
6668
+
6669
+ // WSL installs are read over the \\wsl$ UNC bridge; snapshot to tmp so
6670
+ // sqlite3 never opens a WAL database across the 9p bridge.
6671
+ let snapshot = null;
6672
+ let effectiveDbPath = dbPath;
6673
+ if (isUncPath(dbPath)) {
6674
+ try {
6675
+ snapshot = snapshotSqliteDb(dbPath);
6676
+ effectiveDbPath = snapshot.path;
6677
+ } catch (_e) { }
6678
+ }
6679
+ let rows;
6680
+ try {
6681
+ rows = readSqliteJsonRows(effectiveDbPath, sql, {
6682
+ label: "Kiro CLI",
6683
+ env,
6684
+ maxBuffer: 128 * 1024 * 1024,
6685
+ timeout: 120_000,
6686
+ ...sqliteOptions,
6687
+ });
6688
+ } finally {
6689
+ if (snapshot) snapshot.cleanup();
6690
+ }
5963
6691
  const flat = [];
5964
6692
  for (const row of rows) {
5965
6693
  let requests;
@@ -6058,6 +6786,11 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
6058
6786
  }
6059
6787
  await writeKiroCliCreditsSidecar({
6060
6788
  queuePath,
6789
+ // Install identity mirrors resolveKiroCliSessionFiles' sessions home so
6790
+ // dual installs keep separate sidecar entries.
6791
+ installKey:
6792
+ resolvedEnv.KIRO_HOME ||
6793
+ path.join(resolvedEnv.HOME || require("node:os").homedir(), ".kiro"),
6061
6794
  totalCredits: kiroCreditTotal,
6062
6795
  recordCount: kiroCreditRecords,
6063
6796
  sessionCount: kiroCreditSessions,
@@ -14371,6 +15104,7 @@ module.exports = {
14371
15104
  resolveQoderDbPath,
14372
15105
  resolveQoderDbPaths,
14373
15106
  readQoderDbMessages,
15107
+ resolveKiroBasePath,
14374
15108
  resolveKiroDbPath,
14375
15109
  resolveKiroJsonlPath,
14376
15110
  resolveHermesPath,
@@ -14398,12 +15132,22 @@ module.exports = {
14398
15132
  parseQoderDbIncremental,
14399
15133
  openclawCursorKey,
14400
15134
  parseOpenclawIncremental,
15135
+ resolveOpenclawHome,
15136
+ resolveOpenclawHomes,
15137
+ resolveOpenclawSessionFiles,
15138
+ resolveClaudeScienceDbPath,
15139
+ resolveClaudeScienceDbPaths,
15140
+ buildClaudeScienceFramesQuery,
15141
+ readClaudeScienceFrames,
15142
+ parseClaudeScienceIncremental,
14401
15143
  parseCursorApiIncremental,
14402
15144
  parseKiroIncremental,
14403
15145
  parseHermesIncremental,
14404
15146
  gooseInstallOwnsCursor,
14405
15147
  zedInstallOwnsCursor,
14406
15148
  hermesInstallOwnsCursor,
15149
+ kiroInstallOwnsCursor,
15150
+ kiroCliInstallOwnsCursor,
14407
15151
  copilotOtelCursorHasLegacyCliUsage,
14408
15152
  pruneCopilotUsageClaims,
14409
15153
  parseCopilotIncremental,