tledger 0.3.0 → 0.4.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.
@@ -1,9 +1,30 @@
1
1
  import { buildBurnDayBins, buildUsageTrend, trendModelLabel } from "./token-ledger-trend.mjs";
2
+ import { chooseBinSize } from "./token-ledger-image-layout.mjs";
2
3
  import {
4
+ MAX_SAFE_TOKEN_COUNT,
5
+ checkedTokenAdd,
3
6
  splitUsageBucketsAtBoundaries,
7
+ tokenValue,
4
8
  usageBuckets,
5
9
  usageCallCount,
6
10
  } from "../lib/token-ledger-usage.mjs";
11
+ import {
12
+ createTimeZoneFormatter,
13
+ formatCalendarDate,
14
+ localDateBoundary,
15
+ localDateString,
16
+ shiftCalendarDate,
17
+ } from "../lib/token-ledger-calendar.mjs";
18
+ import { isFastServiceTier } from "../lib/token-ledger-rates.mjs";
19
+ import { sanitizeTerminalText } from "../lib/token-ledger-terminal-text.mjs";
20
+ import {
21
+ snapshotFreshnessDetail,
22
+ sourceStatusLine,
23
+ } from "./token-ledger-source-status.mjs";
24
+ import { incompleteSourceWarning } from "./token-ledger-terminal.mjs";
25
+
26
+ export { chooseBinSize } from "./token-ledger-image-layout.mjs";
27
+ import { historyScopeLabel } from "../lib/token-ledger-collection.mjs";
7
28
 
8
29
  const RESET = "\u001b[0m";
9
30
  const PRIMARY_STYLE = [38, 2, 255, 255, 255];
@@ -12,9 +33,97 @@ const BORDER_STYLE = [38, 2, 88, 88, 88];
12
33
  const GRID_STYLE = [38, 2, 72, 72, 72];
13
34
  const LINE_STYLE = [1, 38, 2, 255, 236, 168];
14
35
  const RESET_LINE_STYLE = [1, 38, 2, 255, 255, 255];
36
+ const TOKEN_SCALE = Symbol("tokenScale");
37
+
38
+ function tokenScale(target) {
39
+ return Number.isFinite(target[TOKEN_SCALE]) && target[TOKEN_SCALE] >= 1
40
+ ? target[TOKEN_SCALE]
41
+ : 1;
42
+ }
43
+
44
+ function setTokenScale(target, scale) {
45
+ Object.defineProperty(target, TOKEN_SCALE, {
46
+ configurable: true,
47
+ enumerable: false,
48
+ value: scale,
49
+ writable: true,
50
+ });
51
+ }
52
+
53
+ function scaleTokenMap(values, ratio) {
54
+ for (const [model, value] of values) {
55
+ values.set(model, value * ratio);
56
+ }
57
+ }
58
+
59
+ function addBinTokens(bin, model, tokens, fast) {
60
+ if (!(tokens > 0)) return;
61
+ const scale = tokenScale(bin);
62
+ const scaledTokens = tokens / scale;
63
+ bin.totalTokens += scaledTokens;
64
+ bin.values.set(model, (bin.values.get(model) ?? 0) + scaledTokens);
65
+ if (fast) {
66
+ bin.fastValues.set(model, (bin.fastValues.get(model) ?? 0) + scaledTokens);
67
+ }
68
+
69
+ const scaleFactor = Math.max(1, bin.totalTokens / MAX_SAFE_TOKEN_COUNT);
70
+ if (scaleFactor === 1) return;
71
+ bin.totalTokens = MAX_SAFE_TOKEN_COUNT;
72
+ scaleTokenMap(bin.values, 1 / scaleFactor);
73
+ scaleTokenMap(bin.fastValues, 1 / scaleFactor);
74
+ setTokenScale(bin, scale * scaleFactor);
75
+ }
76
+
77
+ function mergeBinTotals(state, bin) {
78
+ const sourceScale = tokenScale(bin);
79
+ const commonScale = Math.max(state.scale, sourceScale);
80
+ const targetRatio = state.scale / commonScale;
81
+ const sourceRatio = sourceScale / commonScale;
82
+ state.totalTokens *= targetRatio;
83
+ scaleTokenMap(state.values, targetRatio);
84
+ scaleTokenMap(state.fastValues, targetRatio);
85
+ for (const [model, value] of bin.values) {
86
+ state.totalTokens += value * sourceRatio;
87
+ state.values.set(
88
+ model,
89
+ (state.values.get(model) ?? 0) + value * sourceRatio,
90
+ );
91
+ }
92
+ for (const [model, value] of bin.fastValues) {
93
+ state.fastValues.set(
94
+ model,
95
+ (state.fastValues.get(model) ?? 0) + value * sourceRatio,
96
+ );
97
+ }
98
+ if (bin.estimated) state.estimated = true;
99
+ for (const model of bin.estimatedModels) {
100
+ state.estimatedModels.add(model);
101
+ }
102
+
103
+ const scaleFactor = Math.max(1, state.totalTokens / MAX_SAFE_TOKEN_COUNT);
104
+ if (scaleFactor > 1) {
105
+ state.totalTokens = MAX_SAFE_TOKEN_COUNT;
106
+ scaleTokenMap(state.values, 1 / scaleFactor);
107
+ scaleTokenMap(state.fastValues, 1 / scaleFactor);
108
+ }
109
+ state.scale = commonScale * scaleFactor;
110
+ }
111
+
112
+ function alignBinsToScale(bins, scale) {
113
+ for (const bin of bins) {
114
+ const sourceScale = tokenScale(bin);
115
+ if (sourceScale === scale) continue;
116
+ const ratio = sourceScale / scale;
117
+ bin.totalTokens *= ratio;
118
+ scaleTokenMap(bin.values, ratio);
119
+ scaleTokenMap(bin.fastValues, ratio);
120
+ setTokenScale(bin, scale);
121
+ }
122
+ }
15
123
 
16
124
  // Mirrors the SVG renderer's validated categorical palette.
17
125
  export const TREND_MODEL_COLORS = {
126
+ Astra: [38, 2, 232, 121, 249],
18
127
  Luna: [38, 2, 42, 120, 214],
19
128
  Sol: [38, 2, 235, 104, 52],
20
129
  Terra: [38, 2, 27, 175, 122],
@@ -28,6 +137,7 @@ export const TREND_MODEL_COLORS = {
28
137
  };
29
138
 
30
139
  const MODEL_ORDER = [
140
+ "Astra",
31
141
  "Luna",
32
142
  "Sol",
33
143
  "Terra",
@@ -39,6 +149,8 @@ const MODEL_ORDER = [
39
149
  "Unknown",
40
150
  ];
41
151
 
152
+ const ATTRIBUTION_MODEL_ORDER = ["Astra", "Luna", "Sol", "Terra"];
153
+
42
154
  function colorsEnabled(options = {}) {
43
155
  return options.forceColor ??
44
156
  (!options.plain && !process.env.NO_COLOR && Boolean(process.stdout.isTTY));
@@ -49,7 +161,7 @@ function colorize(value, style, enabled) {
49
161
  }
50
162
 
51
163
  function stripAnsi(value) {
52
- return String(value).replace(/\u001b\[[0-9;]*m/g, "");
164
+ return sanitizeTerminalText(value);
53
165
  }
54
166
 
55
167
  function visibleLength(value) {
@@ -112,101 +224,11 @@ function modelSort(left, right) {
112
224
  );
113
225
  }
114
226
 
115
- function dateParts(dateString) {
116
- return dateString.split("-").map(Number);
117
- }
118
-
119
- function dateStringFromParts(year, month, day) {
120
- return [year, month, day]
121
- .map((value, index) =>
122
- index === 0 ? String(value) : String(value).padStart(2, "0"),
123
- )
124
- .join("-");
125
- }
126
-
127
- function shiftCalendarDate(dateString, amount) {
128
- const [year, month, day] = dateParts(dateString);
129
- const date = new Date(Date.UTC(year, month - 1, day + amount));
130
- return dateStringFromParts(
131
- date.getUTCFullYear(),
132
- date.getUTCMonth() + 1,
133
- date.getUTCDate(),
134
- );
135
- }
136
-
137
- function timeZoneFormatter(timeZone) {
138
- return new Intl.DateTimeFormat("en-US", {
139
- timeZone,
140
- timeZoneName: "longOffset",
141
- year: "numeric",
142
- month: "2-digit",
143
- day: "2-digit",
144
- hour: "2-digit",
145
- minute: "2-digit",
146
- second: "2-digit",
147
- hourCycle: "h23",
148
- });
149
- }
150
-
151
- function timeZoneOffsetMs(instant, formatter) {
152
- const parts = formatter.formatToParts(instant);
153
- const value = parts.find((part) => part.type === "timeZoneName")?.value ?? "GMT";
154
- if (value === "GMT") return 0;
155
- const match = value.match(/^GMT([+-])(\d{2}):?(\d{2})?$/);
156
- if (!match) return 0;
157
- const minutes = Number(match[2]) * 60 + Number(match[3] || 0);
158
- return (match[1] === "+" ? 1 : -1) * minutes * 60 * 1_000;
159
- }
160
-
161
- function zonedMidnight(
162
- dateString,
163
- timeZone,
164
- formatter = timeZoneFormatter(timeZone),
165
- ) {
166
- const [year, month, day] = dateParts(dateString);
167
- const utcGuess = Date.UTC(year, month - 1, day);
168
- const first = new Date(utcGuess - timeZoneOffsetMs(new Date(utcGuess), formatter));
169
- return new Date(first.getTime() - timeZoneOffsetMs(first, formatter));
170
- }
171
-
172
- function localDateString(
173
- timestamp,
174
- timeZone,
175
- formatter = timeZoneFormatter(timeZone),
176
- ) {
177
- const parts = formatter.formatToParts(new Date(timestamp));
178
- const values = Object.fromEntries(
179
- parts
180
- .filter((part) => part.type !== "literal")
181
- .map((part) => [part.type, part.value]),
182
- );
183
- return `${values.year}-${values.month}-${values.day}`;
184
- }
185
-
186
- function localDateLabel(dateString, timeZone) {
187
- const date = zonedMidnight(dateString, timeZone);
188
- return new Intl.DateTimeFormat("en-US", {
189
- timeZone,
227
+ function localDateLabel(dateString) {
228
+ return formatCalendarDate(dateString, {
190
229
  month: "short",
191
230
  day: "2-digit",
192
- })
193
- .format(date)
194
- .toUpperCase();
195
- }
196
-
197
- export function chooseBinSize(days, width, { minBinWidth = 1, preferDaily = false } = {}) {
198
- const rangeDays = Number(days);
199
- const plotWidth = Math.max(1, Number(width) || 1);
200
- const minimumBinWidth = Math.max(1, Number(minBinWidth) || 1);
201
- const maxBinCount = Math.max(1, Math.floor(plotWidth / minimumBinWidth));
202
- const preferredBinSize = preferDaily
203
- ? 1
204
- : rangeDays <= 14
205
- ? 1
206
- : plotWidth >= 120
207
- ? 2
208
- : 3;
209
- return Math.max(preferredBinSize, Math.ceil(rangeDays / maxBinCount));
231
+ }).toUpperCase();
210
232
  }
211
233
 
212
234
  function sortedModelEntries(values) {
@@ -220,7 +242,12 @@ export function buildActualTokenBins(
220
242
  bounds,
221
243
  days,
222
244
  width,
223
- { binSize: forcedBinSize, minBinWidth, preferDaily } = {},
245
+ {
246
+ binSize: forcedBinSize,
247
+ minBinWidth,
248
+ preferDaily,
249
+ events = null,
250
+ } = {},
224
251
  ) {
225
252
  const binSize = forcedBinSize ?? chooseBinSize(days, width, { minBinWidth, preferDaily });
226
253
  const binCount = Math.ceil(days / binSize);
@@ -234,6 +261,8 @@ export function buildActualTokenBins(
234
261
  fastValues: new Map(),
235
262
  totalTokens: 0,
236
263
  calls: 0,
264
+ estimated: false,
265
+ estimatedModels: new Set(),
237
266
  }));
238
267
  const startDate = bounds.startDateString;
239
268
  const dateIndexByString = new Map(
@@ -242,16 +271,16 @@ export function buildActualTokenBins(
242
271
  index,
243
272
  ]),
244
273
  );
245
- const dateFormatter = timeZoneFormatter(bounds.timeZone);
274
+ const dateFormatter = createTimeZoneFormatter(bounds.timeZone);
246
275
  const binBoundaries = [
247
276
  bins[0]?.startDateString,
248
277
  ...bins.map((bin) => bin.endDateString),
249
278
  ]
250
279
  .filter(Boolean)
251
280
  .map((dateString) =>
252
- zonedMidnight(dateString, bounds.timeZone, dateFormatter).getTime());
281
+ localDateBoundary(dateString, bounds.timeZone, dateFormatter).getTime());
253
282
  for (const event of splitUsageBucketsAtBoundaries(
254
- usageBuckets(snapshot),
283
+ events ?? usageBuckets(snapshot),
255
284
  binBoundaries,
256
285
  )) {
257
286
  const timestamp = new Date(event.timestamp).getTime();
@@ -260,27 +289,42 @@ export function buildActualTokenBins(
260
289
  const dayIndex = dateIndexByString.get(dateString);
261
290
  if (dayIndex === undefined || dayIndex >= days) continue;
262
291
  const bin = bins[Math.floor(dayIndex / binSize)];
263
- const tokens = Math.max(0, Number(event.totalTokens) || 0);
292
+ if (event?.invalidTokenRecord === true) continue;
293
+ const allowFractional = event.rangeAllocationEstimated === true;
294
+ const tokens = tokenValue(event.totalTokens, { allowFractional });
264
295
  const model = trendModelLabel(event.model);
265
- bin.totalTokens += tokens;
266
- bin.calls += usageCallCount(event);
267
- bin.values.set(model, (bin.values.get(model) ?? 0) + tokens);
268
- if (event.serviceTier === "priority") {
269
- bin.fastValues.set(model, (bin.fastValues.get(model) ?? 0) + tokens);
296
+ if (event.rangeAllocationEstimated === true && tokens > 0) {
297
+ bin.estimated = true;
298
+ bin.estimatedModels.add(model);
270
299
  }
300
+ bin.calls = checkedTokenAdd(bin.calls, usageCallCount(event), {
301
+ allowFractional,
302
+ });
303
+ addBinTokens(bin, model, tokens, isFastServiceTier(event.serviceTier));
271
304
  }
272
305
 
273
- const totals = new Map();
274
- const fastTotals = new Map();
306
+ const totalsState = {
307
+ scale: 1,
308
+ totalTokens: 0,
309
+ values: new Map(),
310
+ fastValues: new Map(),
311
+ estimated: false,
312
+ estimatedModels: new Set(),
313
+ };
275
314
  for (const bin of bins) {
276
- for (const [model, value] of bin.values) {
277
- totals.set(model, (totals.get(model) ?? 0) + value);
278
- }
279
- for (const [model, value] of bin.fastValues) {
280
- fastTotals.set(model, (fastTotals.get(model) ?? 0) + value);
281
- }
315
+ mergeBinTotals(totalsState, bin);
282
316
  }
283
- return { bins, totals, fastTotals, binSize, binCount };
317
+ alignBinsToScale(bins, totalsState.scale);
318
+ return {
319
+ bins,
320
+ totals: totalsState.values,
321
+ fastTotals: totalsState.fastValues,
322
+ estimated: totalsState.estimated,
323
+ estimatedModels: totalsState.estimatedModels,
324
+ scale: totalsState.scale,
325
+ binSize,
326
+ binCount,
327
+ };
284
328
  }
285
329
 
286
330
  function niceCeiling(value) {
@@ -296,7 +340,12 @@ function allocateSegmentHeights(entries, total, maxValue, plotHeight) {
296
340
  ? Math.max(1, Math.round((total / maxValue) * (plotHeight - 1)))
297
341
  : 0;
298
342
  if (!barHeight) return [];
299
- const ideal = entries.map(([, value]) => (value / total) * barHeight);
343
+ // Model counters can cap independently when the bin total reaches the
344
+ // safe-token limit. Partition the already-scaled bar across the model
345
+ // entries so capped components cannot make the stack taller than its bin.
346
+ const segmentTotal = entries.reduce((sum, [, value]) => sum + value, 0);
347
+ const partitionTotal = segmentTotal > 0 ? segmentTotal : total;
348
+ const ideal = entries.map(([, value]) => (value / partitionTotal) * barHeight);
300
349
  const heights = ideal.map(Math.floor);
301
350
  let remainder = barHeight - heights.reduce((sum, value) => sum + value, 0);
302
351
  const order = ideal
@@ -319,11 +368,17 @@ function allocateSegmentHeights(entries, total, maxValue, plotHeight) {
319
368
  return heights;
320
369
  }
321
370
 
322
- function sampleQuota(trend, bounds, width) {
371
+ export function sampleQuota(trend, bounds, width) {
323
372
  const startMs = bounds.start.getTime();
324
373
  const endMs = bounds.end.getTime();
325
374
  const points = trend.points ?? [];
326
375
  const resets = trend.resets ?? [];
376
+ const lastObservedPoint = [...points]
377
+ .reverse()
378
+ .find((point) => point.observed !== false);
379
+ const observedThroughMs = Number.isFinite(trend.observedThroughMs)
380
+ ? Math.min(endMs, trend.observedThroughMs)
381
+ : lastObservedPoint?.timestampMs ?? null;
327
382
  const samples = [];
328
383
  let pointIndex = 0;
329
384
  let resetIndex = 0;
@@ -332,6 +387,18 @@ function sampleQuota(trend, bounds, width) {
332
387
  for (let column = 0; column < width; column += 1) {
333
388
  const ratio = width <= 1 ? 0 : column / (width - 1);
334
389
  const timestampMs = startMs + (endMs - startMs) * ratio;
390
+ if (
391
+ observedThroughMs === null ||
392
+ timestampMs > observedThroughMs
393
+ ) {
394
+ samples.push({
395
+ timestampMs,
396
+ point: null,
397
+ reset: false,
398
+ remainingPercent: null,
399
+ });
400
+ continue;
401
+ }
335
402
  while (
336
403
  pointIndex < points.length &&
337
404
  points[pointIndex].timestampMs <= timestampMs
@@ -376,7 +443,7 @@ function lineRow(remainingPercent, plotHeight) {
376
443
  );
377
444
  }
378
445
 
379
- function xLabelLine(bins, plotWidth, leftWidth, rightWidth, timeZone) {
446
+ function xLabelLine(bins, plotWidth, leftWidth, rightWidth) {
380
447
  const labels = Array.from({ length: plotWidth }, () => " ");
381
448
  const write = (label, offset) => {
382
449
  for (let index = 0; index < label.length; index += 1) {
@@ -387,7 +454,7 @@ function xLabelLine(bins, plotWidth, leftWidth, rightWidth, timeZone) {
387
454
  bins.forEach((bin, index) => {
388
455
  const start = Math.round((index * plotWidth) / bins.length);
389
456
  const end = Math.round(((index + 1) * plotWidth) / bins.length);
390
- const label = localDateLabel(bin.startDateString, timeZone);
457
+ const label = localDateLabel(bin.startDateString);
391
458
  if (end - start >= label.length) {
392
459
  write(label, start + Math.floor((end - start - label.length) / 2));
393
460
  } else if (index === 0 || index === bins.length - 1 || end - start >= 4) {
@@ -406,9 +473,26 @@ function frameLine(content, width) {
406
473
  return `│${fit(content, width - 2)}│`;
407
474
  }
408
475
 
476
+ function wrapAttributionEntries(prefix, entries, width) {
477
+ const lines = [];
478
+ let line = prefix;
479
+ for (const entry of entries) {
480
+ const separator = line === prefix ? " · " : " ";
481
+ const candidate = `${line}${separator}${entry}`;
482
+ if (visibleLength(candidate) <= width) {
483
+ line = candidate;
484
+ continue;
485
+ }
486
+ lines.push(line);
487
+ line = entry;
488
+ }
489
+ lines.push(line);
490
+ return lines;
491
+ }
492
+
409
493
  function formatAttribution(trend, enabled, width, percentMode) {
410
494
  const rows = new Map((trend.models ?? []).map((row) => [row.model, row]));
411
- const entries = ["Luna", "Sol"]
495
+ const entries = ATTRIBUTION_MODEL_ORDER
412
496
  .map((model) => {
413
497
  const row = rows.get(model);
414
498
  if (!row || !(row.tokensPerBurnPoint > 0)) return null;
@@ -418,30 +502,20 @@ function formatAttribution(trend, enabled, width, percentMode) {
418
502
  })
419
503
  .filter(Boolean);
420
504
  if (!entries.length) return [];
421
- if (percentMode) {
422
- const valueLine = colorize(
423
- `Observed burn rate · ${entries.join(" ")}`,
424
- SECONDARY_STYLE,
425
- enabled,
426
- );
427
- const method = colorize(
428
- `Columns sum to observed meter drops; model split via rate-card credit weights (card ${trend.rateCardAsOf}).`,
429
- SECONDARY_STYLE,
430
- enabled,
431
- );
432
- return [fit(valueLine, width - 2), fit(method, width - 2)];
433
- }
434
- const valueLine = colorize(
435
- `ESTIMATE ONLY · quota attribution lens · ${entries.join(" ")}`,
436
- SECONDARY_STYLE,
437
- enabled,
505
+ const prefix = percentMode
506
+ ? "Observed burn rate"
507
+ : "ESTIMATE ONLY · quota attribution lens";
508
+ const valueLines = wrapAttributionEntries(prefix, entries, width - 2).map(
509
+ (line) => fit(colorize(line, SECONDARY_STYLE, enabled), width - 2),
438
510
  );
439
511
  const method = colorize(
440
- `Rate-card/token weights, ${trend.rateCardAsOf}; separate from actual-token bars and not official quota math.`,
512
+ percentMode
513
+ ? `Columns sum to observed meter drops; model split via rate-card credit weights (card ${trend.rateCardAsOf}).`
514
+ : `Rate-card/token weights, ${trend.rateCardAsOf}; separate from actual-token bars and not official quota math.`,
441
515
  SECONDARY_STYLE,
442
516
  enabled,
443
517
  );
444
- return [fit(valueLine, width - 2), fit(method, width - 2)];
518
+ return [...valueLines, fit(method, width - 2)];
445
519
  }
446
520
 
447
521
  function drainLabelLine(burnBins, plotWidth, leftWidth, rightWidth, enabled) {
@@ -467,10 +541,14 @@ function drainLabelLine(burnBins, plotWidth, leftWidth, rightWidth, enabled) {
467
541
  export function renderTrendCombo({
468
542
  snapshot,
469
543
  bounds,
470
- trend = buildUsageTrend(snapshot, bounds),
544
+ trend: providedTrend = null,
471
545
  days = bounds.rangeDays ?? 7,
472
546
  options = {},
547
+ analysis = null,
548
+ snapshotFreshness = null,
549
+ sourceStatus = "unchecked-cache",
473
550
  }) {
551
+ const trend = providedTrend ?? analysis?.trend ?? buildUsageTrend(snapshot, bounds, { analysis });
474
552
  const enabled = colorsEnabled(options);
475
553
  const frameWidth = Math.max(82, Math.min(158, Number(options.width) || 120));
476
554
  const innerWidth = frameWidth - 2;
@@ -478,7 +556,9 @@ export function renderTrendCombo({
478
556
  const rightWidth = 7;
479
557
  const plotWidth = Math.max(36, innerWidth - leftWidth - rightWidth - 2);
480
558
  const plotHeight = 11;
481
- const actual = buildActualTokenBins(snapshot, bounds, days, plotWidth);
559
+ const actual = buildActualTokenBins(snapshot, bounds, days, plotWidth, {
560
+ events: analysis?.currentEvents,
561
+ });
482
562
  const burn = buildBurnDayBins(trend, bounds, {
483
563
  days,
484
564
  binSize: actual.binSize,
@@ -488,6 +568,9 @@ export function renderTrendCombo({
488
568
  // and shows the observed drop per column in a label row instead.
489
569
  const meterUsable = Boolean(trend.available && burn.totalPercent > 0);
490
570
  const percentMode = Boolean(options.drain) && meterUsable;
571
+ const meterAvailable = Boolean(trend.available && (trend.points ?? []).length > 0);
572
+ const drainFallback = Boolean(options.drain) && !percentMode;
573
+ const actualEstimated = actual.estimated === true;
491
574
  const barBins = percentMode ? burn.bins : actual.bins;
492
575
  const binTotal = (bin) => (percentMode ? bin.totalPercent : bin.totalTokens);
493
576
  const maxLeft = niceCeiling(
@@ -561,11 +644,23 @@ export function renderTrendCombo({
561
644
  }
562
645
 
563
646
  const axisRows = [0, Math.floor(baseline / 2), baseline];
647
+ const history = historyScopeLabel(snapshot);
648
+ const sourceWarning = incompleteSourceWarning(snapshot);
649
+ const actualModeTitle = drainFallback
650
+ ? "ACTUAL TOKENS · DRAIN UNAVAILABLE"
651
+ : meterAvailable
652
+ ? "ACTUAL TOKENS + WEEKLY QUOTA"
653
+ : "ACTUAL TOKENS";
654
+ const actualModeSubtitle = drainFallback
655
+ ? `DRAIN UNAVAILABLE · BARS = raw local tokens${actualEstimated ? " · ≈ marks allocated estimates" : ""}${meterAvailable ? " · LINE = meter remaining" : ""}`
656
+ : meterAvailable
657
+ ? `BARS = ${actualEstimated ? "token quantity by model · ≈ marks allocated estimates" : "actual token quantity by model"} · LINE = meter remaining${meterUsable ? " · -% row = observed drain per column" : " · no usable meter drain observed"}`
658
+ : `BARS = ${actualEstimated ? "token quantity by model · ≈ marks allocated estimates" : "actual token quantity by model"} · no account-wide weekly meter observed`;
564
659
  const lines = [
565
660
  `┌${"─".repeat(frameWidth - 2)}┐`,
566
661
  frameLine(
567
662
  colorize(
568
- `TOKEN LEDGER · ${percentMode ? "OBSERVED LIMIT DRAIN + WEEKLY METER" : "ACTUAL TOKENS + WEEKLY QUOTA"} · ${localDateLabel(bounds.startDateString, bounds.timeZone)} – ${localDateLabel(bounds.endDateString, bounds.timeZone)} · ${days}D`,
663
+ `TOKEN LEDGER · ${percentMode ? "OBSERVED LIMIT DRAIN + WEEKLY METER" : actualModeTitle} · ${localDateLabel(bounds.startDateString)} – ${localDateLabel(bounds.endDateString)} · ${days}D`,
569
664
  PRIMARY_STYLE,
570
665
  enabled,
571
666
  ),
@@ -575,14 +670,30 @@ export function renderTrendCombo({
575
670
  colorize(
576
671
  percentMode
577
672
  ? "BARS = observed limit % consumed per day by model · LINE = meter remaining · one percent scale"
578
- : meterUsable
579
- ? "BARS = actual token quantity by model · LINE = meter remaining · -% row = observed drain per column"
580
- : "BARS = actual token quantity by model · LINE = observed remaining quota · separate scales",
673
+ : actualModeSubtitle,
674
+ SECONDARY_STYLE,
675
+ enabled,
676
+ ),
677
+ frameWidth,
678
+ ),
679
+ frameLine(
680
+ colorize(
681
+ `SNAPSHOT · ${snapshotFreshnessDetail(snapshotFreshness)}`,
581
682
  SECONDARY_STYLE,
582
683
  enabled,
583
684
  ),
584
685
  frameWidth,
585
686
  ),
687
+ frameLine(
688
+ colorize(sourceStatusLine(sourceStatus), SECONDARY_STYLE, enabled),
689
+ frameWidth,
690
+ ),
691
+ ...(history
692
+ ? [frameLine(colorize(history, SECONDARY_STYLE, enabled), frameWidth)]
693
+ : []),
694
+ ...(sourceWarning
695
+ ? [frameLine(colorize(sourceWarning, SECONDARY_STYLE, enabled), frameWidth)]
696
+ : []),
586
697
  `├${"─".repeat(frameWidth - 2)}┤`,
587
698
  ];
588
699
  for (let row = 0; row < plotHeight; row += 1) {
@@ -593,7 +704,9 @@ export function renderTrendCombo({
593
704
  ? percent(leftValue)
594
705
  : compact(leftValue)
595
706
  : "";
596
- const rightLabel = axisRows.includes(row) ? `${Math.round(rightValue)}%` : "";
707
+ const rightLabel = meterAvailable && axisRows.includes(row)
708
+ ? `${Math.round(rightValue)}%`
709
+ : "";
597
710
  const content = chart[row]
598
711
  .map(({ char, style }) => colorize(char, style, enabled))
599
712
  .join("");
@@ -601,16 +714,21 @@ export function renderTrendCombo({
601
714
  }
602
715
  const axis = `${" ".repeat(leftWidth)}${colorize(`└${"─".repeat(plotWidth)}┘`, BORDER_STYLE, enabled)}${" ".repeat(rightWidth)}`;
603
716
  lines.push(frameLine(axis, frameWidth));
604
- lines.push(frameLine(xLabelLine(barBins, plotWidth, leftWidth, rightWidth, bounds.timeZone), frameWidth));
717
+ lines.push(frameLine(xLabelLine(barBins, plotWidth, leftWidth, rightWidth), frameWidth));
605
718
  if (!percentMode && meterUsable) {
606
719
  lines.push(frameLine(drainLabelLine(burn.bins, plotWidth, leftWidth, rightWidth, enabled), frameWidth));
607
720
  lines.push(frameLine(fit("CALENDAR DAY · -% = OBSERVED METER DROP", innerWidth, "center"), frameWidth));
721
+ } else if (drainFallback) {
722
+ lines.push(frameLine(fit("CALENDAR DAY · --drain unavailable; showing raw local tokens", innerWidth, "center"), frameWidth));
608
723
  } else {
609
724
  lines.push(frameLine(fit("CALENDAR DAY", innerWidth, "center"), frameWidth));
610
725
  }
611
726
  lines.push(`├${"─".repeat(frameWidth - 2)}┤`);
612
727
 
613
- const totalTokens = [...actual.totals.values()].reduce((sum, value) => sum + value, 0);
728
+ const totalTokens = [...actual.totals.values()].reduce(
729
+ (sum, value) => checkedTokenAdd(sum, value, { allowFractional: true }),
730
+ 0,
731
+ );
614
732
  const legendModels = percentMode
615
733
  ? [...burn.totals.keys()].sort(modelSort)
616
734
  : [...actual.totals.keys()].sort(modelSort);
@@ -628,20 +746,25 @@ export function renderTrendCombo({
628
746
  const fastPart = fastTokens > 0
629
747
  ? ` · ${percent((fastTokens / actual.totals.get(model)) * 100)} fast`
630
748
  : "";
749
+ const estimatedPrefix = actual.estimatedModels?.has(model) ? "≈" : "";
631
750
  return colorize(
632
- `■ ${model} ${compact(actual.totals.get(model))} (${percent((actual.totals.get(model) / totalTokens) * 100)})${fastPart}`,
751
+ `■ ${model} ${estimatedPrefix}${compact(actual.totals.get(model))} (${percent((actual.totals.get(model) / totalTokens) * 100)})${fastPart}`,
633
752
  styleForModel(model),
634
753
  enabled,
635
754
  );
636
755
  });
637
- lines.push(frameLine(colorize(percentMode ? "OBSERVED LIMIT DRAIN BY MODEL · LEFT AXIS" : "ACTUAL TOKEN VOLUME · LEFT AXIS", PRIMARY_STYLE, enabled), frameWidth));
756
+ lines.push(frameLine(colorize(percentMode ? "OBSERVED LIMIT DRAIN BY MODEL · LEFT AXIS" : actualEstimated ? "ACTUAL TOKEN VOLUME BY MODEL · ≈ = ALLOCATED ESTIMATE · LEFT AXIS" : "ACTUAL TOKEN VOLUME · LEFT AXIS", PRIMARY_STYLE, enabled), frameWidth));
638
757
  for (let index = 0; index < legend.length; index += 2) {
639
758
  const leftLegendWidth = Math.floor((innerWidth - 2) / 2);
640
759
  const rightLegendWidth = innerWidth - 2 - leftLegendWidth;
641
760
  lines.push(frameLine(`${fit(legend[index], leftLegendWidth)} ${fit(legend[index + 1] ?? "", rightLegendWidth)}`, frameWidth));
642
761
  }
643
- lines.push(frameLine(colorize("LINE · OBSERVED WEEKLY QUOTA REMAINING · RIGHT AXIS", LINE_STYLE, enabled), frameWidth));
644
- lines.push(frameLine(colorize(" reset marker returns the line to 100%; it never rises within a cycle", SECONDARY_STYLE, enabled), frameWidth));
762
+ if (meterAvailable) {
763
+ lines.push(frameLine(colorize("LINE · OBSERVED WEEKLY QUOTA REMAINING · RIGHT AXIS", LINE_STYLE, enabled), frameWidth));
764
+ lines.push(frameLine(colorize("↟ reset marker returns the line to 100%; it never rises within a cycle", SECONDARY_STYLE, enabled), frameWidth));
765
+ } else {
766
+ lines.push(frameLine(colorize("NO ACCOUNT-WIDE WEEKLY METER OBSERVED", SECONDARY_STYLE, enabled), frameWidth));
767
+ }
645
768
  for (const line of formatAttribution(trend, enabled, frameWidth, percentMode)) {
646
769
  lines.push(frameLine(line, frameWidth));
647
770
  }