letmecode 0.1.9 → 0.1.10

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.md CHANGED
@@ -8,6 +8,8 @@ npx -y letmecode -- -h
8
8
  npx -y letmecode -- --log-to log.txt
9
9
  ```
10
10
 
11
+ `--log-to` now records Claude binary discovery, session-root selection, parsed transcript file summaries, entrypoint matching, raw `/usage` output, and live-window event matching so zero-token windows are diagnosable.
12
+
11
13
  <img width="2308" height="1491" alt="image" src="https://github.com/user-attachments/assets/f3f52d79-00e3-4ff5-bf2f-65f8be632aaa" />
12
14
 
13
15
  ## Controls
@@ -62,8 +62,9 @@ export function buildHelpText() {
62
62
  "",
63
63
  "Trace logging:",
64
64
  " --log-to PATH writes Claude CLI SDK and Claude VSCode detection details,",
65
+ " session root selection, parsed session file summaries, entrypoint matching,",
65
66
  " every candidate binary path check, the final found/not-found result,",
66
- " and the raw /usage command output."
67
+ " and the raw /usage command output plus live window matching details."
67
68
  ].join("\n");
68
69
  }
69
70
  export function createFileTraceLogger(logPath) {
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import https from "node:https";
2
3
  import { createRequire } from "node:module";
3
4
  import fs from "node:fs";
4
5
  import os from "node:os";
@@ -66,6 +67,21 @@ const RATE_CARD = {
66
67
  const UNPRICED_MODELS = new Set([
67
68
  "gpt-oss-120b"
68
69
  ]);
70
+ const ANTIGRAVITY_PRIMARY_WINDOW_MINUTES = 5 * 60;
71
+ const ANTIGRAVITY_WEEKLY_WINDOW_MINUTES = 7 * 24 * 60;
72
+ const ANTIGRAVITY_QUOTA_SUMMARY_PATH = "/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary";
73
+ const ANTIGRAVITY_DEBUG_LOG_PATH = process.env.LETMECODE_ANTIGRAVITY_DEBUG_LOG ??
74
+ path.join(os.tmpdir(), "letmecode-antigravity-debug.jsonl");
75
+ const GEMINI_QUOTA_MODELS = [
76
+ "gemini-3.5-flash",
77
+ "gemini-3.1-pro",
78
+ "gemini-3-flash"
79
+ ];
80
+ const THIRD_PARTY_QUOTA_MODELS = [
81
+ "claude-opus-4-6",
82
+ "claude-sonnet-4-6",
83
+ "gpt-oss-120b"
84
+ ];
69
85
  const MODEL_ALIASES = {
70
86
  "gemini-3-flash-a": "gemini-3-flash",
71
87
  "gemini-3-flash-preview": "gemini-3-flash",
@@ -80,15 +96,33 @@ export class AntigravityUsageProvider extends UsageProviderBase {
80
96
  this.collectUsage =
81
97
  options.collectUsage ??
82
98
  collectAntigravityUsageFromTokscale;
99
+ this.collectQuota =
100
+ options.collectQuota ??
101
+ collectAntigravityQuotaFromLocalRpc;
83
102
  }
84
103
  async getStats(_options = {}) {
85
104
  const warnings = [];
86
- let records = [];
87
- try {
88
- records = await this.collectUsage();
105
+ const [usageResult, quotaResult] = await Promise.allSettled([
106
+ this.collectUsage(),
107
+ this.collectQuota()
108
+ ]);
109
+ const records = usageResult.status === "fulfilled"
110
+ ? usageResult.value
111
+ : [];
112
+ const quotaSnapshot = quotaResult.status === "fulfilled"
113
+ ? quotaResult.value
114
+ : null;
115
+ if (usageResult.status === "rejected") {
116
+ warnings.push("Could not synchronize Antigravity token usage through Tokscale.");
117
+ }
118
+ if (quotaResult.status === "rejected") {
119
+ warnings.push("Live Antigravity quota is unavailable. Ensure the Antigravity IDE is running.");
89
120
  }
90
- catch {
91
- warnings.push("Open Antigravity IDE before running LetMeCode so token usage can be synchronized.");
121
+ else if (quotaResult.value.entries.length === 0) {
122
+ warnings.push("Antigravity local quota RPC responded, but no recognized model quota windows were found.");
123
+ }
124
+ if (isAntigravityDebugEnabled()) {
125
+ warnings.push(`Antigravity debug log: ${ANTIGRAVITY_DEBUG_LOG_PATH}`);
92
126
  }
93
127
  const selectedRecords = deduplicateRecords(records);
94
128
  const duplicateEvents = records.length - selectedRecords.length;
@@ -116,6 +150,7 @@ export class AntigravityUsageProvider extends UsageProviderBase {
116
150
  if (unknownPricedModels.length > 0) {
117
151
  warnings.push(`No Antigravity estimated API-equivalent rate configured for: ${unknownPricedModels.join(", ")}.`);
118
152
  }
153
+ const limitWindows = quotaSnapshot?.entries.map((quota) => buildAntigravityLimitWindow(quota, selectedRecords, quotaSnapshot.fetchedAt)) ?? [];
119
154
  return {
120
155
  providerId: this.id,
121
156
  providerLabel: this.label,
@@ -125,14 +160,16 @@ export class AntigravityUsageProvider extends UsageProviderBase {
125
160
  tokenEvents: selectedRecords.length,
126
161
  totals: sumUsageTotals(modelUsage.map((row) => row.totals)),
127
162
  distinctModels: modelUsage.map((row) => row.modelId),
128
- distinctPlanTypes: [],
129
- rootLabel: "Antigravity IDE sync",
130
- rootPath: "antigravity-ide-rpc"
163
+ distinctPlanTypes: [
164
+ ...new Set(limitWindows.map((window) => window.planType))
165
+ ],
166
+ rootLabel: "Tokscale usage + Antigravity local quota",
167
+ rootPath: getAntigravityCacheRoot()
131
168
  },
132
169
  modelUsage,
133
170
  dayUsage: buildDailyUsageRows(byDay),
134
- primaryLimitWindows: [],
135
- secondaryLimitWindows: [],
171
+ primaryLimitWindows: limitWindows.filter((window) => window.scope === "primary"),
172
+ secondaryLimitWindows: limitWindows.filter((window) => window.scope === "secondary"),
136
173
  warnings
137
174
  };
138
175
  }
@@ -140,6 +177,9 @@ export class AntigravityUsageProvider extends UsageProviderBase {
140
177
  export async function collectAntigravityUsage() {
141
178
  return collectAntigravityUsageFromTokscale();
142
179
  }
180
+ export async function collectAntigravityQuota() {
181
+ return collectAntigravityQuotaFromLocalRpc();
182
+ }
143
183
  async function collectAntigravityUsageFromTokscale() {
144
184
  await runTokscale([
145
185
  "antigravity",
@@ -153,11 +193,459 @@ async function runTokscale(args) {
153
193
  maxBuffer: 32 * 1024 * 1024
154
194
  });
155
195
  }
196
+ async function collectAntigravityQuotaFromLocalRpc() {
197
+ const server = await findAntigravityLocalServer();
198
+ if (!server) {
199
+ throw new Error("Antigravity local language server was not found.");
200
+ }
201
+ const fetchedAt = Date.now();
202
+ const payload = await readAntigravityQuotaSummary(server);
203
+ const entries = parseAntigravityQuotaEntries(payload);
204
+ await writeAntigravityDebugEvent("quota-rpc-response", {
205
+ port: server.port,
206
+ path: ANTIGRAVITY_QUOTA_SUMMARY_PATH,
207
+ entries: entries.map((entry) => ({
208
+ limitId: entry.limitId,
209
+ remainingFraction: entry.remainingFraction,
210
+ resetAt: new Date(entry.resetAt).toISOString(),
211
+ windowMinutes: entry.windowMinutes,
212
+ scope: entry.scope,
213
+ modelIds: entry.modelIds
214
+ })),
215
+ ...(isAntigravityRawDebugEnabled() ? { payload } : {})
216
+ });
217
+ return {
218
+ entries,
219
+ fetchedAt
220
+ };
221
+ }
222
+ function recordsForQuotaWindow(quota, records) {
223
+ if (quota.modelIds.length === 0) {
224
+ return [];
225
+ }
226
+ const endMs = quota.resetAt;
227
+ const startMs = endMs - quota.windowMinutes * 60000;
228
+ const modelIds = new Set(quota.modelIds.map(resolveModelId));
229
+ return records.filter((record) => {
230
+ const modelId = resolveModelId(record.modelId);
231
+ return (record.timestamp >= startMs &&
232
+ record.timestamp < endMs &&
233
+ modelIds.has(modelId));
234
+ });
235
+ }
236
+ function buildAntigravityLimitWindow(quota, records, fetchedAt) {
237
+ const matchingRecords = recordsForQuotaWindow(quota, records);
238
+ const byModel = new Map();
239
+ for (const record of matchingRecords) {
240
+ const modelId = resolveModelId(record.modelId);
241
+ addModelUsage(byModel, modelId, usageRecordToTotals(modelId, record));
242
+ }
243
+ const modelUsage = [...byModel.entries()]
244
+ .map(([modelId, totals]) => ({
245
+ modelId,
246
+ totals
247
+ }))
248
+ .sort((left, right) => right.totals.estimatedCredits -
249
+ left.totals.estimatedCredits);
250
+ const totals = sumUsageTotals(modelUsage.map((row) => row.totals));
251
+ const usedPercent = clampPercent((1 - quota.remainingFraction) * 100);
252
+ // Quota percentage is authoritative from Antigravity RPC. Token totals are
253
+ // reconstructed from locally available Tokscale events inside the same time
254
+ // window and may not match Antigravity's internal quota accounting exactly.
255
+ return {
256
+ scope: quota.scope,
257
+ planType: quota.planType,
258
+ limitId: quota.limitId,
259
+ windowMinutes: quota.windowMinutes,
260
+ startTimeUtcIso: new Date(quota.resetAt - quota.windowMinutes * 60000).toISOString(),
261
+ endTimeUtcIso: new Date(quota.resetAt).toISOString(),
262
+ firstSeenUtcIso: new Date(fetchedAt).toISOString(),
263
+ lastSeenUtcIso: new Date(fetchedAt).toISOString(),
264
+ minUsedPercent: usedPercent,
265
+ maxUsedPercent: usedPercent,
266
+ totals,
267
+ modelUsage,
268
+ eventCount: totals.eventCount
269
+ };
270
+ }
271
+ const antigravityHttpsAgent = new https.Agent({
272
+ rejectUnauthorized: false
273
+ });
274
+ async function findAntigravityLocalServer() {
275
+ const process = await findAntigravityProcess();
276
+ if (!process) {
277
+ await writeAntigravityDebugEvent("process-not-found", {});
278
+ return null;
279
+ }
280
+ const ports = await findListeningLoopbackPorts(process.pid);
281
+ await writeAntigravityDebugEvent("process-found", {
282
+ pid: process.pid,
283
+ ports
284
+ });
285
+ return probeAntigravityPorts(ports, process.csrfToken);
286
+ }
287
+ async function findAntigravityProcess() {
288
+ const fromProc = await findAntigravityProcessFromProc();
289
+ if (fromProc) {
290
+ return fromProc;
291
+ }
292
+ return findAntigravityProcessFromPs();
293
+ }
294
+ async function findAntigravityProcessFromProc() {
295
+ let entries;
296
+ try {
297
+ entries = await fs.promises.readdir("/proc", { withFileTypes: true });
298
+ }
299
+ catch {
300
+ return null;
301
+ }
302
+ for (const entry of entries) {
303
+ if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) {
304
+ continue;
305
+ }
306
+ const pid = Number(entry.name);
307
+ const cmdline = await readProcCmdline(path.join("/proc", entry.name, "cmdline"));
308
+ const process = parseAntigravityProcessFromArgs(pid, cmdline);
309
+ if (process) {
310
+ return process;
311
+ }
312
+ }
313
+ return null;
314
+ }
315
+ async function readProcCmdline(filePath) {
316
+ try {
317
+ const content = await fs.promises.readFile(filePath);
318
+ return content
319
+ .toString("utf8")
320
+ .split("\0")
321
+ .filter(Boolean);
322
+ }
323
+ catch {
324
+ return [];
325
+ }
326
+ }
327
+ async function findAntigravityProcessFromPs() {
328
+ try {
329
+ const { stdout } = await execFileAsync("ps", ["-eo", "pid=,args="], {
330
+ encoding: "utf8",
331
+ maxBuffer: 4 * 1024 * 1024,
332
+ timeout: 5000
333
+ });
334
+ for (const line of stdout.split(/\r?\n/)) {
335
+ const match = line.match(/^\s*(\d+)\s+(.+)$/);
336
+ if (!match) {
337
+ continue;
338
+ }
339
+ const process = parseAntigravityProcessFromArgs(Number(match[1]), splitCommandLineForDiscovery(match[2]));
340
+ if (process) {
341
+ return process;
342
+ }
343
+ }
344
+ }
345
+ catch {
346
+ return null;
347
+ }
348
+ return null;
349
+ }
350
+ function splitCommandLineForDiscovery(value) {
351
+ return value.match(/"[^"]*"|'[^']*'|\S+/g)?.map((part) => part.replace(/^['"]|['"]$/g, "")) ?? [];
352
+ }
353
+ function parseAntigravityProcessFromArgs(pid, args) {
354
+ if (!Number.isInteger(pid) || pid <= 0 || !isAntigravityLanguageServerCommand(args)) {
355
+ return null;
356
+ }
357
+ const csrfToken = readNamedArg(args, "--csrf_token")?.trim();
358
+ if (!csrfToken) {
359
+ return null;
360
+ }
361
+ return { pid, csrfToken };
362
+ }
363
+ function isAntigravityLanguageServerCommand(args) {
364
+ const normalized = args.join(" ").toLowerCase();
365
+ return (normalized.includes("antigravity") &&
366
+ (normalized.includes("language-server") ||
367
+ normalized.includes("language_server") ||
368
+ normalized.includes("extension-server") ||
369
+ normalized.includes("extension_server")));
370
+ }
371
+ function readNamedArg(args, name) {
372
+ for (let index = 0; index < args.length; index += 1) {
373
+ const arg = args[index];
374
+ if (arg === name) {
375
+ return args[index + 1] ?? null;
376
+ }
377
+ if (arg.startsWith(`${name}=`)) {
378
+ return arg.slice(name.length + 1);
379
+ }
380
+ }
381
+ return null;
382
+ }
383
+ async function findListeningLoopbackPorts(pid) {
384
+ const parsers = [
385
+ () => findListeningLoopbackPortsWithSs(pid),
386
+ () => findListeningLoopbackPortsWithLsof(pid)
387
+ ];
388
+ for (const parse of parsers) {
389
+ const ports = await parse();
390
+ if (ports.length > 0) {
391
+ return ports;
392
+ }
393
+ }
394
+ return [];
395
+ }
396
+ async function findListeningLoopbackPortsWithSs(pid) {
397
+ try {
398
+ const { stdout } = await execFileAsync("ss", ["-H", "-ltnp"], {
399
+ encoding: "utf8",
400
+ maxBuffer: 1024 * 1024,
401
+ timeout: 5000
402
+ });
403
+ return uniquePorts(stdout
404
+ .split(/\r?\n/)
405
+ .filter((line) => line.includes(`pid=${pid},`) && isLoopbackListenLine(line))
406
+ .map(extractPortFromListenLine));
407
+ }
408
+ catch {
409
+ return [];
410
+ }
411
+ }
412
+ async function findListeningLoopbackPortsWithLsof(pid) {
413
+ try {
414
+ const { stdout } = await execFileAsync("lsof", ["-Pan", "-p", String(pid), "-iTCP", "-sTCP:LISTEN"], {
415
+ encoding: "utf8",
416
+ maxBuffer: 1024 * 1024,
417
+ timeout: 5000
418
+ });
419
+ return uniquePorts(stdout
420
+ .split(/\r?\n/)
421
+ .filter(isLoopbackListenLine)
422
+ .map(extractPortFromListenLine));
423
+ }
424
+ catch {
425
+ return [];
426
+ }
427
+ }
428
+ function isLoopbackListenLine(line) {
429
+ return /(?:127\.0\.0\.1|localhost|\[::1\]|::1):\d+\b/.test(line);
430
+ }
431
+ function extractPortFromListenLine(line) {
432
+ const matches = [...line.matchAll(/(?:127\.0\.0\.1|localhost|\[::1\]|::1):(\d+)/g)];
433
+ const value = matches.at(-1)?.[1];
434
+ const port = value ? Number(value) : NaN;
435
+ return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
436
+ }
437
+ function uniquePorts(ports) {
438
+ return [...new Set(ports.filter((port) => port !== null))];
439
+ }
440
+ async function probeAntigravityPorts(ports, csrfToken) {
441
+ for (const port of ports) {
442
+ const server = { port, csrfToken };
443
+ try {
444
+ await readAntigravityQuotaSummary(server);
445
+ await writeAntigravityDebugEvent("port-probe-ok", { port });
446
+ return server;
447
+ }
448
+ catch (error) {
449
+ await writeAntigravityDebugEvent("port-probe-failed", {
450
+ port,
451
+ error: error instanceof Error ? error.message : String(error)
452
+ });
453
+ // Try the next loopback listener owned by the same Antigravity process.
454
+ }
455
+ }
456
+ return null;
457
+ }
458
+ async function readAntigravityQuotaSummary(server) {
459
+ return requestAntigravityQuotaSummary(server);
460
+ }
461
+ function requestAntigravityQuotaSummary(server) {
462
+ const body = "{}";
463
+ return new Promise((resolve, reject) => {
464
+ const request = https.request({
465
+ hostname: "127.0.0.1",
466
+ port: server.port,
467
+ path: ANTIGRAVITY_QUOTA_SUMMARY_PATH,
468
+ method: "POST",
469
+ timeout: 5000,
470
+ agent: antigravityHttpsAgent,
471
+ headers: {
472
+ "X-Codeium-Csrf-Token": server.csrfToken,
473
+ "Content-Type": "application/json",
474
+ "Connect-Protocol-Version": "1",
475
+ Accept: "application/json",
476
+ "Content-Length": Buffer.byteLength(body)
477
+ }
478
+ }, (response) => {
479
+ const chunks = [];
480
+ response.on("data", (chunk) => chunks.push(chunk));
481
+ response.on("end", () => {
482
+ const responseBody = Buffer.concat(chunks).toString("utf8");
483
+ if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
484
+ reject(new Error(`Unexpected Antigravity quota summary response: ${response.statusCode ?? "unknown"}`));
485
+ return;
486
+ }
487
+ try {
488
+ resolve(responseBody ? JSON.parse(responseBody) : {});
489
+ }
490
+ catch (error) {
491
+ reject(error);
492
+ }
493
+ });
494
+ });
495
+ request.on("timeout", () => {
496
+ request.destroy(new Error("Timed out reading Antigravity quota summary."));
497
+ });
498
+ request.on("error", reject);
499
+ request.end(body);
500
+ });
501
+ }
502
+ export function parseAntigravityQuotaEntries(payload) {
503
+ const root = asRecord(payload);
504
+ const response = asRecord(root?.response);
505
+ const groups = asArray(response?.groups);
506
+ const entries = [];
507
+ for (const groupValue of groups) {
508
+ const group = asRecord(groupValue);
509
+ if (!group) {
510
+ continue;
511
+ }
512
+ const displayName = asString(group.displayName) ?? "";
513
+ const description = asString(group.description) ?? "";
514
+ const modelIds = resolveQuotaGroupModelIds(displayName, description);
515
+ if (modelIds.length === 0) {
516
+ void writeAntigravityDebugEvent("quota-group-skipped", {
517
+ displayName,
518
+ description
519
+ });
520
+ continue;
521
+ }
522
+ for (const bucketValue of asArray(group.buckets)) {
523
+ const bucket = asRecord(bucketValue);
524
+ if (!bucket) {
525
+ continue;
526
+ }
527
+ const bucketId = asString(bucket.bucketId);
528
+ const windowConfig = resolveQuotaWindow(asString(bucket.window));
529
+ const remainingFraction = asFiniteNumber(bucket.remainingFraction);
530
+ const resetTime = asString(bucket.resetTime);
531
+ const resetAt = resetTime === null ? NaN : Date.parse(resetTime);
532
+ if (!bucketId ||
533
+ remainingFraction === null ||
534
+ remainingFraction < 0 ||
535
+ remainingFraction > 1 ||
536
+ !Number.isFinite(resetAt) ||
537
+ !windowConfig) {
538
+ continue;
539
+ }
540
+ entries.push({
541
+ limitId: bucketId,
542
+ modelIds,
543
+ remainingFraction,
544
+ resetAt,
545
+ windowMinutes: windowConfig.windowMinutes,
546
+ scope: windowConfig.scope,
547
+ planType: "unknown"
548
+ });
549
+ }
550
+ }
551
+ return entries;
552
+ }
553
+ function resolveQuotaWindow(window) {
554
+ switch (window) {
555
+ case "5h":
556
+ return {
557
+ scope: "primary",
558
+ windowMinutes: ANTIGRAVITY_PRIMARY_WINDOW_MINUTES
559
+ };
560
+ case "weekly":
561
+ return {
562
+ scope: "secondary",
563
+ windowMinutes: ANTIGRAVITY_WEEKLY_WINDOW_MINUTES
564
+ };
565
+ default:
566
+ return null;
567
+ }
568
+ }
569
+ function resolveQuotaGroupModelIds(displayName, description) {
570
+ const text = `${displayName} ${description}`.toLowerCase();
571
+ if (text.includes("gemini")) {
572
+ return GEMINI_QUOTA_MODELS;
573
+ }
574
+ if (text.includes("claude") || text.includes("gpt")) {
575
+ return THIRD_PARTY_QUOTA_MODELS;
576
+ }
577
+ return [];
578
+ }
579
+ function asRecord(value) {
580
+ return value && typeof value === "object" && !Array.isArray(value)
581
+ ? value
582
+ : null;
583
+ }
584
+ function asArray(value) {
585
+ return Array.isArray(value) ? value : [];
586
+ }
587
+ function asString(value) {
588
+ return typeof value === "string" && value.trim() ? value.trim() : null;
589
+ }
590
+ function asFiniteNumber(value) {
591
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
592
+ }
593
+ function isAntigravityDebugEnabled() {
594
+ const value = process.env.LETMECODE_DEBUG_ANTIGRAVITY;
595
+ return value === "1" || value === "true" || value === "yes";
596
+ }
597
+ function isAntigravityRawDebugEnabled() {
598
+ const value = process.env.LETMECODE_DEBUG_ANTIGRAVITY_RAW;
599
+ return value === "1" || value === "true" || value === "yes";
600
+ }
601
+ async function writeAntigravityDebugEvent(event, data) {
602
+ if (!isAntigravityDebugEnabled()) {
603
+ return;
604
+ }
605
+ const line = JSON.stringify({
606
+ timestamp: new Date().toISOString(),
607
+ event,
608
+ data: redactDebugValue(data)
609
+ });
610
+ try {
611
+ await fs.promises.mkdir(path.dirname(ANTIGRAVITY_DEBUG_LOG_PATH), {
612
+ recursive: true
613
+ });
614
+ await fs.promises.appendFile(ANTIGRAVITY_DEBUG_LOG_PATH, `${line}\n`, "utf8");
615
+ }
616
+ catch {
617
+ // Debug logging must never break provider stats collection.
618
+ }
619
+ }
620
+ function redactDebugValue(value, key = "") {
621
+ if (isSensitiveDebugKey(key)) {
622
+ return "[redacted]";
623
+ }
624
+ if (Array.isArray(value)) {
625
+ return value.map((item) => redactDebugValue(item));
626
+ }
627
+ if (!value || typeof value !== "object") {
628
+ return value;
629
+ }
630
+ return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [
631
+ entryKey,
632
+ redactDebugValue(entryValue, entryKey)
633
+ ]));
634
+ }
635
+ function isSensitiveDebugKey(key) {
636
+ return /token|csrf|authorization|cookie|email/i.test(key);
637
+ }
156
638
  function numberOrZero(value) {
157
639
  return typeof value === "number" && Number.isFinite(value)
158
640
  ? value
159
641
  : 0;
160
642
  }
643
+ function clampPercent(value) {
644
+ if (!Number.isFinite(value)) {
645
+ return 0;
646
+ }
647
+ return Math.min(100, Math.max(0, value));
648
+ }
161
649
  async function readAntigravityUsageCache(cacheRoot) {
162
650
  const sessionsRoot = path.join(cacheRoot, "sessions");
163
651
  const records = [];
@@ -58,7 +58,8 @@ export class ClaudeUsageProvider extends UsageProviderBase {
58
58
  this.now = options.now ?? (() => new Date());
59
59
  }
60
60
  async getStats(options = {}) {
61
- const resolvedSessionsRoot = await resolveClaudeSessionsRoot(this.root);
61
+ traceClaude(options.traceLogger, this.usageCommandKind, `Starting stats collection with root=${this.root} entrypoints=[${[...this.entrypoints].join(", ")}].`);
62
+ const resolvedSessionsRoot = await resolveClaudeSessionsRoot(this.root, this.usageCommandKind, options.traceLogger);
62
63
  const sessionsRoot = resolvedSessionsRoot.rootPath;
63
64
  const agentName = normalizeAnalyticsAgentName(this.label);
64
65
  const userIdHash = await readClaudeUserIdHash(this.root, this.usageCommandKind, this.readAuthStatusOutput, agentName, options.traceLogger);
@@ -74,9 +75,19 @@ export class ClaudeUsageProvider extends UsageProviderBase {
74
75
  tokenEvents: 0,
75
76
  malformedLines: 0
76
77
  };
77
- const parsedSessionFiles = await loadParsedClaudeSessionFiles(sessionsRoot);
78
+ const parsedSessionFiles = await loadParsedClaudeSessionFiles(sessionsRoot, this.usageCommandKind, options.traceLogger);
79
+ traceClaude(options.traceLogger, this.usageCommandKind, `Loaded ${parsedSessionFiles.length} parsed session file(s) from ${sessionsRoot}.`);
78
80
  for (const file of parsedSessionFiles) {
79
81
  const matchingEvents = file.events.filter((event) => this.entrypoints.has(event.entrypoint));
82
+ traceClaude(options.traceLogger, this.usageCommandKind, [
83
+ `Session file ${describeSessionFilePath(sessionsRoot, file.filePath)}:`,
84
+ `lines=${file.linesRead}`,
85
+ `malformed=${file.malformedLines}`,
86
+ `assistantUsageEvents=${file.events.length}`,
87
+ `matchingEvents=${matchingEvents.length}`,
88
+ `entrypoints=${summarizeEventCounts(file.events.map((event) => event.entrypoint || "<empty>"))}`,
89
+ `models=${summarizeDistinctValues(file.events.map((event) => event.modelId || "unknown"))}`
90
+ ].join(" "));
80
91
  if (matchingEvents.length === 0) {
81
92
  continue;
82
93
  }
@@ -91,6 +102,16 @@ export class ClaudeUsageProvider extends UsageProviderBase {
91
102
  ...parsedEvents.keyedEvents.values(),
92
103
  ...parsedEvents.unkeyedEvents.values()
93
104
  ];
105
+ traceClaude(options.traceLogger, this.usageCommandKind, [
106
+ `Transcript selection summary: filesWithMatches=${parseTotals.filesScanned}/${parsedSessionFiles.length}`,
107
+ `selectedEvents=${selectedEvents.length}`,
108
+ `duplicateUsageKeys=${parsedEvents.duplicateUsageKeys}`,
109
+ `duplicateUsageKeyCollisions=${parsedEvents.duplicateUsageKeyCollisions}`,
110
+ `duplicateUnkeyedEvents=${parsedEvents.duplicateUnkeyedEvents}`
111
+ ].join(" "));
112
+ if (selectedEvents.length === 0 && parsedSessionFiles.length > 0) {
113
+ traceClaude(options.traceLogger, this.usageCommandKind, `No transcript usage matched entrypoints [${[...this.entrypoints].join(", ")}]. Observed entrypoints=${summarizeEventCounts(collectEntryPoints(parsedSessionFiles))}.`);
114
+ }
94
115
  for (const event of selectedEvents) {
95
116
  addModelUsage(byModel, event.modelId, event.totals);
96
117
  const planType = typeof event.rateLimits?.plan_type === "string" ? event.rateLimits.plan_type : undefined;
@@ -141,6 +162,19 @@ export class ClaudeUsageProvider extends UsageProviderBase {
141
162
  const secondaryLimitWindows = liveLimitWindows.secondaryLimitWindows.length > 0
142
163
  ? liveLimitWindows.secondaryLimitWindows
143
164
  : fallbackSecondaryLimitWindows;
165
+ traceClaude(options.traceLogger, this.usageCommandKind, [
166
+ `Finished stats collection:`,
167
+ `filesScanned=${parseTotals.filesScanned}`,
168
+ `linesRead=${parseTotals.linesRead}`,
169
+ `tokenEvents=${parseTotals.tokenEvents}`,
170
+ `models=${modelUsage.length}`,
171
+ `primaryWindows=${primaryLimitWindows.length}`,
172
+ `secondaryWindows=${secondaryLimitWindows.length}`,
173
+ `input=${summaryTotals.inputTokens}`,
174
+ `output=${summaryTotals.outputTokens}`,
175
+ `cacheRead=${summaryTotals.cacheReadInputTokens}`,
176
+ `cacheWrite=${summaryTotals.cacheWriteInputTokens}`
177
+ ].join(" "));
144
178
  return {
145
179
  providerId: this.id,
146
180
  providerLabel: this.label,
@@ -240,17 +274,23 @@ function resolveClaudeCacheWriteBreakdown(usage) {
240
274
  function isSessionFile(filePath) {
241
275
  return filePath.endsWith(".jsonl");
242
276
  }
243
- async function resolveClaudeSessionsRoot(root) {
277
+ async function resolveClaudeSessionsRoot(root, usageCommandKind, traceLogger) {
244
278
  const candidates = buildClaudeSessionsRootCandidates(root);
279
+ traceClaude(traceLogger, usageCommandKind, `Checking ${candidates.length} Claude session root candidate(s).`);
245
280
  for (const candidate of candidates) {
246
- if (await isDirectory(candidate.rootPath)) {
281
+ const exists = await isDirectory(candidate.rootPath);
282
+ traceClaude(traceLogger, usageCommandKind, `Session root candidate ${candidate.rootLabel} -> ${candidate.rootPath} (${exists ? "exists" : "missing"}).`);
283
+ if (exists) {
284
+ traceClaude(traceLogger, usageCommandKind, `Selected session root ${candidate.rootLabel} -> ${candidate.rootPath}.`);
247
285
  return candidate;
248
286
  }
249
287
  }
250
- return candidates[0] ?? {
288
+ const fallbackCandidate = candidates[0] ?? {
251
289
  rootLabel: "~/.claude/projects",
252
290
  rootPath: path.join(path.resolve(root), ".claude", "projects")
253
291
  };
292
+ traceClaude(traceLogger, usageCommandKind, `No session root candidate exists yet; defaulting to ${fallbackCandidate.rootLabel} -> ${fallbackCandidate.rootPath}.`);
293
+ return fallbackCandidate;
254
294
  }
255
295
  function buildClaudeSessionsRootCandidates(root) {
256
296
  const resolvedRoot = path.resolve(root);
@@ -338,17 +378,21 @@ async function* walkSessionFiles(directory) {
338
378
  }
339
379
  }
340
380
  }
341
- async function loadParsedClaudeSessionFiles(sessionsRoot) {
381
+ async function loadParsedClaudeSessionFiles(sessionsRoot, usageCommandKind, traceLogger) {
342
382
  const cacheKey = path.resolve(sessionsRoot);
343
383
  const cached = parsedClaudeSessionFilesCache.get(cacheKey);
344
384
  if (cached) {
345
- return cached;
385
+ const files = await cached;
386
+ traceClaude(traceLogger, usageCommandKind, `Session parse cache hit for ${sessionsRoot} (${files.length} file(s)).`);
387
+ return files;
346
388
  }
347
389
  const pending = (async () => {
348
390
  const files = [];
391
+ traceClaude(traceLogger, usageCommandKind, `Scanning session files under ${sessionsRoot}.`);
349
392
  for await (const filePath of walkSessionFiles(sessionsRoot)) {
350
393
  files.push(await parseSessionFile(filePath));
351
394
  }
395
+ traceClaude(traceLogger, usageCommandKind, `Completed session file scan under ${sessionsRoot}: ${files.length} file(s) parsed.`);
352
396
  return files;
353
397
  })();
354
398
  parsedClaudeSessionFilesCache.set(cacheKey, pending);
@@ -397,7 +441,7 @@ async function parseSessionFile(filePath) {
397
441
  rateLimits
398
442
  });
399
443
  }
400
- return { linesRead, malformedLines, events };
444
+ return { filePath, linesRead, malformedLines, events };
401
445
  }
402
446
  function buildUsageEventKey(payloadObject, message) {
403
447
  const sessionId = String(payloadObject.sessionId ?? "");
@@ -493,6 +537,41 @@ function describeUsageOutput(output) {
493
537
  }
494
538
  return output.trim() ? output : "<empty>";
495
539
  }
540
+ function describeSessionFilePath(sessionsRoot, filePath) {
541
+ const relativePath = path.relative(sessionsRoot, filePath);
542
+ if (!relativePath || relativePath.startsWith("..")) {
543
+ return filePath;
544
+ }
545
+ return relativePath;
546
+ }
547
+ function summarizeEventCounts(values) {
548
+ const counts = new Map();
549
+ for (const value of values) {
550
+ const normalizedValue = value || "<empty>";
551
+ counts.set(normalizedValue, (counts.get(normalizedValue) ?? 0) + 1);
552
+ }
553
+ if (counts.size === 0) {
554
+ return "<none>";
555
+ }
556
+ return [...counts.entries()]
557
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
558
+ .map(([value, count]) => `${value}:${count}`)
559
+ .join(", ");
560
+ }
561
+ function summarizeDistinctValues(values, limit = 5) {
562
+ const distinctValues = [...new Set([...values].filter(Boolean))].sort();
563
+ if (distinctValues.length === 0) {
564
+ return "<none>";
565
+ }
566
+ const visibleValues = distinctValues.slice(0, limit);
567
+ const remainder = distinctValues.length - visibleValues.length;
568
+ return remainder > 0
569
+ ? `${visibleValues.join(", ")} (+${remainder} more)`
570
+ : visibleValues.join(", ");
571
+ }
572
+ function collectEntryPoints(files) {
573
+ return files.flatMap((file) => file.events.map((event) => event.entrypoint || "<empty>"));
574
+ }
496
575
  function buildClaudeCommandEnvironment() {
497
576
  return {
498
577
  ...process.env,
@@ -506,19 +585,47 @@ async function buildLiveLimitWindows(options) {
506
585
  ]);
507
586
  const snapshots = parseLiveUsageWindowSnapshots(usageOutput, options.now);
508
587
  traceClaude(options.traceLogger, options.usageCommandKind, `Parsed ${snapshots.length} live usage snapshot(s) from /usage output.`);
588
+ if (snapshots.length === 0) {
589
+ traceClaude(options.traceLogger, options.usageCommandKind, "No live usage snapshots matched the expected /usage format.");
590
+ }
509
591
  const resolvedPlanType = subscriptionType || "live";
592
+ traceClaude(options.traceLogger, options.usageCommandKind, `Resolved live plan type ${resolvedPlanType}.`);
593
+ const primaryLimitWindows = snapshots
594
+ .filter((snapshot) => snapshot.scope === "primary")
595
+ .map((snapshot) => buildLiveLimitWindowRow(snapshot, resolvedPlanType, options.selectedEvents, options.now));
596
+ const secondaryLimitWindows = snapshots
597
+ .filter((snapshot) => snapshot.scope === "secondary")
598
+ .map((snapshot) => buildLiveLimitWindowRow(snapshot, resolvedPlanType, options.selectedEvents, options.now));
599
+ for (let index = 0; index < snapshots.length; index += 1) {
600
+ const snapshot = snapshots[index];
601
+ const row = snapshot.scope === "primary"
602
+ ? primaryLimitWindows.filter((window) => window.limitId === `current-${snapshot.label}`)[0]
603
+ : secondaryLimitWindows.filter((window) => window.limitId === `current-${snapshot.label}`)[0];
604
+ if (!row) {
605
+ continue;
606
+ }
607
+ traceClaude(options.traceLogger, options.usageCommandKind, [
608
+ `Live window ${snapshot.scope}/${snapshot.label}:`,
609
+ `used=${snapshot.usedPercent}%`,
610
+ `range=${row.startTimeUtcIso}->${row.endTimeUtcIso}`,
611
+ `matchedEvents=${row.eventCount}`,
612
+ `input=${row.totals.inputTokens}`,
613
+ `output=${row.totals.outputTokens}`,
614
+ `cacheRead=${row.totals.cacheReadInputTokens}`,
615
+ `cacheWrite=${row.totals.cacheWriteInputTokens}`
616
+ ].join(" "));
617
+ }
510
618
  return {
511
- primaryLimitWindows: snapshots
512
- .filter((snapshot) => snapshot.scope === "primary")
513
- .map((snapshot) => buildLiveLimitWindowRow(snapshot, resolvedPlanType, options.selectedEvents, options.now)),
514
- secondaryLimitWindows: snapshots
515
- .filter((snapshot) => snapshot.scope === "secondary")
516
- .map((snapshot) => buildLiveLimitWindowRow(snapshot, resolvedPlanType, options.selectedEvents, options.now))
619
+ primaryLimitWindows,
620
+ secondaryLimitWindows
517
621
  };
518
622
  }
519
623
  async function readClaudeSubscriptionType(root, usageCommandKind, override, traceLogger) {
520
624
  const output = await readClaudeAuthStatusOutput(root, usageCommandKind, override, traceLogger);
521
625
  const subscriptionType = parseClaudeSubscriptionType(output);
626
+ if (output && !subscriptionType) {
627
+ traceClaude(traceLogger, usageCommandKind, "Could not parse subscription type from auth status output.");
628
+ }
522
629
  traceClaude(traceLogger, usageCommandKind, `Subscription type result: ${subscriptionType ?? "<none>"}.`);
523
630
  return subscriptionType;
524
631
  }
@@ -602,6 +709,7 @@ async function readClaudeUserIdHash(root, usageCommandKind, override, agentName,
602
709
  const authStatusOutput = await readClaudeAuthStatusOutput(root, usageCommandKind, override, traceLogger);
603
710
  const snapshot = parseClaudeAuthStatusSnapshot(authStatusOutput);
604
711
  if (!snapshot) {
712
+ traceClaude(traceLogger, usageCommandKind, "Auth status output did not yield an analytics identity snapshot.");
605
713
  return null;
606
714
  }
607
715
  return buildUserIdHash([agentName, snapshot.email, snapshot.orgId, snapshot.orgName]);
@@ -16,7 +16,7 @@ export function createProviders() {
16
16
  new AntigravityUsageProvider()
17
17
  ];
18
18
  }
19
- export { AntigravityUsageProvider } from "./antigravity.js";
19
+ export { AntigravityUsageProvider, collectAntigravityQuota, collectAntigravityUsage } from "./antigravity.js";
20
20
  export { ClaudeUsageProvider } from "./claude.js";
21
21
  export { CodexUsageProvider } from "./codex.js";
22
22
  export { CopilotUsageProvider, configureCopilotVsCodeLogging } from "./copilot.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letmecode",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Provider-based terminal usage dashboard for LetMeCode.",
5
5
  "author": "devforth.io",
6
6
  "license": "MIT",