letmecode 0.1.23 → 0.1.24

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.
@@ -4,6 +4,7 @@ import { Box, Text, measureElement, useApp, useInput, useStdin, useStdout, rende
4
4
  import { buildHelpText, buildProviderStatsOptions, parseCliOptions } from "./cli-options.js";
5
5
  import { configureCopilotVsCodeLogging, createProviders } from "./providers/index.js";
6
6
  import { reportAnonymousUsage } from "./reporting.js";
7
+ import { estimateLimitFullValue } from "./providers/limits.js";
7
8
  const ESC = String.fromCharCode(0x1b);
8
9
  // Normal mouse tracking (button press/release only) + SGR extended coordinates.
9
10
  // This makes the tabs clickable while leaving Shift+drag native text selection
@@ -19,7 +20,7 @@ const DETAIL_TABS = [
19
20
  { id: "usage-by-model", label: "Models" }
20
21
  ];
21
22
  const CODEX_CREDIT_COST_USD = 0.01;
22
- const LIMIT_TABLE_HEADERS = ["Scope", "Plan", "Models", "Window", "Used", "Start", "End", "API eq."];
23
+ const LIMIT_TABLE_HEADERS = ["Scope", "Plan", "Models", "Window", "Used", "Start", "End", "API eq.", "Full"];
23
24
  const DAILY_TABLE_HEADERS = ["Day", "Ev", "Input", "Output", "C read", "C write", "API eq."];
24
25
  const MODEL_TABLE_HEADERS = ["Model", "Input", "Output", "C read", "C write", "API eq."];
25
26
  const COPILOT_ACTIONS = [
@@ -417,7 +418,10 @@ function buildLimitWindowTableRow(window) {
417
418
  formatCompactLocalDateTime(window.endTimeUtcIso),
418
419
  // Status-aware: shows "-" when the API-equivalent cost is unknown rather
419
420
  // than a misleading $0.00.
420
- formatUsageUsd(window.totals)
421
+ formatUsageUsd(window.totals),
422
+ // Extrapolated full value of the limit, rounded to a single figure here;
423
+ // the details panel shows the unrounded ±1% range.
424
+ formatLimitFullValueCompact(window.totals, window.maxUsedPercent)
421
425
  ]
422
426
  };
423
427
  }
@@ -461,7 +465,7 @@ function SelectionDetailsPanel(props) {
461
465
  }
462
466
  if (props.tabId === "limit-windows" && props.selectedLimitRow) {
463
467
  const row = props.selectedLimitRow;
464
- return (_jsx(DetailsPanelFrame, { children: _jsxs(Box, { children: [_jsxs(Box, { flexDirection: "column", width: 25, children: [_jsx(DetailRow, { label: "Plan", value: row.planType }), _jsx(DetailRow, { label: "Models", value: formatLimitWindowModels(row) }), _jsx(DetailRow, { label: "Window", value: formatCompactWindowMinutes(row.windowMinutes) }), _jsx(DetailRow, { label: "Usage", value: formatUsedPercentRange(row.minUsedPercent, row.maxUsedPercent) }), _jsx(DetailRow, { label: "Events", value: formatInteger(row.eventCount) }), _jsx(DetailRow, { label: "API eq.", value: formatUsageUsd(row.totals) })] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(DetailRow, { label: "Period", value: `${formatCompactLocalDateTime(row.startTimeUtcIso)} → ${formatCompactLocalDateTime(row.endTimeUtcIso)}` }), _jsx(DetailRow, { label: "Input", value: formatInteger(row.totals.inputTokens) }), _jsx(DetailRow, { label: "Cache read", value: formatCacheTokens(row.totals.cacheReadStatus, row.totals.cacheReadInputTokens) }), _jsx(DetailRow, { label: "Cache write", value: formatCacheTokens(row.totals.cacheWriteStatus, row.totals.cacheWriteInputTokens) }), _jsx(DetailRow, { label: "Output", value: formatInteger(row.totals.outputTokens) }), _jsx(DetailRow, { label: "Total", value: formatInteger(row.totals.totalTokens) })] })] }) }));
468
+ return (_jsxs(DetailsPanelFrame, { children: [_jsxs(Box, { children: [_jsxs(Box, { flexDirection: "column", width: 25, children: [_jsx(DetailRow, { label: "Plan", value: row.planType }), _jsx(DetailRow, { label: "Models", value: formatLimitWindowModels(row) }), _jsx(DetailRow, { label: "Window", value: formatCompactWindowMinutes(row.windowMinutes) }), _jsx(DetailRow, { label: "Usage", value: formatUsedPercentRange(row.minUsedPercent, row.maxUsedPercent) }), _jsx(DetailRow, { label: "Events", value: formatInteger(row.eventCount) })] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(DetailRow, { label: "Period", value: `${formatCompactLocalDateTime(row.startTimeUtcIso)} → ${formatCompactLocalDateTime(row.endTimeUtcIso)}` }), _jsx(DetailRow, { label: "Input", value: formatInteger(row.totals.inputTokens) }), _jsx(DetailRow, { label: "Cache read", value: formatCacheTokens(row.totals.cacheReadStatus, row.totals.cacheReadInputTokens) }), _jsx(DetailRow, { label: "Cache write", value: formatCacheTokens(row.totals.cacheWriteStatus, row.totals.cacheWriteInputTokens) }), _jsx(DetailRow, { label: "Output", value: formatInteger(row.totals.outputTokens) }), _jsx(DetailRow, { label: "Total", value: formatInteger(row.totals.totalTokens) })] })] }), _jsx(DetailRow, { label: "API eq.", value: formatUsageUsd(row.totals), note: "API equivalent cost" }), _jsx(DetailRow, { label: "Full value", value: formatLimitFullValueRange(row.totals, row.maxUsedPercent), note: "API equivalent cost if 100% will be used" })] }));
465
469
  }
466
470
  if (props.tabId === "day-to-day-analyses" && props.selectedDayRow) {
467
471
  const row = props.selectedDayRow;
@@ -479,7 +483,7 @@ function DetailRow(props) {
479
483
  const labelText = props.noSlice
480
484
  ? props.label.padEnd(props.padLength ?? 14)
481
485
  : pad(props.label, props.padLength ?? 14);
482
- return (_jsxs(Text, { children: [labelText, props.value] }));
486
+ return (_jsxs(Text, { children: [labelText, props.value, props.note ? _jsx(Text, { color: "gray", children: ` ${props.note}` }) : null] }));
483
487
  }
484
488
  function UsageTotalsDetails(props) {
485
489
  const { totals } = props;
@@ -565,6 +569,37 @@ function formatUsd(value) {
565
569
  maximumFractionDigits: 2
566
570
  });
567
571
  }
572
+ function formatUsdWhole(value) {
573
+ return Math.round(value).toLocaleString("en-US", {
574
+ currency: "USD",
575
+ style: "currency",
576
+ minimumFractionDigits: 0,
577
+ maximumFractionDigits: 0
578
+ });
579
+ }
580
+ // Extrapolate the limit's full USD value from the observed API-equivalent cost
581
+ // and how much of the limit it represents. Uses the highest reported percent
582
+ // (the latest cumulative usage) and returns "-" when the cost is unknown or the
583
+ // percent is missing.
584
+ function limitFullValueUsd(totals, usedPercent) {
585
+ if (totals.estimatedCreditsStatus === "unavailable") {
586
+ return null;
587
+ }
588
+ const usedUsd = totals.estimatedCredits * CODEX_CREDIT_COST_USD;
589
+ return estimateLimitFullValue(usedUsd, usedPercent);
590
+ }
591
+ function formatLimitFullValueCompact(totals, usedPercent) {
592
+ const estimate = limitFullValueUsd(totals, usedPercent);
593
+ return estimate ? formatUsdWhole(estimate.point) : "-";
594
+ }
595
+ function formatLimitFullValueRange(totals, usedPercent) {
596
+ const estimate = limitFullValueUsd(totals, usedPercent);
597
+ if (!estimate) {
598
+ return "-";
599
+ }
600
+ const low = formatUsd(estimate.low);
601
+ return Number.isFinite(estimate.high) ? `${low} – ${formatUsd(estimate.high)}` : `≥ ${low}`;
602
+ }
568
603
  function formatUnitUsd(value) {
569
604
  if (!Number.isFinite(value)) {
570
605
  return "-";
@@ -236,10 +236,20 @@ function clampPercent(value) {
236
236
  function deduplicateRecords(records) {
237
237
  const byKey = new Map();
238
238
  for (const record of records) {
239
- byKey.set(`${record.sessionId}:${record.responseId}`, record);
239
+ const key = `${record.sessionId}:${record.responseId}`;
240
+ const existing = byKey.get(key);
241
+ // The RPC may surface the same response more than once (e.g. progressive
242
+ // snapshots in unspecified order). Keep the largest coherent total rather
243
+ // than trusting iteration order, so we never undercount a final snapshot.
244
+ if (!existing || recordTokenTotal(record) > recordTokenTotal(existing)) {
245
+ byKey.set(key, record);
246
+ }
240
247
  }
241
248
  return [...byKey.values()];
242
249
  }
250
+ function recordTokenTotal(record) {
251
+ return record.input + record.cacheRead + record.cacheWrite + record.output;
252
+ }
243
253
  function usageRecordToTotals(modelId, record) {
244
254
  return {
245
255
  inputTokens: record.input,
@@ -255,11 +265,12 @@ function usageRecordToTotals(modelId, record) {
255
265
  record.output,
256
266
  estimatedCredits: creditsFor(modelId, record),
257
267
  eventCount: 1,
258
- // The local RPC reports cache reads but never cache writes, so cache reads
259
- // are accurate while cache writes are genuinely unknown (not a confirmed
260
- // zero) surfaced as "-" everywhere, including the input/output ratio.
268
+ // The local RPC reports cache reads but never cache writes, so a zero cache
269
+ // write is genuinely unknown (not a confirmed zero) and is surfaced as "-".
270
+ // A positive value only appears when a source explicitly reports it, in
271
+ // which case it is both billed (see creditsFor) and shown as known.
261
272
  cacheReadStatus: "known",
262
- cacheWriteStatus: "unavailable",
273
+ cacheWriteStatus: record.cacheWrite > 0 ? "known" : "unavailable",
263
274
  estimatedCreditsStatus: rateForModel(modelId, record.input)
264
275
  ? "known"
265
276
  : "unavailable"
@@ -240,7 +240,9 @@ function creditsFor(modelId, usage, timestampMs) {
240
240
  return 0;
241
241
  }
242
242
  const cacheWriteBreakdown = resolveClaudeCacheWriteBreakdown(usage);
243
- const inferenceMultiplier = usage.inferenceGeo === "us" ? 1.1 : 1;
243
+ // The US inference surcharge must match regardless of the casing the source
244
+ // reports (e.g. "us", "US"), so compare case-insensitively.
245
+ const inferenceMultiplier = usage.inferenceGeo.trim().toLowerCase() === "us" ? 1.1 : 1;
244
246
  return (((usage.inputTokens / 1000000) * rate.input +
245
247
  (usage.cacheReadInputTokens / 1000000) * rate.cacheRead +
246
248
  (cacheWriteBreakdown.cacheWrite5mInputTokens / 1000000) * rate.cacheWrite5m +
@@ -566,15 +568,20 @@ function mergeParsedUsageEvents(previous, next) {
566
568
  rateLimits: latestEvent.rateLimits ?? previous.rateLimits ?? next.rateLimits
567
569
  };
568
570
  }
569
- // Pick the snapshot that carries the most usage. Cumulative snapshots are monotonic, so the
570
- // largest total is the final state; this also keeps a real synthetic-followup row (0 tokens)
571
- // from clobbering the real usage it follows. Ties fall back to the later, then the earlier-seen
572
- // event for deterministic output.
571
+ // Pick the snapshot with the latest timestamp. Same-key events are repeated/streamed snapshots
572
+ // of one logical request, so the most recent one reflects the final state. The one exception is
573
+ // a zero-usage internal <synthetic> completion marker, which is a real followup row rather than
574
+ // an updated snapshot and must not clobber the real usage it follows.
573
575
  function selectMergedSnapshotEvent(previous, next) {
574
- if (next.totals.totalTokens !== previous.totals.totalTokens) {
575
- return next.totals.totalTokens > previous.totals.totalTokens ? next : previous;
576
+ const previousIsHollowSynthetic = isInternalClaudeModel(previous.modelId) && previous.totals.totalTokens === 0;
577
+ const nextIsHollowSynthetic = isInternalClaudeModel(next.modelId) && next.totals.totalTokens === 0;
578
+ if (nextIsHollowSynthetic && !previousIsHollowSynthetic) {
579
+ return previous;
576
580
  }
577
- return normalizeTimestamp(next.timestampMs) > normalizeTimestamp(previous.timestampMs) ? next : previous;
581
+ if (previousIsHollowSynthetic && !nextIsHollowSynthetic) {
582
+ return next;
583
+ }
584
+ return normalizeTimestamp(next.timestampMs) >= normalizeTimestamp(previous.timestampMs) ? next : previous;
578
585
  }
579
586
  function selectMergedEventModelId(primary, other) {
580
587
  if (primary.modelId === other.modelId) {
@@ -7,10 +7,16 @@ import { UsageProviderBase, addUsageTotals, createEmptyUsageTotals, sumUsageTota
7
7
  import { applyRateLimits, asRecord, buildWindowLists, createLimitWindowAggregates, numberOrZero } from "./limits.js";
8
8
  import { addDailyUsage, buildDailyUsageRows, createDailyUsageAggregates } from "./daily.js";
9
9
  import { resolveUsageRate } from "./pricing.js";
10
+ // One credit equals $0.01 (see CODEX_CREDIT_COST_USD in index.tsx), so credits
11
+ // equal USD * 100. Rate cards are expressed in the model's actual API price in
12
+ // USD per 1M tokens and scaled to credits in creditsFor, matching the Claude
13
+ // provider. These are the real gpt-5.* API prices, not the (4x cheaper) Codex
14
+ // subscription credit prices.
15
+ const USD_TO_CREDITS = 100;
10
16
  const RATE_CARD = {
11
- "gpt-5.5": { input: 125, cacheRead: 12.5, cacheWrite: 125, cacheWrite5m: 125, cacheWrite1h: 125, output: 750 },
12
- "gpt-5.4": { input: 62.5, cacheRead: 6.25, cacheWrite: 62.5, cacheWrite5m: 62.5, cacheWrite1h: 62.5, output: 375 },
13
- "gpt-5.4-mini": { input: 18.75, cacheRead: 1.875, cacheWrite: 18.75, cacheWrite5m: 18.75, cacheWrite1h: 18.75, output: 113 }
17
+ "gpt-5.5": { input: 5, cacheRead: 0.5, cacheWrite: 5, cacheWrite5m: 5, cacheWrite1h: 5, output: 30 },
18
+ "gpt-5.4": { input: 2.5, cacheRead: 0.25, cacheWrite: 2.5, cacheWrite5m: 2.5, cacheWrite1h: 2.5, output: 15 },
19
+ "gpt-5.4-mini": { input: 0.75, cacheRead: 0.075, cacheWrite: 0.75, cacheWrite5m: 0.75, cacheWrite1h: 0.75, output: 4.5 }
14
20
  };
15
21
  export class CodexUsageProvider extends UsageProviderBase {
16
22
  constructor(options = {}) {
@@ -235,9 +241,10 @@ function creditsFor(modelId, usage) {
235
241
  }
236
242
  const cachedInputTokens = Math.min(usage.cachedInputTokens, usage.inputTokens);
237
243
  const nonCachedInputTokens = Math.max(0, usage.inputTokens - cachedInputTokens);
238
- return ((nonCachedInputTokens / 1000000) * rate.input +
244
+ return (((nonCachedInputTokens / 1000000) * rate.input +
239
245
  (cachedInputTokens / 1000000) * rate.cacheRead +
240
- (usage.outputTokens / 1000000) * rate.output);
246
+ (usage.outputTokens / 1000000) * rate.output) *
247
+ USD_TO_CREDITS);
241
248
  }
242
249
  function rawUsageToTotals(usage) {
243
250
  const cacheReadInputTokens = Math.min(usage.cachedInputTokens, usage.inputTokens);
@@ -3,11 +3,42 @@ export function createLimitWindowAggregates() {
3
3
  return new Map();
4
4
  }
5
5
  export function numberOrZero(value) {
6
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
6
+ if (typeof value === "number") {
7
+ return Number.isFinite(value) ? value : 0;
8
+ }
9
+ if (typeof value === "string") {
10
+ const trimmed = value.trim();
11
+ if (trimmed === "") {
12
+ return 0;
13
+ }
14
+ const parsed = Number(trimmed);
15
+ return Number.isFinite(parsed) ? parsed : 0;
16
+ }
17
+ return 0;
7
18
  }
8
19
  export function asRecord(value) {
9
20
  return value && typeof value === "object" ? value : null;
10
21
  }
22
+ /**
23
+ * Extrapolate the full value of a limit from a partial observation: if
24
+ * `usedValue` represents `usedPercent` of the limit, the full limit is
25
+ * `usedValue / (usedPercent / 100)`. Because `usedPercent` is only known
26
+ * approximately, a `±percentTolerance` band yields a low/high range around the
27
+ * point estimate. Returns null when there is nothing to extrapolate from
28
+ * (no observed value, or a non-positive percent).
29
+ */
30
+ export function estimateLimitFullValue(usedValue, usedPercent, percentTolerance = 1) {
31
+ if (!(usedValue > 0) || !(usedPercent > 0)) {
32
+ return null;
33
+ }
34
+ const toFull = (percent) => usedValue / (percent / 100);
35
+ const upperPercent = usedPercent - percentTolerance;
36
+ return {
37
+ point: toFull(usedPercent),
38
+ low: toFull(usedPercent + percentTolerance),
39
+ high: upperPercent > 0 ? toFull(upperPercent) : Infinity
40
+ };
41
+ }
11
42
  export function applyRateLimits(windows, rateLimits, eventTimeMs, modelId, deltaTotals, planTypes) {
12
43
  if (!rateLimits) {
13
44
  return;
@@ -4,7 +4,9 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  const REPORTING_ENDPOINT = "https://devforth.io/admin/api/report_ussage_anonymous";
6
6
  const CREDIT_TO_DOLLARS = 0.01;
7
- const MIN_REPORTED_USED_PERCENTS = 1;
7
+ // Limit windows at or below this used-percent carry too little signal to be
8
+ // worth reporting, so they are dropped from the anonymous usage payload.
9
+ const SKIP_REPORT_USED_PERCENTS = 3;
8
10
  let versionCache = null;
9
11
  export async function reportAnonymousUsage(statsList) {
10
12
  const payload = await buildAnonymousUsagePayload(statsList);
@@ -103,7 +105,7 @@ function resolveReportedUsedPercents(window) {
103
105
  return clampPercent(window.maxUsedPercent - window.minUsedPercent);
104
106
  }
105
107
  function shouldReportUsageWindow(window) {
106
- return resolveReportedUsedPercents(window) >= MIN_REPORTED_USED_PERCENTS;
108
+ return resolveReportedUsedPercents(window) > SKIP_REPORT_USED_PERCENTS;
107
109
  }
108
110
  function clampPercent(value) {
109
111
  if (!Number.isFinite(value)) {
package/package.json CHANGED
@@ -1,10 +1,9 @@
1
1
  {
2
2
  "name": "letmecode",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "description": "Terminal AI usage dashboard for Codex, Claude, Copilot, and Antigravity.",
5
5
  "author": "Devforth (https://devforth.io)",
6
6
  "license": "MIT",
7
- "packageManager": "pnpm@10.28.2",
8
7
  "type": "commonjs",
9
8
  "main": "./dist/index.js",
10
9
  "bin": {
@@ -30,16 +29,6 @@
30
29
  "publishConfig": {
31
30
  "access": "public"
32
31
  },
33
- "scripts": {
34
- "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true }); require('node:fs').rmSync('ink-app/dist', { recursive: true, force: true });\"",
35
- "build": "npm run clean && tsc -p tsconfig.json && tsc -p ink-app/tsconfig.json",
36
- "prepack": "npm run build",
37
- "prestart": "npm run build",
38
- "start": "node ./bin/letmecode.js",
39
- "pretest": "npm run build",
40
- "smoke": "node ./bin/letmecode.js",
41
- "test": "node --test ink-app/test/*.test.mjs"
42
- },
43
32
  "keywords": [
44
33
  "ai",
45
34
  "agents",
@@ -64,5 +53,14 @@
64
53
  "@types/node": "^24.0.7",
65
54
  "@types/react": "^18.3.24",
66
55
  "typescript": "^5.8.3"
56
+ },
57
+ "scripts": {
58
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true }); require('node:fs').rmSync('ink-app/dist', { recursive: true, force: true });\"",
59
+ "build": "npm run clean && tsc -p tsconfig.json && tsc -p ink-app/tsconfig.json",
60
+ "prestart": "npm run build",
61
+ "start": "node ./bin/letmecode.js",
62
+ "pretest": "npm run build",
63
+ "smoke": "node ./bin/letmecode.js",
64
+ "test": "node --test ink-app/test/*.test.mjs"
67
65
  }
68
- }
66
+ }