tledger 0.2.0 → 0.3.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.
@@ -0,0 +1,24 @@
1
+ export const INTERACTIVE_KEY_INPUTS = Object.freeze({
2
+ up: Object.freeze(["\u001b[A", "k"]),
3
+ down: Object.freeze(["\u001b[B", "j"]),
4
+ quit: Object.freeze(["q", "Q", "\u0003", "\u001b"]),
5
+ });
6
+
7
+ export const INTERACTIVE_FOOTER = Object.freeze({
8
+ ascii: "[j/k] select [q/Q/esc/ctrl-c] quit",
9
+ unicode: "[↑↓/j/k] select [q/Q/esc/ctrl-c] quit",
10
+ });
11
+
12
+ export const INTERACTIVE_HELP = "↑/↓ or j/k move • q/Q, esc, or ctrl-c quit";
13
+
14
+ export function actionFor(input) {
15
+ const value = String(input);
16
+ if (value.includes(INTERACTIVE_KEY_INPUTS.up[0]) || value === INTERACTIVE_KEY_INPUTS.up[1]) {
17
+ return "up";
18
+ }
19
+ if (value.includes(INTERACTIVE_KEY_INPUTS.down[0]) || value === INTERACTIVE_KEY_INPUTS.down[1]) {
20
+ return "down";
21
+ }
22
+ if (INTERACTIVE_KEY_INPUTS.quit.includes(value)) return "quit";
23
+ return null;
24
+ }
@@ -18,10 +18,13 @@ export const RATE_CARD = {
18
18
  };
19
19
 
20
20
  export function normalizeModel(model) {
21
+ // Collapse underscore and whitespace separators to dashes so variants like
22
+ // "gpt-5.4 mini" resolve to their own rate-card entry. Keep in lockstep
23
+ // with normalizeModel in lib/token-ledger-importer.mjs.
21
24
  const value = String(model || "unknown")
22
25
  .trim()
23
26
  .toLowerCase()
24
- .replaceAll("_", "-");
27
+ .replace(/[\s_]+/g, "-");
25
28
  if (RATE_CARD[value]) return value;
26
29
  if (value.startsWith("gpt-5.6-sol")) return "gpt-5.6-sol";
27
30
  if (value.startsWith("gpt-5.6-terra")) return "gpt-5.6-terra";
@@ -1,7 +1,17 @@
1
1
  import {
2
+ eventCredits,
2
3
  normalizeQuotaTimeline,
3
4
  weeklyQuotaObservations,
4
5
  } from "./token-ledger-trend.mjs";
6
+ import {
7
+ INTERACTIVE_FOOTER,
8
+ INTERACTIVE_HELP,
9
+ } from "./token-ledger-controls.mjs";
10
+ import {
11
+ usageBucketsInRange,
12
+ usageCallCount,
13
+ usageThreadIds,
14
+ } from "../lib/token-ledger-usage.mjs";
5
15
 
6
16
  const RESET = "\u001b[0m";
7
17
  const PRIMARY_STYLE = [38, 2, 255, 255, 255];
@@ -74,12 +84,18 @@ function compact(value) {
74
84
  [1_000_000, "M"],
75
85
  [1_000, "K"],
76
86
  ];
77
- for (const [divisor, suffix] of units) {
78
- if (absolute >= divisor) {
79
- const scaled = value / divisor;
80
- const precision = scaled >= 100 ? 0 : scaled >= 10 ? 1 : 2;
81
- return `${scaled.toFixed(precision)}${suffix}`;
87
+ for (let index = 0; index < units.length; index += 1) {
88
+ const [divisor, suffix] = units[index];
89
+ if (absolute < divisor) continue;
90
+ const scaled = value / divisor;
91
+ const magnitude = Math.abs(scaled);
92
+ const precision = magnitude >= 100 ? 0 : magnitude >= 10 ? 1 : 2;
93
+ // Values that round to 1000 of a unit belong to the next unit up
94
+ // (999,999 → 1.00M, not 1000K).
95
+ if (index > 0 && Number(magnitude.toFixed(precision)) >= 1_000) {
96
+ return compact(Math.sign(value) * divisor * 1_000);
82
97
  }
98
+ return `${scaled.toFixed(precision)}${suffix}`;
83
99
  }
84
100
  return Math.round(value).toLocaleString("en-US");
85
101
  }
@@ -127,7 +143,13 @@ function displayProject(row) {
127
143
  return row.displayProject || row.project || "Unlabelled activity";
128
144
  }
129
145
 
130
- function dateLabel(bounds, range = "day") {
146
+ function isRollingRange(range) {
147
+ return range === "rolling24h" || range === "rolling";
148
+ }
149
+
150
+ function dateLabel(bounds, range = "day", rollingLabel = "1 day") {
151
+ if (range === "rolling24h") return "LAST 24 HOURS";
152
+ if (range === "rolling") return `LAST ${rollingLabel.toUpperCase()}`;
131
153
  if (range === "week" && bounds.startDateString && bounds.endDateString) {
132
154
  const startParts = bounds.startDateString.split("-").map(Number);
133
155
  const endParts = bounds.endDateString.split("-").map(Number);
@@ -227,30 +249,52 @@ export function quotaCycleSummary(snapshot = {}, displayedEvents = []) {
227
249
  };
228
250
  }
229
251
 
230
- const inObservedCycle = (event) => {
231
- const eventMs = new Date(event.timestamp).getTime();
232
- return (
233
- Number.isFinite(eventMs) &&
234
- eventMs >= windowStartMs &&
235
- eventMs <= observedThroughMs
252
+ const sumUsage = (events) =>
253
+ events.reduce(
254
+ (acc, event) => {
255
+ const tokens = Number(event.totalTokens) || 0;
256
+ acc.tokens += tokens;
257
+ const credits = eventCredits(event);
258
+ if (Number.isFinite(credits) && credits >= 0) {
259
+ acc.credits += credits;
260
+ acc.ratedTokens += tokens;
261
+ }
262
+ return acc;
263
+ },
264
+ { tokens: 0, credits: 0, ratedTokens: 0 },
236
265
  );
237
- };
238
- const sumTokens = (events) =>
239
- events.reduce((sum, event) => sum + (Number(event.totalTokens) || 0), 0);
240
- const cycleTokens = sumTokens((snapshot.events ?? []).filter(inObservedCycle));
241
- const displayedTokens = sumTokens(displayedEvents.filter(inObservedCycle));
266
+ const cycleEndMs = observedThroughMs + 1;
267
+ const cycle = sumUsage(
268
+ usageBucketsInRange(snapshot, windowStartMs, cycleEndMs),
269
+ );
270
+ const displayed = sumUsage(
271
+ usageBucketsInRange(
272
+ { events: displayedEvents },
273
+ windowStartMs,
274
+ cycleEndMs,
275
+ ),
276
+ );
242
277
  const usedPercent = Math.min(100, Math.max(0, Number(observation.usedPercent) || 0));
243
- const displayedSharePercent = cycleTokens
244
- ? (displayedTokens / cycleTokens) * 100
245
- : null;
278
+ // The weekly meter weights usage by model, token type, and fast mode;
279
+ // rate-card credits carry those weights. Allocate burn by credit share
280
+ // when every event in the cycle is rated, and fall back to raw token
281
+ // share otherwise.
282
+ const creditsUsable =
283
+ cycle.tokens > 0 && cycle.ratedTokens === cycle.tokens && cycle.credits > 0;
284
+ const displayedSharePercent = creditsUsable
285
+ ? (displayed.credits / cycle.credits) * 100
286
+ : cycle.tokens
287
+ ? (displayed.tokens / cycle.tokens) * 100
288
+ : null;
246
289
 
247
290
  return {
248
291
  available: true,
249
292
  usedPercent,
250
293
  remainingPercent: 100 - usedPercent,
251
- cycleTokens,
252
- displayedTokens,
294
+ cycleTokens: cycle.tokens,
295
+ displayedTokens: displayed.tokens,
253
296
  displayedSharePercent,
297
+ shareBasis: creditsUsable ? "credits" : "tokens",
254
298
  estimatedDisplayedBurnPercent:
255
299
  displayedSharePercent === null
256
300
  ? null
@@ -263,8 +307,13 @@ function summary(events) {
263
307
  (sum, event) => sum + (Number(event.totalTokens) || 0),
264
308
  0,
265
309
  );
266
- const calls = events.length;
267
- const threadIds = new Set(events.map((event) => event.threadId).filter(Boolean));
310
+ const calls = events.reduce(
311
+ (sum, event) => sum + usageCallCount(event),
312
+ 0,
313
+ );
314
+ const threadIds = new Set(
315
+ events.flatMap((event) => usageThreadIds(event)),
316
+ );
268
317
  const outputTokens = events.reduce(
269
318
  (sum, event) => sum + (Number(event.outputTokens) || 0),
270
319
  0,
@@ -496,10 +545,28 @@ function panel(leftLines, rightLines, leftWidth, rightWidth, enabled, ascii) {
496
545
  return [top, ...body, bottom];
497
546
  }
498
547
 
499
- function headerLines(stats, bounds, frameWidth, options, enabled) {
548
+ function snapshotLine(freshness, enabled) {
549
+ const detail = freshness?.status === "fresh" || freshness?.status === "stale"
550
+ ? `${freshness.status} · ${freshness.ageLabel}`
551
+ : "age unknown";
552
+ return `${colorize("SNAPSHOT", ACCENT_STYLE, enabled)} ${colorize("·", SECONDARY_STYLE, enabled)} ${colorize(detail, SECONDARY_STYLE, enabled)}`;
553
+ }
554
+
555
+ function headerLines(stats, bounds, frameWidth, options, enabled, freshness) {
500
556
  const left = colorize("TOKEN LEDGER", TITLE_STYLE, enabled);
501
- const date = colorize(dateLabel(bounds, options.range), TEXT_STYLE, enabled);
502
- const mode = colorize(options.range === "week" ? "7 DAYS" : "DAY", [1, ...ACCENT_STYLE], enabled);
557
+ const date = colorize(
558
+ dateLabel(bounds, options.range, options.rollingLabel),
559
+ TEXT_STYLE,
560
+ enabled,
561
+ );
562
+ const modeLabel = options.range === "rolling24h"
563
+ ? "24 HOURS"
564
+ : options.range === "rolling"
565
+ ? options.rollingLabel.toUpperCase()
566
+ : options.range === "week"
567
+ ? "7 DAYS"
568
+ : "DAY";
569
+ const mode = colorize(modeLabel, [1, ...ACCENT_STYLE], enabled);
503
570
  const metric = (value, label) =>
504
571
  `${colorize(String(value), TITLE_STYLE, enabled)} ${colorize(label, SECONDARY_STYLE, enabled)}`;
505
572
  const separator = colorize("·", SECONDARY_STYLE, enabled);
@@ -515,13 +582,21 @@ function headerLines(stats, bounds, frameWidth, options, enabled) {
515
582
  metric(stats.projectCount.toLocaleString("en-US"), "PROJECTS"),
516
583
  ].join(join);
517
584
  if (visibleLength(fullLine) < frameWidth) {
518
- return [alignHeader(fullLine)];
585
+ const lines = [alignHeader(fullLine)];
586
+ if (isRollingRange(options.range)) lines.push(alignHeader(snapshotLine(freshness, enabled)));
587
+ return lines;
519
588
  }
520
589
 
521
- const compactDate = dateLabel(bounds, options.range)
590
+ const compactDate = dateLabel(bounds, options.range, options.rollingLabel)
522
591
  .replace(/ 20\d{2}$/, "")
523
592
  .replace(" – ", "–");
524
- const compactMode = options.range === "week" ? "7D" : "DAY";
593
+ const compactMode = options.range === "rolling24h"
594
+ ? "24H"
595
+ : options.range === "rolling"
596
+ ? `${options.rollingAmount}${options.rollingUnit.toUpperCase()}`
597
+ : options.range === "week"
598
+ ? "7D"
599
+ : "DAY";
525
600
  const compactLine = [
526
601
  left,
527
602
  colorize(compactDate, TEXT_STYLE, enabled),
@@ -532,7 +607,9 @@ function headerLines(stats, bounds, frameWidth, options, enabled) {
532
607
  metric(stats.projectCount.toLocaleString("en-US"), "P"),
533
608
  ].join(join);
534
609
  if (visibleLength(compactLine) < frameWidth) {
535
- return [alignHeader(compactLine)];
610
+ const lines = [alignHeader(compactLine)];
611
+ if (isRollingRange(options.range)) lines.push(alignHeader(snapshotLine(freshness, enabled)));
612
+ return lines;
536
613
  }
537
614
 
538
615
  const minimalTitle = colorize(frameWidth >= 45 ? "LEDGER" : "L", TITLE_STYLE, enabled);
@@ -545,10 +622,20 @@ function headerLines(stats, bounds, frameWidth, options, enabled) {
545
622
  compact(stats.threads),
546
623
  compact(stats.projectCount),
547
624
  ].join(" ");
548
- return [alignHeader(minimalLine)];
625
+ const lines = [alignHeader(minimalLine)];
626
+ if (isRollingRange(options.range)) lines.push(alignHeader(snapshotLine(freshness, enabled)));
627
+ return lines;
549
628
  }
550
629
 
551
- export function renderTerminal({ options, snapshot, bounds, events, rows, allRows }) {
630
+ export function renderTerminal({
631
+ options,
632
+ snapshot,
633
+ snapshotFreshness,
634
+ bounds,
635
+ events,
636
+ rows,
637
+ allRows,
638
+ }) {
552
639
  const enabled = colorsEnabled(options);
553
640
  const stats = summary(events);
554
641
  const quota = quotaCycleSummary(snapshot, events);
@@ -561,19 +648,25 @@ export function renderTerminal({ options, snapshot, bounds, events, rows, allRow
561
648
  const left = panelLines(rows, allRows, stats.totalTokens, leftWidth, options, enabled);
562
649
  const right = sideBySide ? sidebarLines(stats, sideWidth, enabled, options, quota) : null;
563
650
  const lines = [
564
- ...headerLines(stats, bounds, frameWidth, options, enabled),
651
+ ...headerLines(stats, bounds, frameWidth, options, enabled, snapshotFreshness),
565
652
  ...panel(left, right, leftWidth, sideWidth, enabled, options.ascii),
566
653
  ];
567
654
  if (!sideBySide) {
568
655
  lines.push("");
569
656
  lines.push(...sidebarLines(stats, frameWidth, enabled, options, quota));
570
657
  }
658
+ if (events.some((event) => event.rangeAllocationEstimated === true)) {
659
+ lines.push("");
660
+ lines.push(colorize(
661
+ "≈ Boundary-spanning compact history is allocated proportionally.",
662
+ SECONDARY_STYLE,
663
+ enabled,
664
+ ));
665
+ }
571
666
  lines.push("");
572
667
  lines.push(
573
668
  colorize(
574
- options.ascii
575
- ? "[j/k] select [enter] inspect [d/w/m] range [q] quit"
576
- : "[↑↓] select [enter] inspect [d/w/m] range [q] quit",
669
+ options.ascii ? INTERACTIVE_FOOTER.ascii : INTERACTIVE_FOOTER.unicode,
577
670
  SECONDARY_STYLE,
578
671
  enabled,
579
672
  ),
@@ -591,7 +684,17 @@ function paintFullscreenLine(line, width, background, enabled) {
591
684
  return `${background}${restored}${RESET}`;
592
685
  }
593
686
 
594
- export function renderFullscreen({ options, snapshot, bounds, events, rows, allRows, width, height }) {
687
+ export function renderFullscreen({
688
+ options,
689
+ snapshot,
690
+ snapshotFreshness,
691
+ bounds,
692
+ events,
693
+ rows,
694
+ allRows,
695
+ width,
696
+ height,
697
+ }) {
595
698
  const enabled = options.forceColor ?? colorsEnabled(options);
596
699
  const columns = Math.max(40, Number(width) || Number(process.stdout.columns) || 120);
597
700
  const screenHeight = Math.max(1, Number(height) || Number(process.stdout.rows) || 32);
@@ -606,6 +709,7 @@ export function renderFullscreen({ options, snapshot, bounds, events, rows, allR
606
709
  width: frameWidth + 2,
607
710
  },
608
711
  snapshot,
712
+ snapshotFreshness,
609
713
  bounds,
610
714
  events,
611
715
  rows,
@@ -615,7 +719,7 @@ export function renderFullscreen({ options, snapshot, bounds, events, rows, allR
615
719
  staticLines.pop();
616
720
  if (staticLines.at(-1) === "") staticLines.pop();
617
721
  const summaryLine = staticLines.shift() ?? "";
618
- const help = colorize("↑/↓ move • j/k move • q/esc quit", SECONDARY_STYLE, enabled);
722
+ const help = colorize(INTERACTIVE_HELP, SECONDARY_STYLE, enabled);
619
723
  const content = [
620
724
  { line: summaryLine, background: SCREEN_BASE },
621
725
  ...staticLines.map((line) => ({ line, background: PANEL_BASE })),