tokentracker-cli 0.5.70 → 0.5.72

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 (26) hide show
  1. package/README.md +3 -2
  2. package/dashboard/dist/assets/{Card-Bmd_CiIj.js → Card-D_q1XGfK.js} +1 -1
  3. package/dashboard/dist/assets/{DashboardPage-o3xjHuSr.js → DashboardPage-CuBSoNgI.js} +2 -2
  4. package/dashboard/dist/assets/{FadeIn-Dqezp4M6.js → FadeIn-ClpHby-T.js} +1 -1
  5. package/dashboard/dist/assets/{IpCheckPage-Dvo1fWrV.js → IpCheckPage-B5TpHE6G.js} +1 -1
  6. package/dashboard/dist/assets/{LeaderboardPage-sarzdedp.js → LeaderboardPage-BOrtGeIf.js} +2 -2
  7. package/dashboard/dist/assets/LeaderboardProfilePage-C8xexZ08.js +1 -0
  8. package/dashboard/dist/assets/{LimitsPage-BYVQqh0y.js → LimitsPage-CERWQyIU.js} +1 -1
  9. package/dashboard/dist/assets/{SettingsPage-C0bLU9fQ.js → SettingsPage-DUwt4x-H.js} +1 -1
  10. package/dashboard/dist/assets/{WidgetsPage-DH1pg04g.js → WidgetsPage-Cmr2tbD7.js} +1 -1
  11. package/dashboard/dist/assets/{download-Dqdzgt-u.js → download-M8e1PJC-.js} +1 -1
  12. package/dashboard/dist/assets/leaderboard-columns-Dcg9r7R2.js +1 -0
  13. package/dashboard/dist/assets/{main-Vhl9rhYB.js → main-D1VdJk4V.js} +16 -15
  14. package/dashboard/dist/assets/{use-limits-display-prefs-CJuYd6SN.js → use-limits-display-prefs-CSj55sfK.js} +1 -1
  15. package/dashboard/dist/assets/{use-usage-limits-BEbR2ySJ.js → use-usage-limits-BuWINUAm.js} +1 -1
  16. package/dashboard/dist/brand-logos/kimi.svg +1 -0
  17. package/dashboard/dist/index.html +1 -1
  18. package/dashboard/dist/share.html +1 -1
  19. package/package.json +1 -1
  20. package/src/commands/init.js +11 -0
  21. package/src/commands/status.js +9 -0
  22. package/src/commands/sync.js +52 -1
  23. package/src/lib/local-api.js +159 -56
  24. package/src/lib/rollout.js +217 -4
  25. package/dashboard/dist/assets/LeaderboardProfilePage-DsoEom4U.js +0 -1
  26. package/dashboard/dist/assets/leaderboard-columns-eDzDr_Xs.js +0 -1
@@ -1 +1 @@
1
- import{r as a}from"./main-Vhl9rhYB.js";const d=["claude","codex","cursor","gemini","kiro","copilot","antigravity"],v={claude:"Claude",codex:"Codex",cursor:"Cursor",gemini:"Gemini",kiro:"Kiro",copilot:"GitHub Copilot",antigravity:"Antigravity"},k={claude:"/brand-logos/claude-code.svg",codex:"/brand-logos/codex.svg",cursor:"/brand-logos/cursor.svg",gemini:"/brand-logos/gemini.svg",kiro:"/brand-logos/kiro.svg",copilot:"/brand-logos/copilot.svg",antigravity:"/brand-logos/antigravity.svg"},l="tt.limits.providerOrder",g="tt.limits.providerVisibility";function y(){if(typeof window>"u")return[...d];try{const r=window.localStorage.getItem(l);if(!r)return[...d];const s=JSON.parse(r);if(!Array.isArray(s))return[...d];const n=s.filter(c=>d.includes(c));for(const c of d)n.includes(c)||n.push(c);return n}catch{return[...d]}}function m(){const r=Object.fromEntries(d.map(s=>[s,!0]));if(typeof window>"u")return r;try{const s=window.localStorage.getItem(g);if(!s)return r;const n=JSON.parse(s);if(!n||typeof n!="object")return r;const c={...r};for(const u of d)typeof n[u]=="boolean"&&(c[u]=n[u]);return c}catch{return r}}function C(){const[r,s]=a.useState(y),[n,c]=a.useState(m);a.useEffect(()=>{if(!(typeof window>"u"))try{window.localStorage.setItem(l,JSON.stringify(r))}catch{}},[r]),a.useEffect(()=>{if(!(typeof window>"u"))try{window.localStorage.setItem(g,JSON.stringify(n))}catch{}},[n]),a.useEffect(()=>{if(typeof window>"u")return;const o=t=>{t.key===l&&s(y()),t.key===g&&c(m())};return window.addEventListener("storage",o),()=>window.removeEventListener("storage",o)},[]);const u=a.useCallback(o=>{c(t=>({...t,[o]:!t[o]}))},[]),b=a.useCallback(o=>{s(t=>{const e=t.indexOf(o);if(e<=0)return t;const i=[...t];return[i[e-1],i[e]]=[i[e],i[e-1]],i})},[]),p=a.useCallback(o=>{s(t=>{const e=t.indexOf(o);if(e<0||e>=t.length-1)return t;const i=[...t];return[i[e],i[e+1]]=[i[e+1],i[e]],i})},[]),O=a.useCallback((o,t)=>{o!==t&&s(e=>{const i=e.indexOf(o),w=e.indexOf(t);if(i<0||w<0)return e;const f=[...e],[E]=f.splice(i,1);return f.splice(w,0,E),f})},[]),x=a.useCallback(()=>{s([...d]),c(Object.fromEntries(d.map(o=>[o,!0])))},[]),I=a.useMemo(()=>r.filter(o=>n[o]!==!1),[r,n]);return{order:r,visibility:n,visibleOrdered:I,toggle:u,moveUp:b,moveDown:p,moveToward:O,reset:x}}export{k as L,v as a,C as u};
1
+ import{r as a}from"./main-D1VdJk4V.js";const d=["claude","codex","cursor","gemini","kiro","copilot","antigravity"],v={claude:"Claude",codex:"Codex",cursor:"Cursor",gemini:"Gemini",kiro:"Kiro",copilot:"GitHub Copilot",antigravity:"Antigravity"},k={claude:"/brand-logos/claude-code.svg",codex:"/brand-logos/codex.svg",cursor:"/brand-logos/cursor.svg",gemini:"/brand-logos/gemini.svg",kiro:"/brand-logos/kiro.svg",copilot:"/brand-logos/copilot.svg",antigravity:"/brand-logos/antigravity.svg"},l="tt.limits.providerOrder",g="tt.limits.providerVisibility";function y(){if(typeof window>"u")return[...d];try{const r=window.localStorage.getItem(l);if(!r)return[...d];const s=JSON.parse(r);if(!Array.isArray(s))return[...d];const n=s.filter(c=>d.includes(c));for(const c of d)n.includes(c)||n.push(c);return n}catch{return[...d]}}function m(){const r=Object.fromEntries(d.map(s=>[s,!0]));if(typeof window>"u")return r;try{const s=window.localStorage.getItem(g);if(!s)return r;const n=JSON.parse(s);if(!n||typeof n!="object")return r;const c={...r};for(const u of d)typeof n[u]=="boolean"&&(c[u]=n[u]);return c}catch{return r}}function C(){const[r,s]=a.useState(y),[n,c]=a.useState(m);a.useEffect(()=>{if(!(typeof window>"u"))try{window.localStorage.setItem(l,JSON.stringify(r))}catch{}},[r]),a.useEffect(()=>{if(!(typeof window>"u"))try{window.localStorage.setItem(g,JSON.stringify(n))}catch{}},[n]),a.useEffect(()=>{if(typeof window>"u")return;const o=t=>{t.key===l&&s(y()),t.key===g&&c(m())};return window.addEventListener("storage",o),()=>window.removeEventListener("storage",o)},[]);const u=a.useCallback(o=>{c(t=>({...t,[o]:!t[o]}))},[]),b=a.useCallback(o=>{s(t=>{const e=t.indexOf(o);if(e<=0)return t;const i=[...t];return[i[e-1],i[e]]=[i[e],i[e-1]],i})},[]),p=a.useCallback(o=>{s(t=>{const e=t.indexOf(o);if(e<0||e>=t.length-1)return t;const i=[...t];return[i[e],i[e+1]]=[i[e+1],i[e]],i})},[]),O=a.useCallback((o,t)=>{o!==t&&s(e=>{const i=e.indexOf(o),w=e.indexOf(t);if(i<0||w<0)return e;const f=[...e],[E]=f.splice(i,1);return f.splice(w,0,E),f})},[]),x=a.useCallback(()=>{s([...d]),c(Object.fromEntries(d.map(o=>[o,!0])))},[]),I=a.useMemo(()=>r.filter(o=>n[o]!==!1),[r,n]);return{order:r,visibility:n,visibleOrdered:I,toggle:u,moveUp:b,moveDown:p,moveToward:O,reset:x}}export{k as L,v as a,C as u};
@@ -1 +1 @@
1
- import{r,aM as l}from"./main-Vhl9rhYB.js";function y(i){const[o,a]=r.useState(null),[u,s]=r.useState(null),[c,f]=r.useState(!0),n=!!i?.initialRefresh,g=r.useCallback(async()=>{try{const e=await l({refresh:!0});a(e&&typeof e=="object"?e:null),s(null)}catch(e){s(e?.message||String(e))}},[]);return r.useEffect(()=>{let e=!1;return(async()=>{try{const t=await l(n?{refresh:!0}:{});if(e)return;a(t&&typeof t=="object"?t:null),s(null)}catch(t){if(e)return;s(t?.message||String(t))}finally{e||f(!1)}})(),()=>{e=!0}},[n]),{data:o,error:u,isLoading:c,refresh:g}}export{y as u};
1
+ import{r,aM as l}from"./main-D1VdJk4V.js";function y(i){const[o,a]=r.useState(null),[u,s]=r.useState(null),[c,f]=r.useState(!0),n=!!i?.initialRefresh,g=r.useCallback(async()=>{try{const e=await l({refresh:!0});a(e&&typeof e=="object"?e:null),s(null)}catch(e){s(e?.message||String(e))}},[]);return r.useEffect(()=>{let e=!1;return(async()=>{try{const t=await l(n?{refresh:!0}:{});if(e)return;a(t&&typeof t=="object"?t:null),s(null)}catch(t){if(e)return;s(t?.message||String(t))}finally{e||f(!1)}})(),()=>{e=!0}},[n]),{data:o,error:u,isLoading:c,refresh:g}}export{y as u};
@@ -0,0 +1 @@
1
+ <svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z"></path></svg>
@@ -135,7 +135,7 @@
135
135
  ]
136
136
  }
137
137
  </script>
138
- <script type="module" crossorigin src="/assets/main-Vhl9rhYB.js"></script>
138
+ <script type="module" crossorigin src="/assets/main-D1VdJk4V.js"></script>
139
139
  <link rel="stylesheet" crossorigin href="/assets/main-CNzfq4Ln.css">
140
140
  </head>
141
141
  <body>
@@ -51,7 +51,7 @@
51
51
  "description": "Shareable Token Tracker dashboard snapshot."
52
52
  }
53
53
  </script>
54
- <script type="module" crossorigin src="/assets/main-Vhl9rhYB.js"></script>
54
+ <script type="module" crossorigin src="/assets/main-D1VdJk4V.js"></script>
55
55
  <link rel="stylesheet" crossorigin href="/assets/main-CNzfq4Ln.css">
56
56
  </head>
57
57
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokentracker-cli",
3
- "version": "0.5.70",
3
+ "version": "0.5.72",
4
4
  "description": "Token usage tracker for AI agent CLIs (Claude Code, Codex, Cursor, Kiro, Gemini, OpenCode, OpenClaw, Hermes, GitHub Copilot)",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
@@ -404,6 +404,17 @@ async function applyIntegrationSetup({ home, trackerDir, notifyPath, notifyOrigi
404
404
  summary.push({ label: "Cursor", status: "skipped", detail: "Not installed" });
405
405
  }
406
406
 
407
+ // Kimi: passive reader — no hook installation needed.
408
+ // TokenTracker reads ~/.kimi/sessions/**/wire.jsonl directly.
409
+ {
410
+ const kimiHome = process.env.KIMI_HOME || path.join(home, ".kimi");
411
+ const kimiSessions = path.join(kimiHome, "sessions");
412
+ const fssync = require("node:fs");
413
+ if (fssync.existsSync(kimiSessions)) {
414
+ summary.push({ label: "Kimi Code", status: "detected", detail: "Passive reader (no hook needed)" });
415
+ }
416
+ }
417
+
407
418
  const openclawBefore = await probeOpenclawSessionPluginState({
408
419
  home,
409
420
  trackerDir,
@@ -1,6 +1,7 @@
1
1
  const os = require("node:os");
2
2
  const path = require("node:path");
3
3
  const fs = require("node:fs/promises");
4
+ const fssync = require("node:fs");
4
5
 
5
6
  const { readJson } = require("../lib/fs");
6
7
  const { readCodexNotify, readEveryCodeNotify } = require("../lib/codex-config");
@@ -19,6 +20,7 @@ const { collectTrackerDiagnostics } = require("../lib/diagnostics");
19
20
  const { probeOpenclawHookState } = require("../lib/openclaw-hook");
20
21
  const { probeOpenclawSessionPluginState } = require("../lib/openclaw-session-plugin");
21
22
  const { resolveTrackerPaths } = require("../lib/tracker-paths");
23
+ const { resolveKimiWireFiles } = require("../lib/rollout");
22
24
 
23
25
  async function cmdStatus(argv = []) {
24
26
  const opts = parseArgs(argv);
@@ -112,6 +114,10 @@ async function cmdStatus(argv = []) {
112
114
  const subscriptionLines =
113
115
  subscriptions.length > 0 ? subscriptions.map(formatSubscriptionLine) : [];
114
116
 
117
+ const kimiWireFiles = resolveKimiWireFiles(process.env);
118
+ const kimiHome = process.env.KIMI_HOME || path.join(home, ".kimi");
119
+ const kimiInstalled = fssync.existsSync(path.join(kimiHome, "sessions"));
120
+
115
121
  const copilotToken = readCopilotOauthToken({ home });
116
122
  const copilotOtel = describeCopilotOtelStatus({ home, env: process.env });
117
123
  const copilotLines = formatCopilotLines({ token: copilotToken, otel: copilotOtel });
@@ -138,6 +144,9 @@ async function cmdStatus(argv = []) {
138
144
  `- Opencode plugin: ${opencodePluginConfigured ? "set" : "unset"}`,
139
145
  `- OpenClaw session plugin: ${openclawSessionPluginState?.configured ? "set" : "unset"}`,
140
146
  `- OpenClaw hook (legacy): ${openclawHookState?.configured ? "set" : "unset"}`,
147
+ kimiInstalled
148
+ ? `- Kimi Code: passive reader (${kimiWireFiles.length} wire.jsonl file${kimiWireFiles.length !== 1 ? "s" : ""} found)`
149
+ : null,
141
150
  ...copilotLines,
142
151
  ...subscriptionLines,
143
152
  "",
@@ -25,11 +25,16 @@ const {
25
25
  parseKiroIncremental,
26
26
  parseHermesIncremental,
27
27
  parseCopilotIncremental,
28
+ resolveKimiWireFiles,
29
+ parseKimiIncremental,
28
30
  } = require("../lib/rollout");
29
31
  const { createProgress, renderBar, formatNumber, formatBytes } = require("../lib/progress");
30
32
  const {
31
33
  normalizeState: normalizeUploadState,
32
34
  decideAutoUpload,
35
+ recordUploadFailure,
36
+ recordUploadSuccess,
37
+ parseRetryAfterMs,
33
38
  } = require("../lib/upload-throttle");
34
39
  const {
35
40
  isCursorInstalled,
@@ -354,6 +359,28 @@ async function cmdSync(argv) {
354
359
  });
355
360
  }
356
361
 
362
+ // ── Kimi (passive wire.jsonl reader) ──
363
+ let kimiResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
364
+ const kimiWireFiles = resolveKimiWireFiles(process.env);
365
+ if (kimiWireFiles.length > 0) {
366
+ if (progress?.enabled) {
367
+ progress.start(`Parsing Kimi Code ${renderBar(0)} | buckets 0`);
368
+ }
369
+ kimiResult = await parseKimiIncremental({
370
+ wireFiles: kimiWireFiles,
371
+ cursors,
372
+ queuePath,
373
+ env: process.env,
374
+ onProgress: (p) => {
375
+ if (!progress?.enabled) return;
376
+ const pct = p.total > 0 ? p.index / p.total : 1;
377
+ progress.update(
378
+ `Parsing Kimi Code ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} files | buckets ${formatNumber(p.bucketsQueued)}`,
379
+ );
380
+ },
381
+ });
382
+ }
383
+
357
384
  // ── GitHub Copilot CLI (OTEL JSONL files) ──
358
385
  let copilotResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
359
386
  const copilotPaths = resolveCopilotOtelPaths(process.env);
@@ -412,7 +439,24 @@ async function cmdSync(argv) {
412
439
  maxBatches: opts.drain ? 100 : 5,
413
440
  batchSize: 200,
414
441
  });
442
+ // Record success so the exponential backoff step resets — otherwise
443
+ // a single past failure keeps us pessimistically throttled forever.
444
+ uploadThrottleState = recordUploadSuccess({
445
+ nowMs: Date.now(),
446
+ state: uploadThrottleState,
447
+ });
448
+ await writeJson(uploadThrottlePath, uploadThrottleState);
415
449
  } catch (e) {
450
+ // Persist a backoff on 429 / 5xx so the next auto-sync waits instead
451
+ // of retrying immediately and making the rate-limit worse. The
452
+ // throttle module already parses Retry-After when we surface it on
453
+ // the error object (drainQueueToCloud stamps err.status + err.retryAfterMs).
454
+ uploadThrottleState = recordUploadFailure({
455
+ nowMs: Date.now(),
456
+ state: uploadThrottleState,
457
+ error: e,
458
+ });
459
+ await writeJson(uploadThrottlePath, uploadThrottleState);
416
460
  if (!opts.auto) {
417
461
  process.stderr.write(`Upload error: ${e?.message || e}\n`);
418
462
  }
@@ -453,6 +497,7 @@ async function cmdSync(argv) {
453
497
  cursorResult.recordsProcessed +
454
498
  kiroResult.recordsProcessed +
455
499
  hermesResult.recordsProcessed +
500
+ kimiResult.recordsProcessed +
456
501
  copilotResult.recordsProcessed;
457
502
  const totalBuckets =
458
503
  parseResult.bucketsQueued +
@@ -463,6 +508,7 @@ async function cmdSync(argv) {
463
508
  cursorResult.bucketsQueued +
464
509
  kiroResult.bucketsQueued +
465
510
  hermesResult.bucketsQueued +
511
+ kimiResult.bucketsQueued +
466
512
  copilotResult.bucketsQueued;
467
513
  process.stdout.write(
468
514
  [
@@ -822,7 +868,12 @@ async function drainQueueToCloud({ baseUrl, deviceToken, queuePath, queueStatePa
822
868
  let data = {};
823
869
  try { data = JSON.parse(rawText); } catch { data = {}; }
824
870
  if (!res.ok) {
825
- throw new Error(`HTTP ${res.status}: ${rawText.substring(0, 500)}`);
871
+ const err = new Error(`HTTP ${res.status}: ${rawText.substring(0, 500)}`);
872
+ err.status = res.status;
873
+ const retryAfter = res.headers?.get?.("Retry-After") ?? null;
874
+ const retryAfterMs = parseRetryAfterMs(retryAfter);
875
+ if (retryAfterMs !== null) err.retryAfterMs = retryAfterMs;
876
+ throw err;
826
877
  }
827
878
 
828
879
  inserted += Number(data?.inserted || 0);
@@ -116,30 +116,90 @@ function resolveQueuePath() {
116
116
  return path.join(home, ".tokentracker", "tracker", "queue.jsonl");
117
117
  }
118
118
 
119
- function readQueueData(queuePath) {
119
+ function readProjectQueueData(projectQueuePath) {
120
+ let raw;
120
121
  try {
121
- const raw = fs.readFileSync(queuePath, "utf8");
122
- const lines = raw.split("\n").filter((l) => l.trim());
123
- const parsed = lines.map((l) => JSON.parse(l));
124
- // Deduplicate: each sync appends cumulative totals per bucket, so for
125
- // each (source, model, hour_start) keep only the latest (last) entry.
126
- const seen = new Map();
127
- for (const row of parsed) {
128
- const key = `${row.source || ""}|${row.model || ""}|${row.hour_start || ""}`;
122
+ raw = fs.readFileSync(projectQueuePath, "utf8");
123
+ } catch (e) {
124
+ if (e?.code !== "ENOENT") {
125
+ console.error("[LocalAPI] readProjectQueueData: failed to read:", e?.message || e);
126
+ }
127
+ return [];
128
+ }
129
+ const lines = raw.split("\n").filter((l) => l.trim());
130
+ const seen = new Map();
131
+ for (const line of lines) {
132
+ try {
133
+ const row = JSON.parse(line);
134
+ const key = `${row.project_key || ""}|${row.source || ""}|${row.hour_start || ""}`;
129
135
  seen.set(key, row);
136
+ } catch {
137
+ // skip malformed
138
+ }
139
+ }
140
+ return Array.from(seen.values());
141
+ }
142
+
143
+ function readQueueData(queuePath) {
144
+ let raw;
145
+ try {
146
+ raw = fs.readFileSync(queuePath, "utf8");
147
+ } catch (e) {
148
+ // ENOENT is legitimate (never synced yet); anything else is a signal we
149
+ // don't want to hide behind an empty array forever — the dashboard would
150
+ // otherwise render "0 tokens" with no clue the queue was unreadable.
151
+ if (e?.code !== "ENOENT") {
152
+ console.error("[LocalAPI] readQueueData: failed to read queue:", e?.message || e);
130
153
  }
131
- return Array.from(seen.values());
132
- } catch (_e) {
133
154
  return [];
134
155
  }
156
+ const lines = raw.split("\n").filter((l) => l.trim());
157
+ // Parse row-by-row so a single corrupted line (partial write, disk-full
158
+ // truncation, …) does not wipe out every other row with it.
159
+ const parsed = [];
160
+ let malformed = 0;
161
+ for (const line of lines) {
162
+ try {
163
+ parsed.push(JSON.parse(line));
164
+ } catch {
165
+ malformed += 1;
166
+ }
167
+ }
168
+ if (malformed > 0) {
169
+ console.error(
170
+ `[LocalAPI] readQueueData: skipped ${malformed}/${lines.length} malformed line(s) in ${queuePath}`,
171
+ );
172
+ }
173
+ // Deduplicate: each sync appends cumulative totals per bucket, so for
174
+ // each (source, model, hour_start) keep only the latest (last) entry.
175
+ const seen = new Map();
176
+ for (const row of parsed) {
177
+ const key = `${row.source || ""}|${row.model || ""}|${row.hour_start || ""}`;
178
+ seen.set(key, row);
179
+ }
180
+ return Array.from(seen.values());
135
181
  }
136
182
 
137
- function aggregateByDay(rows) {
183
+ function rowDayKey(row, timeZoneContext) {
184
+ const hs = row.hour_start;
185
+ if (!hs) return "";
186
+ if (
187
+ timeZoneContext &&
188
+ (timeZoneContext.timeZone || Number.isFinite(timeZoneContext.offsetMinutes))
189
+ ) {
190
+ const parts = getZonedParts(new Date(hs), timeZoneContext);
191
+ const key = formatPartsDayKey(parts);
192
+ if (key) return key;
193
+ }
194
+ return hs.slice(0, 10);
195
+ }
196
+
197
+ function aggregateByDay(rows, timeZoneContext = null) {
138
198
  const byDay = new Map();
139
199
  for (const row of rows) {
140
- const hs = row.hour_start;
141
- if (!hs) continue;
142
- const day = hs.slice(0, 10);
200
+ if (!row.hour_start) continue;
201
+ const day = rowDayKey(row, timeZoneContext);
202
+ if (!day) continue;
143
203
  if (!byDay.has(day)) {
144
204
  byDay.set(day, {
145
205
  day,
@@ -674,8 +734,9 @@ function createLocalApiHandler({ queuePath }) {
674
734
  if (p === "/functions/tokentracker-usage-summary") {
675
735
  const from = url.searchParams.get("from") || "";
676
736
  const to = url.searchParams.get("to") || "";
737
+ const timeZoneContext = getTimeZoneContext(url);
677
738
  const rows = readQueueData(qp);
678
- const daily = aggregateByDay(rows).filter((d) => d.day >= from && d.day <= to);
739
+ const daily = aggregateByDay(rows, timeZoneContext).filter((d) => d.day >= from && d.day <= to);
679
740
  const totals = daily.reduce(
680
741
  (acc, r) => {
681
742
  acc.total_tokens += r.total_tokens;
@@ -693,16 +754,19 @@ function createLocalApiHandler({ queuePath }) {
693
754
  );
694
755
  const totalCost = totals.total_cost_usd;
695
756
 
696
- const today = new Date();
697
- const todayStr = today.toISOString().slice(0, 10);
698
- const allDaily = aggregateByDay(rows);
757
+ const todayParts = getZonedParts(new Date(), timeZoneContext);
758
+ const todayStr = formatPartsDayKey(todayParts) || new Date().toISOString().slice(0, 10);
759
+ const allDaily = aggregateByDay(rows, timeZoneContext);
699
760
 
761
+ const shiftDay = (dayStr, delta) => {
762
+ const d = new Date(`${dayStr}T00:00:00Z`);
763
+ d.setUTCDate(d.getUTCDate() + delta);
764
+ return d.toISOString().slice(0, 10);
765
+ };
700
766
  const collectDays = (n) => {
701
767
  const out = [];
702
768
  for (let i = n - 1; i >= 0; i--) {
703
- const d = new Date(today);
704
- d.setUTCDate(d.getUTCDate() - i);
705
- const ds = d.toISOString().slice(0, 10);
769
+ const ds = shiftDay(todayStr, -i);
706
770
  const dd = allDaily.find((x) => x.day === ds);
707
771
  if (dd) out.push(dd);
708
772
  }
@@ -719,17 +783,15 @@ function createLocalApiHandler({ queuePath }) {
719
783
  const l30 = collectDays(30);
720
784
  const l7t = sumDays(l7);
721
785
  const l30t = sumDays(l30);
722
- const l7from = new Date(today);
723
- l7from.setUTCDate(l7from.getUTCDate() - 6);
724
- const l30from = new Date(today);
725
- l30from.setUTCDate(l30from.getUTCDate() - 29);
786
+ const l7fromStr = shiftDay(todayStr, -6);
787
+ const l30fromStr = shiftDay(todayStr, -29);
726
788
 
727
789
  json(res, {
728
790
  from, to, days: daily.length,
729
791
  totals: { ...totals, total_cost_usd: totalCost.toFixed(6) },
730
792
  rolling: {
731
- last_7d: { from: l7from.toISOString().slice(0, 10), to: todayStr, active_days: l7.length, totals: l7t },
732
- last_30d: { from: l30from.toISOString().slice(0, 10), to: todayStr, active_days: l30.length, totals: l30t, avg_per_active_day: l30.length > 0 ? Math.round(l30t.billable_total_tokens / l30.length) : 0 },
793
+ last_7d: { from: l7fromStr, to: todayStr, active_days: l7.length, totals: l7t },
794
+ last_30d: { from: l30fromStr, to: todayStr, active_days: l30.length, totals: l30t, avg_per_active_day: l30.length > 0 ? Math.round(l30t.billable_total_tokens / l30.length) : 0 },
733
795
  },
734
796
  });
735
797
  return true;
@@ -739,8 +801,9 @@ function createLocalApiHandler({ queuePath }) {
739
801
  if (p === "/functions/tokentracker-usage-daily") {
740
802
  const from = url.searchParams.get("from") || "";
741
803
  const to = url.searchParams.get("to") || "";
804
+ const timeZoneContext = getTimeZoneContext(url);
742
805
  const rows = readQueueData(qp);
743
- const daily = aggregateByDay(rows).filter((d) => d.day >= from && d.day <= to);
806
+ const daily = aggregateByDay(rows, timeZoneContext).filter((d) => d.day >= from && d.day <= to);
744
807
  json(res, { from, to, data: daily });
745
808
  return true;
746
809
  }
@@ -748,10 +811,12 @@ function createLocalApiHandler({ queuePath }) {
748
811
  // --- usage-heatmap ---
749
812
  if (p === "/functions/tokentracker-usage-heatmap") {
750
813
  const weeks = parseInt(url.searchParams.get("weeks") || "52", 10);
814
+ const timeZoneContext = getTimeZoneContext(url);
751
815
  const rows = readQueueData(qp);
752
- const daily = aggregateByDay(rows);
753
- const today = new Date();
754
- const end = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()));
816
+ const daily = aggregateByDay(rows, timeZoneContext);
817
+ const todayParts = getZonedParts(new Date(), timeZoneContext);
818
+ const todayStr = formatPartsDayKey(todayParts) || new Date().toISOString().slice(0, 10);
819
+ const end = new Date(`${todayStr}T00:00:00Z`);
755
820
  const start = new Date(end);
756
821
  start.setUTCDate(start.getUTCDate() - weeks * 7 + 1);
757
822
  const from = start.toISOString().slice(0, 10);
@@ -792,9 +857,10 @@ function createLocalApiHandler({ queuePath }) {
792
857
  if (p === "/functions/tokentracker-usage-model-breakdown") {
793
858
  const from = url.searchParams.get("from") || "";
794
859
  const to = url.searchParams.get("to") || "";
860
+ const timeZoneContext = getTimeZoneContext(url);
795
861
  const rows = readQueueData(qp).filter((r) => {
796
862
  if (!r.hour_start) return false;
797
- const d = r.hour_start.slice(0, 10);
863
+ const d = rowDayKey(r, timeZoneContext);
798
864
  return d >= from && d <= to;
799
865
  });
800
866
 
@@ -852,35 +918,71 @@ function createLocalApiHandler({ queuePath }) {
852
918
 
853
919
  // --- project-usage-summary ---
854
920
  if (p === "/functions/tokentracker-project-usage-summary") {
855
- const projectMap = new Map();
856
- scanCodexProjects(projectMap);
857
- scanClaudeProjects(projectMap);
858
-
859
- const rows = readQueueData(qp);
860
- const totalTokens = rows.reduce((s, r) => s + (r.total_tokens || 0), 0);
861
- const entries = [];
921
+ // Use the per-project bucket log that rollout.js emits — it already
922
+ // carries the actual tokens attributed to each (project_key, source,
923
+ // hour_start). Falling back to "session-file count × total tokens"
924
+ // (the old behavior) produced pure fiction: every short-and-hot
925
+ // project got the same weight as every long-and-cold one.
926
+ const projectQueuePath = path.join(
927
+ path.dirname(qp),
928
+ "project.queue.jsonl",
929
+ );
930
+ const projectRows = readProjectQueueData(projectQueuePath);
931
+
932
+ const byProject = new Map();
933
+ for (const row of projectRows) {
934
+ const key = row.project_key || "unknown";
935
+ if (!byProject.has(key)) {
936
+ byProject.set(key, {
937
+ project_key: key,
938
+ project_ref: row.project_ref || key,
939
+ total_tokens: 0,
940
+ billable_total_tokens: 0,
941
+ });
942
+ }
943
+ const agg = byProject.get(key);
944
+ agg.total_tokens += Number(row.total_tokens || 0);
945
+ agg.billable_total_tokens += Number(row.total_tokens || 0);
946
+ if (!agg.project_ref && row.project_ref) agg.project_ref = row.project_ref;
947
+ }
862
948
 
863
- if (projectMap.size === 0) {
949
+ // If no project-attributed rows exist yet (user hasn't synced project
950
+ // attribution, or never used a project-capable CLI), fall back to
951
+ // per-source aggregation over the main queue so the panel isn't
952
+ // totally empty. This path used to also exist for the non-empty case
953
+ // and produce wrong numbers; keep it only as the empty fallback.
954
+ let entries;
955
+ if (byProject.size === 0) {
956
+ const rows = readQueueData(qp);
864
957
  const bySrc = new Map();
865
958
  for (const row of rows) {
866
959
  const src = row.source || "unknown";
867
- if (!bySrc.has(src)) bySrc.set(src, { project_key: src, project_ref: `https://${src}.ai`, total_tokens: 0, billable_total_tokens: 0 });
960
+ if (!bySrc.has(src)) {
961
+ bySrc.set(src, {
962
+ project_key: src,
963
+ project_ref: `https://${src}.ai`,
964
+ total_tokens: 0,
965
+ billable_total_tokens: 0,
966
+ });
967
+ }
868
968
  bySrc.get(src).total_tokens += row.total_tokens || 0;
869
969
  bySrc.get(src).billable_total_tokens += row.total_tokens || 0;
870
970
  }
871
- entries.push(
872
- ...Array.from(bySrc.values())
873
- .sort((a, b) => b.billable_total_tokens - a.billable_total_tokens)
874
- .map((e) => ({ ...e, total_tokens: String(e.total_tokens), billable_total_tokens: String(e.billable_total_tokens) })),
875
- );
971
+ entries = Array.from(bySrc.values())
972
+ .sort((a, b) => b.billable_total_tokens - a.billable_total_tokens)
973
+ .map((e) => ({
974
+ ...e,
975
+ total_tokens: String(e.total_tokens),
976
+ billable_total_tokens: String(e.billable_total_tokens),
977
+ }));
876
978
  } else {
877
- const totalCount = Array.from(projectMap.values()).reduce((s, p) => s + p.count, 0);
878
- for (const [, proj] of projectMap) {
879
- const ratio = totalCount > 0 ? proj.count / totalCount : 1 / projectMap.size;
880
- const tokens = Math.floor(totalTokens * ratio);
881
- entries.push({ project_key: proj.project_key, project_ref: proj.project_ref, total_tokens: String(tokens), billable_total_tokens: String(tokens) });
882
- }
883
- entries.sort((a, b) => Number(b.billable_total_tokens) - Number(a.billable_total_tokens));
979
+ entries = Array.from(byProject.values())
980
+ .sort((a, b) => b.billable_total_tokens - a.billable_total_tokens)
981
+ .map((e) => ({
982
+ ...e,
983
+ total_tokens: String(e.total_tokens),
984
+ billable_total_tokens: String(e.billable_total_tokens),
985
+ }));
884
986
  }
885
987
 
886
988
  json(res, { generated_at: new Date().toISOString(), entries });
@@ -911,12 +1013,13 @@ function createLocalApiHandler({ queuePath }) {
911
1013
  if (p === "/functions/tokentracker-usage-monthly") {
912
1014
  const from = url.searchParams.get("from") || "";
913
1015
  const to = url.searchParams.get("to") || "";
1016
+ const timeZoneContext = getTimeZoneContext(url);
914
1017
  const rows = readQueueData(qp);
915
1018
  const byMonth = new Map();
916
1019
  for (const row of rows) {
917
1020
  if (!row.hour_start) continue;
918
- const day = row.hour_start.slice(0, 10);
919
- if (day < from || day > to) continue;
1021
+ const day = rowDayKey(row, timeZoneContext);
1022
+ if (!day || day < from || day > to) continue;
920
1023
  const month = day.slice(0, 7);
921
1024
  if (!byMonth.has(month))
922
1025
  byMonth.set(month, { month, total_tokens: 0, billable_total_tokens: 0, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, cache_creation_input_tokens: 0, reasoning_output_tokens: 0, conversation_count: 0 });