nilometer 0.0.0-stage → 0.2.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/LICENSE +21 -0
  3. package/README.md +464 -2
  4. package/core/pricing/prices.json +348 -0
  5. package/core/schema/001_ingestion.sql +57 -0
  6. package/core/schema/002_line_count.sql +7 -0
  7. package/core/schema/003_derived.sql +70 -0
  8. package/core/schema/004_requests_dedup.sql +50 -0
  9. package/core/schema/005_repositories.sql +25 -0
  10. package/core/schema/006_status_readings.sql +33 -0
  11. package/core/schema/007_pricing.sql +95 -0
  12. package/core/schema/008_interruptions.sql +263 -0
  13. package/core/schema/009_headroom.sql +84 -0
  14. package/core/schema/010_attribution.sql +106 -0
  15. package/core/schema/011_projections.sql +83 -0
  16. package/core/schema/012_unsplit_cache_writes.sql +49 -0
  17. package/core/schema/013_repository_revalidation.sql +104 -0
  18. package/core/schema/014_observed_readings.sql +204 -0
  19. package/core/schema/015_response_observations.sql +127 -0
  20. package/core/schema/016_windows_repository_paths.sql +41 -0
  21. package/core/schema/017_unattributed_usage_once.sql +47 -0
  22. package/core/schema/018_request_time_index.sql +21 -0
  23. package/core/schema/019_dedup_earliest_of_equals.sql +61 -0
  24. package/core/schema/020_limit_quota_fields.sql +131 -0
  25. package/core/schema/021_plan_periods.sql +121 -0
  26. package/core/schema/022_attachment_walk.sql +223 -0
  27. package/core/schema/023_auto_continuations.sql +129 -0
  28. package/core/schema/024_counted_limit_groups.sql +29 -0
  29. package/core/schema/025_model_fallback.sql +77 -0
  30. package/core/schema/026_changed_readings.sql +106 -0
  31. package/core/schema/027_weekly_crossings.sql +32 -0
  32. package/core/schema/028_stale_readings.sql +196 -0
  33. package/core/schema/029_lockout_sessions.sql +68 -0
  34. package/core/schema/030_repository_aliases.sql +81 -0
  35. package/core/schema/031_cte_cursor_budget.sql +153 -0
  36. package/core/schema/032_stored_views.sql +233 -0
  37. package/dist/cli/main.cli.js +39 -0
  38. package/dist/cli/program.js +881 -0
  39. package/dist/core/db/database.js +182 -0
  40. package/dist/core/db/fingerprint.js +71 -0
  41. package/dist/core/ingest/attribution.js +131 -0
  42. package/dist/core/ingest/classify.js +382 -0
  43. package/dist/core/ingest/command.js +95 -0
  44. package/dist/core/ingest/derive.js +219 -0
  45. package/dist/core/ingest/discover.js +126 -0
  46. package/dist/core/ingest/ingest.js +160 -0
  47. package/dist/core/ingest/reader.js +89 -0
  48. package/dist/core/ingest/reset-time.js +97 -0
  49. package/dist/core/ingest/results.js +201 -0
  50. package/dist/core/ingest/spool.js +165 -0
  51. package/dist/core/ingest/store.js +99 -0
  52. package/dist/core/ingest/stored-views.js +65 -0
  53. package/dist/core/install/install.js +185 -0
  54. package/dist/core/install/locations.js +89 -0
  55. package/dist/core/install/private-files.js +259 -0
  56. package/dist/core/install/record.js +103 -0
  57. package/dist/core/plans/plan-prices.js +128 -0
  58. package/dist/core/plans/repository-aliases.js +136 -0
  59. package/dist/core/pricing/prices.js +147 -0
  60. package/dist/core/settings/settings-file.js +215 -0
  61. package/dist/core/settings/statusline.js +240 -0
  62. package/dist/core/util/commands.js +91 -0
  63. package/dist/core/util/node-version.js +58 -0
  64. package/dist/core/util/open-page.js +69 -0
  65. package/dist/core/util/paths.js +36 -0
  66. package/dist/core/verify/compare.js +77 -0
  67. package/dist/core/verify/verify.js +220 -0
  68. package/dist/core/view/server.js +142 -0
  69. package/dist/viewer/explain.js +574 -0
  70. package/dist/viewer/format.js +150 -0
  71. package/dist/viewer/html-charts.js +1292 -0
  72. package/dist/viewer/html-contract.js +2 -0
  73. package/dist/viewer/html-data.js +718 -0
  74. package/dist/viewer/html.js +1717 -0
  75. package/dist/viewer/queries.js +128 -0
  76. package/dist/viewer/render.js +602 -0
  77. package/dist/viewer/report.js +120 -0
  78. package/dist/viewer/save.js +110 -0
  79. package/docs/requirements.md +113 -0
  80. package/hooks/statusline.sh +104 -0
  81. package/package.json +61 -4
@@ -0,0 +1,602 @@
1
+ /**
2
+ * @file The one screen: observed sections first, projections in a separate labeled section (docs/development.md P7.1).
3
+ *
4
+ * Wording is part of correctness (CLAUDE.md): labels state what was measured, projections say
5
+ * "projected" in the label itself, caveats sit next to their numbers, and nothing is advice. Every
6
+ * label lives in {@link LABELS}, which the JSON output carries too, so the banned-phrase test (A9)
7
+ * reads every label once. Numbers are rounded only through viewer/format.ts.
8
+ */
9
+ import { UNKNOWN, formatCoverage, formatInstant, formatNumber, printable, } from "./format.js";
10
+ /** Every heading, column name, and caveat the report prints. */
11
+ export const LABELS = {
12
+ title: "Claude subscription usage",
13
+ observed: "OBSERVED",
14
+ projected: "PROJECTED: estimates derived from the observations above, not observations",
15
+ interruptions: "Interruptions",
16
+ // D-090 (step G4.2): lockouts lead; a logged limit hit is a refused request, and one lockout can hold many.
17
+ limitHits: "Limit hits",
18
+ limitHitsNote: "Each logged hit is a request the limit refused. A lockout refuses every request until its reset, so one lockout can hold several hits.",
19
+ midTask: "Mid-task interruptions (the refused request was answering a tool result)",
20
+ lockout: "Elapsed lockout time (first limit hit to the window's reset, overlapping lockouts counted once)",
21
+ resetToNextRequest: "reset to next Claude Code request",
22
+ // D-081 (step G2.9): a status line pointing at a hook file that isn't there records nothing.
23
+ hookMissing: "Warning: your Claude Code status line runs a Nilometer hook file that doesn't exist, so no usage readings are being recorded. Run nilometer init to point it at this install.",
24
+ hookMissingPath: "Missing hook file",
25
+ // D-077 (step G2.4): only the logged marker earns this label; nothing is inferred from timing.
26
+ autoContinued: "resumed by Claude Code (logged auto-continuation)",
27
+ // D-075 (step G2.2): interruptions per plan period.
28
+ byPlan: "By plan period",
29
+ byPlanNote: "plans as entered with plan-price set; a plan applies from the first day of its month, so a change within a month counts from the month it was entered for",
30
+ notResumed: "Sessions with no further request after a limit hit",
31
+ autoResumeCaveat: "A session that continued after its reset may have been resumed automatically; these counts are requests, not who made them.",
32
+ windows: "Usage windows",
33
+ noReadings: "No status line readings yet. Readings are recorded only when Claude Code runs in a terminal; the VS Code extension doesn't run the status line.",
34
+ windowsCaveat: "Last observed usage is a lower bound: usage after the last Claude Code turn isn't seen.",
35
+ // D-083 (step G2.5): what "open" means, and which reading an open window's usage is from.
36
+ openNote: "Open: not reset as of this report. An open window's usage is its last reading's, at the time under Reading at.",
37
+ // D-087 (step G3.2): a weekly window near its limit, stated as readings, without inferring why.
38
+ crossings: "Weekly windows at 75% and 90%: the first reading at or above each level, and the time from it to the reset",
39
+ crossingsWhat: "The first reading at or above each level, and the time from it to the reset.",
40
+ crossingsCaveat: "A level may have been passed earlier, between readings, so the time left is a lower bound.",
41
+ noCrossings: "No weekly window has a reading at or above 75%.",
42
+ // D-089 (step G4.1): usage within a window only rises, so a lower reading is stale.
43
+ staleReadings: "Readings below an earlier reading in their window, not used",
44
+ byModel: "Claude Code tokens by model",
45
+ byRepo: "Claude Code tokens by repository",
46
+ alias: "Alias you entered (nilometer alias)",
47
+ burnRate: "Burn rate",
48
+ burnRatePremise: "Premise: each window's usage started at 0% when it began, and its average rate since then continues.",
49
+ apiListPrice: "Observed tokens at API list price, by month",
50
+ apiListPriceScope: "Covers Claude Code only: web, mobile, and Claude Design usage leaves no token records.",
51
+ columns: {
52
+ window: "Window",
53
+ resets: "Resets",
54
+ lastUsage: "Last observed usage",
55
+ readingAt: "Reading at",
56
+ peak: "Peak",
57
+ readings: "Readings",
58
+ status: "Status",
59
+ model: "Model",
60
+ repository: "Repository",
61
+ kind: "Kind",
62
+ requests: "Requests",
63
+ input: "Input",
64
+ output: "Output",
65
+ cacheRead: "Cache read",
66
+ cacheWrite5m: "Cache write 5m",
67
+ cacheWrite1h: "Cache write 1h",
68
+ cacheWriteUnsplit: "Cache write, no duration",
69
+ month: "Month",
70
+ apiListPrice: "At API list price",
71
+ planPrice: "Plan price entered",
72
+ plan: "Plan",
73
+ months: "Months",
74
+ hits: "Limit hits",
75
+ lockouts: "Lockouts",
76
+ elapsed: "Elapsed lockout time",
77
+ priced: "Priced requests",
78
+ unpriced: "Unpriced requests",
79
+ covers: "Covers",
80
+ },
81
+ };
82
+ /**
83
+ * Lays out rows as a text table with a header and a rule.
84
+ * @param headers - Column names.
85
+ * @param rawRows - Cell text per row; control characters in them are escaped (D-050).
86
+ * @param align - Alignment per column; numbers are right-aligned.
87
+ * @returns Lines, each indented by two spaces.
88
+ */
89
+ export function renderTable(headers, rawRows, align) {
90
+ // Cells hold model names and repository paths from the logs, so escape before measuring (D-050).
91
+ const rows = rawRows.map((row) => row.map(printable));
92
+ const widths = headers.map((header, column) => Math.max(header.length, ...rows.map((row) => (row[column] ?? "").length)));
93
+ /**
94
+ * Pads one row's cells to the column widths.
95
+ * @param cells - Cell text.
96
+ * @returns The joined line.
97
+ */
98
+ const line = (cells) => ` ${cells
99
+ .map((cell, column) => align[column] === "right"
100
+ ? cell.padStart(widths[column] ?? 0)
101
+ : cell.padEnd(widths[column] ?? 0))
102
+ .join(" ")
103
+ .trimEnd()}`;
104
+ return [line(headers), line(widths.map((width) => "-".repeat(width))), ...rows.map(line)];
105
+ }
106
+ /**
107
+ * Counts something with its singular or plural noun.
108
+ * @param count - How many.
109
+ * @param singular - Noun for exactly one.
110
+ * @param pluralForm - Noun otherwise.
111
+ * @returns E.g. `1 hit`, `3 hits`, `unknown hits`.
112
+ */
113
+ export function plural(count, singular, pluralForm) {
114
+ return `${formatNumber(count, "count")} ${count === 1 ? singular : pluralForm}`;
115
+ }
116
+ /**
117
+ * Shortens a path under the home directory to start with `~`.
118
+ * @param path - Any path.
119
+ * @param home - The home directory; an empty string leaves paths unchanged.
120
+ * @returns `~` or `~/rest` (`~\rest` for a Windows path) under home, otherwise the path itself.
121
+ */
122
+ export function displayPath(path, home) {
123
+ if (home === "") {
124
+ return path;
125
+ }
126
+ // A Windows home (`C:\Users\<name>`) also matches with `/` separators, which git uses for repository
127
+ // roots, and with a lowercase drive letter, which some Claude Code logs use (D-049). Both changes
128
+ // keep the length, so the remainder is sliced from the original path.
129
+ const windows = /^[A-Za-z]:[\\/]/.test(home);
130
+ /**
131
+ * Spells a path for comparison with the home directory.
132
+ * @param value - A path.
133
+ * @returns On Windows, the path with an uppercase drive letter and `/` separators; elsewhere unchanged.
134
+ */
135
+ const comparable = (value) => windows ? `${value.charAt(0).toUpperCase()}${value.slice(1)}`.replaceAll("\\", "/") : value;
136
+ const [p, h] = [comparable(path), comparable(home)];
137
+ if (p !== h && !p.startsWith(`${h}/`)) {
138
+ return path;
139
+ }
140
+ return `~${path.slice(home.length)}`;
141
+ }
142
+ /**
143
+ * Names a rate-limit window for display.
144
+ * @param window - Payload window key.
145
+ * @returns `5-hour`, `weekly`, or the key itself.
146
+ */
147
+ export function windowName(window) {
148
+ return window === "five_hour" ? "5-hour" : window === "seven_day" ? "weekly" : window;
149
+ }
150
+ /**
151
+ * Names how a repository was resolved (D-010).
152
+ * @param kind - `repo_kind` from the view.
153
+ * @returns Display text.
154
+ */
155
+ export function repoKindName(kind) {
156
+ switch (kind) {
157
+ case "repo":
158
+ return "git repository";
159
+ case "not_git":
160
+ return "not a git repository";
161
+ case "missing":
162
+ return "directory no longer exists";
163
+ default:
164
+ return "not resolved yet";
165
+ }
166
+ }
167
+ /**
168
+ * Says what one lockout's limit hits were (D-090): requests the limit refused, from how many
169
+ * sessions, and any status line readings at 100% with no refusal logged (D-023).
170
+ * @param interval - The lockout.
171
+ * @returns E.g. `13 refused requests in 3 sessions`, or `1 status line reading at 100%`.
172
+ */
173
+ export function lockoutHitsText(interval) {
174
+ const parts = [];
175
+ if (interval.refused_requests > 0) {
176
+ parts.push(`${plural(interval.refused_requests, "refused request", "refused requests")} in ${plural(interval.sessions, "session", "sessions")}`);
177
+ }
178
+ const readings = interval.hits - interval.refused_requests;
179
+ if (readings > 0) {
180
+ parts.push(`${plural(readings, "status line reading", "status line readings")} at 100%`);
181
+ }
182
+ return parts.join(" and ");
183
+ }
184
+ /**
185
+ * States the sessions Claude Code resumed by itself after a lockout, as logged (D-077).
186
+ * @param interval - The lockout.
187
+ * @returns `; resumed by Claude Code (logged auto-continuation): 1 session, 4 m 15 s after the
188
+ * reset`, or "" when no marker was logged.
189
+ */
190
+ export function autoContinuedText(interval) {
191
+ if (interval.auto_continuations === 0) {
192
+ return "";
193
+ }
194
+ const after = `${n(interval.first_auto_continuation_seconds, "duration")} after the reset`;
195
+ return interval.auto_continuations === 1
196
+ ? `; ${LABELS.autoContinued}: 1 session, ${after}`
197
+ : `; ${LABELS.autoContinued}: ${plural(interval.auto_continuations, "session", "sessions")}, the first ${after}`;
198
+ }
199
+ /**
200
+ * Names a plan period as the report shows it (D-075).
201
+ * @param row - The period's kind and plan name.
202
+ * @returns The plan's name as entered, "no plan entered" before the first, or "time unknown".
203
+ */
204
+ export function planPeriodName(row) {
205
+ if (row.period_kind === "none") {
206
+ return "no plan entered";
207
+ }
208
+ // Raw: the text table and the page each escape cells themselves (D-050).
209
+ return row.period_kind === "unknown" ? "time unknown" : (row.plan_name ?? "");
210
+ }
211
+ /**
212
+ * States which months a plan period covers (D-075).
213
+ * @param row - The period's kind and months.
214
+ * @returns `2026-07`, `2026-07 to 2026-09`, `2026-08 onward`, `before 2026-07`, `every month`, or
215
+ * `no time` for hits that have none.
216
+ */
217
+ export function planPeriodMonths(row) {
218
+ if (row.period_kind === "unknown") {
219
+ return "no time";
220
+ }
221
+ if (row.period_kind === "none") {
222
+ return row.until_month === null ? "every month" : `before ${row.until_month}`;
223
+ }
224
+ if (row.to_month === null) {
225
+ return `${row.from_month ?? ""} onward`;
226
+ }
227
+ return row.to_month === row.from_month
228
+ ? (row.from_month ?? "")
229
+ : `${row.from_month ?? ""} to ${row.to_month}`;
230
+ }
231
+ /**
232
+ * Describes one weekly window's crossings (D-087): each level it reached, or that it wasn't reached.
233
+ * @param rows - The window's `obs_weekly_crossings` rows, lower level first.
234
+ * @param timeZone - Display zone.
235
+ * @returns E.g. `75% first read 2026-10-13 14:05 (4 d 21 h before the reset); 90% not reached`.
236
+ */
237
+ export function crossingText(rows, timeZone) {
238
+ return [75, 90]
239
+ .map((level) => {
240
+ const row = rows.find((r) => r.threshold === level);
241
+ return row === undefined
242
+ ? `${String(level)}% not reached`
243
+ : `${String(level)}% first read ${formatInstant(row.first_at_utc, timeZone)} (${formatNumber(row.seconds_before_reset, "duration")} before the reset)`;
244
+ })
245
+ .join("; ");
246
+ }
247
+ /**
248
+ * Renders the weekly windows' first readings at or above 75% and 90% (D-087, step G3.2).
249
+ * @param rows - `obs_weekly_crossings`, newest window first.
250
+ * @param timeZone - Display zone.
251
+ * @returns Lines for the windows section.
252
+ */
253
+ function renderCrossings(rows, timeZone) {
254
+ if (rows.length === 0) {
255
+ return [` ${LABELS.noCrossings}`];
256
+ }
257
+ // One line per window, in the rows' order; a Set keeps each reset once.
258
+ const resets = [...new Set(rows.map((r) => r.reset_at_utc))];
259
+ return [
260
+ ` ${LABELS.crossings}`,
261
+ ...resets.map((reset) => ` weekly window resetting ${formatInstant(reset, timeZone)}: ${crossingText(rows.filter((r) => r.reset_at_utc === reset), timeZone)}`),
262
+ ` ${LABELS.crossingsCaveat}`,
263
+ ];
264
+ }
265
+ /**
266
+ * Renders the per-plan interruption table (D-075), only once a plan has been entered: before
267
+ * that, every row would be "no plan entered" and repeat the totals above it.
268
+ * @param rows - `obs_interruptions_by_plan` rows, in the view's order.
269
+ * @param timeZone - Display zone.
270
+ * @returns Lines, or none.
271
+ */
272
+ function renderByPlan(rows, timeZone) {
273
+ if (!rows.some((row) => row.period_kind === "plan")) {
274
+ return [];
275
+ }
276
+ const c = LABELS.columns;
277
+ return [
278
+ ` ${LABELS.byPlan}: ${LABELS.byPlanNote} (months in ${timeZone}).`,
279
+ ...renderTable([c.plan, c.months, c.hits, c.lockouts, c.elapsed, c.covers], rows.map((row) => [
280
+ planPeriodName(row),
281
+ planPeriodMonths(row),
282
+ n(row.hits, "count"),
283
+ n(row.lockouts, "count"),
284
+ n(row.lockout_seconds, "duration"),
285
+ row.period_kind === "unknown" ? "" : span(row.covers_from, row.covers_to, timeZone),
286
+ ]), ["left", "left", "right", "right", "right", "left"]).map((line) => ` ${line}`),
287
+ ];
288
+ }
289
+ /**
290
+ * Formats a span without the zone, for table cells under a heading that names it.
291
+ * @param from - First instant.
292
+ * @param to - Last instant.
293
+ * @param timeZone - IANA zone.
294
+ * @returns `YYYY-MM-DD HH:MM to YYYY-MM-DD HH:MM`.
295
+ */
296
+ function span(from, to, timeZone) {
297
+ return `${formatInstant(from, timeZone)} to ${formatInstant(to, timeZone)}`;
298
+ }
299
+ /**
300
+ * Formats a number, shortening the call sites.
301
+ * @param value - Value.
302
+ * @param kind - Kind.
303
+ * @returns Display text.
304
+ */
305
+ const n = (value, kind) => formatNumber(value, kind);
306
+ /**
307
+ * Renders the token table shared by the model and repository sections.
308
+ * @param first - Leading columns' headers.
309
+ * @param rows - Rows with their leading cells.
310
+ * @returns Table lines.
311
+ */
312
+ function tokenTable(first, rows) {
313
+ const c = LABELS.columns;
314
+ return renderTable([
315
+ ...first,
316
+ c.requests,
317
+ c.input,
318
+ c.output,
319
+ c.cacheRead,
320
+ c.cacheWrite5m,
321
+ c.cacheWrite1h,
322
+ c.cacheWriteUnsplit,
323
+ ], rows.map(({ lead, row }) => [
324
+ ...lead,
325
+ n(row.requests, "count"),
326
+ n(row.input_tokens, "tokens"),
327
+ n(row.output_tokens, "tokens"),
328
+ n(row.cache_read_tokens, "tokens"),
329
+ n(row.cache_write_5m_tokens, "tokens"),
330
+ n(row.cache_write_1h_tokens, "tokens"),
331
+ n(row.cache_write_unsplit_tokens, "tokens"),
332
+ ]), [
333
+ ...first.map(() => "left"),
334
+ "right",
335
+ "right",
336
+ "right",
337
+ "right",
338
+ "right",
339
+ "right",
340
+ "right",
341
+ ]);
342
+ }
343
+ /**
344
+ * Renders the observed sections.
345
+ * @param observed - Observed rows.
346
+ * @param timeZone - Display zone.
347
+ * @param home - Home directory to show as `~` in repository paths.
348
+ * @returns Lines.
349
+ */
350
+ export function renderObserved(observed, timeZone, home = "") {
351
+ const { limitHits: hits, midTask, lockout, notResumed } = observed;
352
+ const logs = formatCoverage(hits.covers_from, hits.covers_to, timeZone);
353
+ const statusLine = formatCoverage(hits.status_line_covers_from, hits.status_line_covers_to, timeZone);
354
+ const c = LABELS.columns;
355
+ const lines = [
356
+ LABELS.observed,
357
+ "",
358
+ LABELS.interruptions,
359
+ ` Covers: session logs ${logs}; status line ${statusLine}`,
360
+ // Lockouts first: one lockout is one limit reached, however many requests it refused (D-090).
361
+ ` ${LABELS.lockout}: ${n(lockout.lockout_seconds, "duration")} across ${plural(lockout.intervals, "lockout", "lockouts")}`,
362
+ ` Limit hits with an unknown reset time, in no lockout: ${n(lockout.hits_with_unknown_reset, "count")}`,
363
+ ...observed.lockoutIntervals.map((interval) => ` ${span(interval.locked_from_utc, interval.locked_until_utc, timeZone)}: ${n(interval.lockout_seconds, "duration")}, ${lockoutHitsText(interval)}; ${LABELS.resetToNextRequest}: ${interval.next_request_at_utc === null
364
+ ? "no request after the reset in the logs"
365
+ : n(interval.reset_to_next_request_seconds, "duration")}${autoContinuedText(interval)}`),
366
+ ` ${LABELS.limitHits}: ${n(hits.hits, "count")} in ${plural(lockout.intervals, "lockout", "lockouts")}`,
367
+ ` ${LABELS.limitHitsNote}`,
368
+ ` 5-hour window: ${n(hits.five_hour_hits, "count")} | weekly window: ${n(hits.seven_day_hits, "count")} | window unknown: ${n(hits.unknown_window_hits, "count")}`,
369
+ ` From the session logs: ${n(hits.logged_hits, "count")} | seen only in the status line: ${n(hits.status_line_only_hits, "count")}`,
370
+ ` ${LABELS.midTask}: ${n(midTask.mid_task, "count")}`,
371
+ ` At the start of a turn: ${n(midTask.turn_start, "count")} | position unknown: ${n(midTask.position_unknown, "count")}`,
372
+ ...renderByPlan(observed.interruptionsByPlan, timeZone),
373
+ ` ${LABELS.notResumed}: ${n(notResumed.sessions_not_resumed, "count")} of ${plural(notResumed.sessions_with_hits, "session", "sessions")} with a hit`,
374
+ ` Reset came after the logs end: ${n(notResumed.reset_after_coverage, "count")} | reset time unknown: ${n(notResumed.reset_unknown, "count")}`,
375
+ ` ${LABELS.autoResumeCaveat}`,
376
+ "",
377
+ LABELS.windows,
378
+ ` Covers: status line ${statusLine}. Readings start when the collector is installed.`,
379
+ ];
380
+ if (observed.windows.length === 0) {
381
+ lines.push(` ${LABELS.noReadings}`);
382
+ }
383
+ else {
384
+ lines.push(...renderTable([c.window, c.resets, c.lastUsage, c.readingAt, c.peak, c.readings, c.status], observed.windows.map((w) => [
385
+ windowName(w.window),
386
+ formatInstant(w.reset_at_utc, timeZone),
387
+ n(w.last_used_percentage, "percent"),
388
+ formatInstant(w.last_reading_at_utc, timeZone),
389
+ n(w.peak_used_percentage, "percent"),
390
+ n(w.readings, "count"),
391
+ // Open only until its reset, as of this report (D-083).
392
+ w.open_at_report === 1 ? "open" : "reset",
393
+ ]), ["left", "left", "right", "left", "right", "right", "left"]), ` ${LABELS.windowsCaveat}`, ` ${LABELS.openNote}`);
394
+ const afterReset = observed.windows.reduce((sum, w) => sum + w.readings_after_reset, 0);
395
+ if (afterReset > 0) {
396
+ lines.push(` Readings captured after their window's reset, not counted: ${n(afterReset, "count")}`);
397
+ }
398
+ const stale = observed.windows.reduce((sum, w) => sum + w.readings_stale, 0);
399
+ if (stale > 0) {
400
+ lines.push(` ${LABELS.staleReadings}: ${n(stale, "count")}`);
401
+ }
402
+ lines.push(...renderCrossings(observed.weeklyCrossings, timeZone));
403
+ }
404
+ const modelCoverage = observed.byModel[0];
405
+ lines.push("", LABELS.byModel, ` Covers: session logs ${formatCoverage(modelCoverage?.covers_from, modelCoverage?.covers_to, timeZone)}`);
406
+ if (observed.byModel.length === 0) {
407
+ lines.push(" No Claude Code requests in the logs yet.");
408
+ }
409
+ else {
410
+ lines.push(...tokenTable([c.model], observed.byModel.map((row) => ({ lead: [row.model], row }))));
411
+ const unkeyed = observed.byModel.reduce((sum, row) => sum + row.unkeyed_requests, 0);
412
+ const untimed = observed.byModel.reduce((sum, row) => sum + row.requests_without_timestamp, 0);
413
+ if (unkeyed > 0) {
414
+ lines.push(` Requests without a message or request ID, kept as written: ${n(unkeyed, "count")}`);
415
+ }
416
+ if (untimed > 0) {
417
+ lines.push(` Requests without a readable timestamp: ${n(untimed, "count")}`);
418
+ }
419
+ lines.push("", LABELS.byRepo, ` Covers: session logs ${formatCoverage(modelCoverage?.covers_from, modelCoverage?.covers_to, timeZone)}`, ...tokenTable([c.repository, c.kind], observed.byRepo.map((row) => ({
420
+ lead: [
421
+ row.repository === null
422
+ ? `${UNKNOWN} (no working directory)`
423
+ : displayPath(row.repository, home),
424
+ repoKindName(row.repo_kind),
425
+ ],
426
+ row,
427
+ }))),
428
+ // D-092: each alias the user entered, since it's their link and not something observed.
429
+ ...observed.repositoryAliases.map((a) => ` ${LABELS.alias}: ${displayPath(a.from, home)} counts under ${displayPath(a.to, home)}`));
430
+ }
431
+ return lines;
432
+ }
433
+ /**
434
+ * Describes one window's burn rate projection.
435
+ * @param row - A `proj_burn_rate` row.
436
+ * @param timeZone - Display zone.
437
+ * @returns One line.
438
+ */
439
+ export function describeBurnRate(row, timeZone) {
440
+ const lead = ` ${windowName(row.window)} window resetting ${formatInstant(row.reset_at_utc, timeZone)}: ${n(row.last_used_percentage, "percent")} at ${formatInstant(row.last_reading_at_utc, timeZone)}`;
441
+ if (row.limit_reached === 1) {
442
+ return `${lead}; limit reached at that reading.`;
443
+ }
444
+ const rate = `projected ${n(row.projected_percentage_points_per_hour, "rate")}`;
445
+ if (row.projected_limit_at_utc === null) {
446
+ return `${lead}; ${rate}; projected limit time unknown.`;
447
+ }
448
+ const when = row.projected_limit_before_reset === 1 ? "before the reset" : "after the reset";
449
+ return `${lead}; ${rate}; limit projected for ${formatInstant(row.projected_limit_at_utc, timeZone)}, ${when}.`;
450
+ }
451
+ /**
452
+ * Formats the plan price cell.
453
+ * @param row - A `proj_api_list_price` row.
454
+ * @returns Name and price, or "none entered".
455
+ */
456
+ function planCell(row) {
457
+ return row.plan_name === null
458
+ ? "none entered"
459
+ : `${row.plan_name}, ${n(row.plan_usd_per_month, "usd")} per month`;
460
+ }
461
+ /**
462
+ * Renders the projected sections.
463
+ * @param projected - Projected rows.
464
+ * @param timeZone - Display zone.
465
+ * @returns Lines.
466
+ */
467
+ export function renderProjected(projected, timeZone) {
468
+ const c = LABELS.columns;
469
+ // A window whose reset has passed by the report's time has nothing left to project (D-083).
470
+ const open = projected.burnRate.filter((row) => row.open_at_report === 1);
471
+ const first = projected.burnRate[0];
472
+ const lines = [
473
+ LABELS.projected,
474
+ "",
475
+ LABELS.burnRate,
476
+ ` Covers: status line ${formatCoverage(first?.covers_from, first?.covers_to, timeZone)}`,
477
+ ` ${LABELS.burnRatePremise}`,
478
+ ...(open.length === 0
479
+ ? [" No open usage window in the status line readings."]
480
+ : open.map((row) => describeBurnRate(row, timeZone))),
481
+ "",
482
+ LABELS.apiListPrice,
483
+ ` Months in ${timeZone}. ${LABELS.apiListPriceScope}`,
484
+ ];
485
+ const months = projected.apiListPrice;
486
+ if (months.length === 0) {
487
+ lines.push(" No Claude Code requests in the logs yet.");
488
+ return lines;
489
+ }
490
+ lines.push(...renderTable([c.month, c.apiListPrice, c.planPrice, c.priced, c.unpriced, c.covers], months.map((row) => [
491
+ row.month,
492
+ n(row.api_list_price_usd, "usd"),
493
+ planCell(row),
494
+ n(row.priced_requests, "count"),
495
+ n(row.unpriced_requests, "count"),
496
+ span(row.covers_from, row.covers_to, timeZone),
497
+ ]), ["left", "right", "left", "right", "right", "left"]));
498
+ // One line per reading date, each with its own count: summing every count under the latest date
499
+ // printed the wrong date as soon as two price rows were read on different days.
500
+ for (const reading of projected.rateReadings) {
501
+ lines.push(` Rates were read from Anthropic's pricing page on ${reading.verified_on}; ${n(reading.priced_before_verified_requests, "count")} requests dated before that are priced at those rates.`);
502
+ }
503
+ const lowerBound = months.reduce((sum, row) => sum + row.lower_bound_requests, 0);
504
+ if (lowerBound > 0) {
505
+ lines.push(` Requests with cache writes of unrecorded duration, priced at the 5-minute rate (their cost is a lower bound): ${n(lowerBound, "count")}`);
506
+ }
507
+ if (months.some((row) => row.unpriced_requests > 0)) {
508
+ lines.push(" Unpriced requests have no verified rate and are left out of the amounts, not counted as $0.");
509
+ }
510
+ if (months.every((row) => row.plan_name === null)) {
511
+ lines.push(" To show a plan price beside these amounts: plan-price set <YYYY-MM> <usd> --name <plan>");
512
+ }
513
+ return lines;
514
+ }
515
+ /**
516
+ * Renders the whole screen.
517
+ * @param input - Rows, zone, and ingest facts.
518
+ * @returns The report text, lines joined with newlines.
519
+ */
520
+ export function renderReport(input) {
521
+ const header = [
522
+ LABELS.title,
523
+ `Times are in ${input.timeZone}. Last ingest: ${input.lastIngestAt === null ? "none yet" : formatInstant(input.lastIngestAt, input.timeZone)}. Database: ${displayPath(input.databasePath, input.home)}`,
524
+ // Above everything else: the readings below stop at this point until it's fixed (D-081).
525
+ ...(input.missingHookPath === undefined || input.missingHookPath === null
526
+ ? []
527
+ : [
528
+ "",
529
+ LABELS.hookMissing,
530
+ `${LABELS.hookMissingPath}: ${displayPath(input.missingHookPath, input.home)}`,
531
+ ]),
532
+ "",
533
+ ];
534
+ // A first run has nothing to report; say what produces the numbers instead of printing empty
535
+ // sections. `--json` is unchanged, so anything reading this programmatically still sees the
536
+ // same shape (R2.5).
537
+ if (hasNoData(input.observed)) {
538
+ return [...header, ...renderFirstRun()].join("\n");
539
+ }
540
+ return [
541
+ ...header,
542
+ ...renderObserved(input.observed, input.timeZone, input.home),
543
+ "",
544
+ ...renderProjected(input.projected, input.timeZone),
545
+ ].join("\n");
546
+ }
547
+ /**
548
+ * Reports whether nothing has been recorded yet: no status line readings, no requests, and no
549
+ * limit hits.
550
+ * @param observed - The observed report.
551
+ * @returns True when no source has produced a row.
552
+ * @see D-070 (limit hits count: a database holding only hits printed "Nothing has been recorded
553
+ * yet" while the HTML page saved beside it drew the lockouts)
554
+ */
555
+ export function hasNoData(observed) {
556
+ // A limit hit is recorded data even with no request or reading beside it, and hiding it behind
557
+ // the first-run text would drop an interruption from the report (principle 1).
558
+ return (observed.windows.length === 0 && observed.byModel.length === 0 && observed.limitHits.hits === 0);
559
+ }
560
+ /**
561
+ * What to do next, shown instead of a page of empty sections on a first run.
562
+ *
563
+ * Every section of the report says "no data yet" on its own, which is correct and useless: a new
564
+ * user reads forty lines to learn that nothing happened, and nothing tells them the two sources
565
+ * arrive separately or that readings need a terminal. This replaces the sections until there is
566
+ * something to show (R2.5).
567
+ * @returns Lines for stdout.
568
+ */
569
+ export function renderFirstRun() {
570
+ return [
571
+ "Nothing has been recorded yet.",
572
+ "",
573
+ "Two sources feed this report, and they arrive separately:",
574
+ " Session logs hold tokens, models, and repositories. `init` read whatever was already there,",
575
+ " and `nilometer ingest` reads what Claude Code writes from now on.",
576
+ " Status line readings hold the plan's usage percentages and its limits. They are written only",
577
+ " while Claude Code runs in a terminal; the VS Code extension doesn't run the status line.",
578
+ "",
579
+ "Use Claude Code in a terminal, then run `nilometer ingest` and this report again.",
580
+ "Usage percentages cover the time since the hook was installed; tokens cover every log still on disk.",
581
+ ];
582
+ }
583
+ /**
584
+ * Builds the `--json` output: unrounded rows, kept in separate observed and projected objects,
585
+ * with the labels the table uses.
586
+ * @param input - Rows, zone, and ingest facts.
587
+ * @returns A JSON-serializable object.
588
+ */
589
+ export function reportJson(input) {
590
+ return {
591
+ timeZone: input.timeZone,
592
+ lastIngestAt: input.lastIngestAt,
593
+ databasePath: input.databasePath,
594
+ // D-081: the hook the status line names when its file is missing; null otherwise.
595
+ missingHookPath: input.missingHookPath ?? null,
596
+ // Unrounded, unshortened values: JSON is for tools, and a path under ~ would need expanding back.
597
+ labels: LABELS,
598
+ observed: input.observed,
599
+ projected: input.projected,
600
+ };
601
+ }
602
+ //# sourceMappingURL=render.js.map