nilometer 0.0.0-stage → 0.1.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 (78) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/LICENSE +21 -0
  3. package/README.md +462 -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/dist/cli/main.cli.js +39 -0
  37. package/dist/cli/program.js +801 -0
  38. package/dist/core/db/database.js +182 -0
  39. package/dist/core/db/fingerprint.js +71 -0
  40. package/dist/core/ingest/attribution.js +131 -0
  41. package/dist/core/ingest/classify.js +382 -0
  42. package/dist/core/ingest/command.js +95 -0
  43. package/dist/core/ingest/derive.js +212 -0
  44. package/dist/core/ingest/discover.js +126 -0
  45. package/dist/core/ingest/ingest.js +160 -0
  46. package/dist/core/ingest/reader.js +89 -0
  47. package/dist/core/ingest/reset-time.js +97 -0
  48. package/dist/core/ingest/results.js +201 -0
  49. package/dist/core/ingest/spool.js +165 -0
  50. package/dist/core/ingest/store.js +99 -0
  51. package/dist/core/install/install.js +185 -0
  52. package/dist/core/install/locations.js +89 -0
  53. package/dist/core/install/private-files.js +259 -0
  54. package/dist/core/install/record.js +103 -0
  55. package/dist/core/plans/plan-prices.js +128 -0
  56. package/dist/core/plans/repository-aliases.js +136 -0
  57. package/dist/core/pricing/prices.js +147 -0
  58. package/dist/core/settings/settings-file.js +215 -0
  59. package/dist/core/settings/statusline.js +240 -0
  60. package/dist/core/util/commands.js +91 -0
  61. package/dist/core/util/node-version.js +58 -0
  62. package/dist/core/util/open-page.js +69 -0
  63. package/dist/core/util/paths.js +36 -0
  64. package/dist/core/verify/compare.js +77 -0
  65. package/dist/core/verify/verify.js +220 -0
  66. package/dist/viewer/explain.js +574 -0
  67. package/dist/viewer/format.js +150 -0
  68. package/dist/viewer/html-charts.js +1292 -0
  69. package/dist/viewer/html-contract.js +2 -0
  70. package/dist/viewer/html-data.js +718 -0
  71. package/dist/viewer/html.js +1654 -0
  72. package/dist/viewer/queries.js +128 -0
  73. package/dist/viewer/render.js +602 -0
  74. package/dist/viewer/report.js +120 -0
  75. package/dist/viewer/save.js +110 -0
  76. package/docs/requirements.md +113 -0
  77. package/hooks/statusline.sh +104 -0
  78. package/package.json +61 -4
@@ -0,0 +1,574 @@
1
+ import { formatInstant, formatNumber, printable } from "./format.js";
2
+ import { displayPath, planPeriodMonths, planPeriodName, repoKindName, windowName, } from "./render.js";
3
+ /** A metric name `explain` doesn't know. */
4
+ export class UnknownMetricError extends Error {
5
+ /**
6
+ * Creates the error.
7
+ * @param metric - The name given.
8
+ */
9
+ constructor(metric) {
10
+ super(`Unknown metric "${metric}"; choose one of: ${METRIC_NAMES.join(", ")}`);
11
+ this.name = "UnknownMetricError";
12
+ }
13
+ }
14
+ /** Joins a `raw_line_id` column (aliased `e`) to its file and position. */
15
+ const LOCATION_JOIN = `LEFT JOIN raw_lines l ON l.id = e.raw_line_id
16
+ LEFT JOIN source_files f ON f.id = l.source_file_id`;
17
+ /** Location columns selected with {@link LOCATION_JOIN}. */
18
+ const LOCATION_COLUMNS = "f.relative_path, l.line_number, l.byte_offset";
19
+ /**
20
+ * Formats a source location.
21
+ * @param row - Row with location columns.
22
+ * @returns `path:line (byte N)`, or `unknown location`.
23
+ */
24
+ export function formatLocation(row) {
25
+ return row.relative_path === null
26
+ ? "unknown location"
27
+ : `${row.relative_path}:${row.line_number} (byte ${row.byte_offset})`;
28
+ }
29
+ /**
30
+ * Adds one measure's contributions.
31
+ * @param group - The group.
32
+ * @param index - Measure index.
33
+ * @returns The sum; with no contributing event, 0 for counts and measures marked `nullIsZero`,
34
+ * otherwise null (unknown).
35
+ */
36
+ export function resum(group, index) {
37
+ const values = group.events
38
+ .map((event) => event.contributions[index])
39
+ .filter((value) => value !== null && value !== undefined);
40
+ if (values.length === 0) {
41
+ const measure = group.measures[index];
42
+ return measure?.kind === "count" || measure?.nullIsZero === true ? 0 : null;
43
+ }
44
+ return values.reduce((sum, value) => sum + value, 0);
45
+ }
46
+ /**
47
+ * Describes a limit-hit event.
48
+ * @param row - The event row.
49
+ * @returns The event.
50
+ */
51
+ function hitEvent(row) {
52
+ const source = row.source === "session_log" ? "logged limit hit" : "status line reached 100%";
53
+ const window = row.window === null ? "window unknown" : `${windowName(row.window)} window`;
54
+ // The walk behind the position, so a reader can follow it to the line that decided it (D-076).
55
+ const past = row.attachments_passed > 0
56
+ ? `, past ${row.attachments_passed === 1 ? "1 attachment line" : `${row.attachments_passed} attachment lines`}`
57
+ : "";
58
+ return {
59
+ at: row.hit_at_utc,
60
+ description: `${source} | ${window} | position ${row.position.replace("_", " ")}${past} | session ${row.session_id ?? "unknown"}`,
61
+ location: formatLocation(row),
62
+ contributions: [1],
63
+ };
64
+ }
65
+ /**
66
+ * Reads one value from a single-row summary view.
67
+ * @param db - Database.
68
+ * @param sql - A query returning one row with column `v`.
69
+ * @returns The value.
70
+ */
71
+ function scalar(db, sql) {
72
+ return db.prepare(sql).get().v;
73
+ }
74
+ /** Token measures shared by the model and repository explanations. */
75
+ const TOKEN_MEASURES = [
76
+ ["requests", "Requests", "count"],
77
+ ["input_tokens", "Input tokens", "tokens"],
78
+ ["output_tokens", "Output tokens", "tokens"],
79
+ ["cache_read_tokens", "Cache read tokens", "tokens"],
80
+ ["cache_write_5m_tokens", "Cache write 5m tokens", "tokens"],
81
+ ["cache_write_1h_tokens", "Cache write 1h tokens", "tokens"],
82
+ ["cache_write_unsplit_tokens", "Cache write tokens, no duration", "tokens"],
83
+ ];
84
+ /**
85
+ * Builds a token group from a summary row and its requests.
86
+ * @param title - Group title.
87
+ * @param summary - The summary view row.
88
+ * @param requests - Its request events.
89
+ * @returns The group.
90
+ */
91
+ function tokenGroup(title, summary, requests) {
92
+ return {
93
+ title,
94
+ measures: TOKEN_MEASURES.map(([column, label, kind]) => ({
95
+ label,
96
+ kind,
97
+ reported: summary[column] ?? null,
98
+ ...(column === "cache_write_unsplit_tokens" ? { nullIsZero: true } : {}),
99
+ })),
100
+ events: requests.map((row) => ({
101
+ at: row.timestamp_utc,
102
+ // An aliased request says where it was before, so the user's alias is visible here (D-092).
103
+ description: `request ${row.dedup_key ?? "without an ID"} | ${row.model}${row.aliased_from === undefined || row.aliased_from === null
104
+ ? ""
105
+ : ` | counted here by the alias from ${row.aliased_from}`}`,
106
+ location: formatLocation(row),
107
+ contributions: [
108
+ 1,
109
+ row.input_tokens,
110
+ row.output_tokens,
111
+ row.cache_read_tokens,
112
+ row.cache_write_5m_tokens,
113
+ row.cache_write_1h_tokens,
114
+ row.cache_write_unsplit_tokens,
115
+ ],
116
+ })),
117
+ };
118
+ }
119
+ /**
120
+ * Loads one status line reading as an event, for the window instance the group describes.
121
+ * @param db - Database.
122
+ * @param instance - The group's window instance and the raw line of the reading behind its value.
123
+ * @param what - What the reading is to the group.
124
+ * @returns The event list: one event, or none when there's no reading.
125
+ */
126
+ function readingEvents(db, instance, what) {
127
+ if (instance.reading === null) {
128
+ return [];
129
+ }
130
+ // One spool line carries both windows, so the raw line alone matches two rows; the window and
131
+ // reset pick the one whose value the group reports (found on real data 2026-09-17).
132
+ const row = db
133
+ .prepare(`SELECT e.observed_at_utc, e.captured_at_utc, e.observed_via, e.used_percentage, e.session_id, ${LOCATION_COLUMNS}
134
+ FROM window_readings e ${LOCATION_JOIN}
135
+ WHERE e.raw_line_id = ? AND e.window = ? AND e.reset_at_utc = ?`)
136
+ .get(instance.reading, instance.window, instance.reset_at_utc);
137
+ return [
138
+ {
139
+ at: row.observed_at_utc,
140
+ // D-044: the value was observed at the session's last API response; the capture may be later.
141
+ description: `${what}: ${formatNumber(row.used_percentage, "percent")} | session ${row.session_id ?? "unknown"} | ${row.observed_via === "last_request"
142
+ ? `observed at the session's request before capture ${row.captured_at_utc}`
143
+ : row.observed_via === "changed_capture"
144
+ ? // D-086: the value changed after the session's last request, so it isn't that request's.
145
+ "observed at capture (changed since the session's last request)"
146
+ : "observed at capture (no request found in the logs)"}`,
147
+ location: formatLocation(row),
148
+ contributions: [row.used_percentage],
149
+ },
150
+ ];
151
+ }
152
+ /**
153
+ * Lists a window's stale readings, which add nothing to its value (D-089): usage within a window
154
+ * only rises, so a reading below an earlier one carries a number from before that reading.
155
+ * @param db - Database.
156
+ * @param instance - The window instance.
157
+ * @returns One event per stale reading, contributing nothing, in time order.
158
+ */
159
+ function staleEvents(db, instance) {
160
+ const rows = db
161
+ .prepare(`SELECT e.observed_at_utc, e.used_percentage, e.session_id, ${LOCATION_COLUMNS}
162
+ FROM window_readings e ${LOCATION_JOIN}
163
+ WHERE e.window = ? AND e.reset_at_utc = ? AND e.after_reset = 0 AND e.stale = 1
164
+ ORDER BY e.observed_at_s, e.raw_line_id`)
165
+ .all(instance.window, instance.reset_at_utc);
166
+ return rows.map((row) => ({
167
+ at: row.observed_at_utc,
168
+ description: `stale reading, not used: ${formatNumber(row.used_percentage, "percent")} | session ${row.session_id ?? "unknown"} | below an earlier reading in this window`,
169
+ location: formatLocation(row),
170
+ contributions: [null],
171
+ }));
172
+ }
173
+ /**
174
+ * Titles a window instance group.
175
+ * @param key - Window and reset.
176
+ * @returns E.g. `5-hour window resetting 2026-09-02T17:00:00.000Z`; the instant is shown in the
177
+ * display zone at render time.
178
+ */
179
+ function instanceTitle(key) {
180
+ return `${windowName(key.window)} window resetting ${key.reset_at_utc}`;
181
+ }
182
+ /** Builders for every metric `explain` supports, keyed by command-line name. */
183
+ const BUILDERS = {
184
+ "limit-hits": (db) => ({
185
+ metric: "limit-hits",
186
+ title: "Rate-limit interruptions",
187
+ section: "observed",
188
+ groups: [
189
+ {
190
+ title: "All interruptions",
191
+ measures: [
192
+ {
193
+ label: "Interruptions",
194
+ kind: "count",
195
+ reported: scalar(db, "SELECT hits AS v FROM obs_limit_hits"),
196
+ },
197
+ ],
198
+ events: db
199
+ .prepare(`SELECT e.*, ${LOCATION_COLUMNS} FROM obs_limit_hits_events e ${LOCATION_JOIN} ORDER BY e.hit_at_utc, e.raw_line_id`)
200
+ .all().map(hitEvent),
201
+ },
202
+ ],
203
+ }),
204
+ "mid-task": (db) => ({
205
+ metric: "mid-task",
206
+ title: "Mid-task interruptions",
207
+ section: "observed",
208
+ groups: [
209
+ {
210
+ title: "Interruptions answering a tool result",
211
+ measures: [
212
+ {
213
+ label: "Mid-task interruptions",
214
+ kind: "count",
215
+ reported: scalar(db, "SELECT mid_task AS v FROM obs_mid_task_interruptions"),
216
+ },
217
+ ],
218
+ events: db
219
+ .prepare(`SELECT e.*, ${LOCATION_COLUMNS} FROM obs_mid_task_interruptions_events e ${LOCATION_JOIN} ORDER BY e.hit_at_utc, e.raw_line_id`)
220
+ .all().map(hitEvent),
221
+ },
222
+ ],
223
+ }),
224
+ lockout: (db) => {
225
+ const intervals = db
226
+ .prepare("SELECT * FROM obs_lockout_intervals ORDER BY interval_number")
227
+ .all();
228
+ const hits = db.prepare(`SELECT ${LOCATION_COLUMNS} FROM obs_lockout_interval_hits e ${LOCATION_JOIN}
229
+ WHERE e.interval_number = ? ORDER BY e.start_utc, e.raw_line_id`);
230
+ return {
231
+ metric: "lockout",
232
+ title: "Elapsed lockout time",
233
+ section: "observed",
234
+ groups: [
235
+ {
236
+ title: "Lockouts (first limit hit to the window's reset, overlapping lockouts merged)",
237
+ measures: [
238
+ {
239
+ label: "Elapsed lockout time",
240
+ kind: "duration",
241
+ reported: scalar(db, "SELECT lockout_seconds AS v FROM obs_lockout_time"),
242
+ // The view totals intervals with TOTAL, which is 0 when there are none.
243
+ nullIsZero: true,
244
+ },
245
+ ],
246
+ events: intervals.map((interval) => ({
247
+ at: interval.locked_from_utc,
248
+ description: `locked until ${interval.locked_until_utc} | ${interval.hits === 1 ? "1 hit" : `${interval.hits} hits`}`,
249
+ location: hits.all(interval.interval_number)
250
+ .map(formatLocation)
251
+ .join(", "),
252
+ contributions: [interval.lockout_seconds],
253
+ })),
254
+ },
255
+ {
256
+ // D-077: the logged markers behind each lockout's "resumed by Claude Code".
257
+ title: "Sessions Claude Code resumed by itself after a reset (logged auto-continuation)",
258
+ measures: [
259
+ {
260
+ label: "Resumed by Claude Code",
261
+ kind: "count",
262
+ reported: scalar(db, "SELECT TOTAL(auto_continuations) AS v FROM obs_lockout_intervals"),
263
+ },
264
+ ],
265
+ events: db
266
+ .prepare(`SELECT e.*, ${LOCATION_COLUMNS} FROM obs_auto_continuation_events e ${LOCATION_JOIN}
267
+ ORDER BY e.continued_at_utc, e.raw_line_id`)
268
+ .all().map((row) => ({
269
+ at: row.continued_at_utc,
270
+ description: `auto-continuation | ${formatNumber(row.seconds_after_reset, "duration")} after the reset at ${row.locked_until_utc} | session ${row.session_id}`,
271
+ location: formatLocation(row),
272
+ contributions: [1],
273
+ })),
274
+ },
275
+ ],
276
+ };
277
+ },
278
+ "by-plan": (db) => {
279
+ // The same rows, in the same order, as the report's per-plan table (D-075).
280
+ const periods = db
281
+ .prepare(`SELECT * FROM obs_interruptions_by_plan
282
+ ORDER BY CASE period_kind WHEN 'none' THEN 0 WHEN 'plan' THEN 1 ELSE 2 END, from_month`)
283
+ .all();
284
+ // Each period's hits: obs_limit_hits_events' own rows, so every hit reads as limit-hits lists it.
285
+ const hits = db.prepare(`SELECT e.*, ${LOCATION_COLUMNS} FROM obs_limit_hits_events e
286
+ JOIN obs_limit_hits_by_plan_events b ON b.raw_line_id = e.raw_line_id ${LOCATION_JOIN}
287
+ WHERE b.period_kind = ? AND b.from_month IS ? ORDER BY e.hit_at_utc, e.raw_line_id`);
288
+ const intervals = db.prepare(`SELECT * FROM obs_lockout_by_plan_events
289
+ WHERE period_kind = ? AND from_month IS ? ORDER BY interval_number`);
290
+ const intervalHits = db.prepare(`SELECT ${LOCATION_COLUMNS} FROM obs_lockout_interval_hits e ${LOCATION_JOIN}
291
+ WHERE e.interval_number = ? ORDER BY e.start_utc, e.raw_line_id`);
292
+ return {
293
+ metric: "by-plan",
294
+ title: "Interruptions by plan period",
295
+ section: "observed",
296
+ groups: periods.flatMap((period) => {
297
+ const name = `${planPeriodName(period)} (${planPeriodMonths(period)})`;
298
+ return [
299
+ {
300
+ title: `${name}: limit hits`,
301
+ measures: [{ label: "Limit hits", kind: "count", reported: period.hits }],
302
+ events: hits.all(period.period_kind, period.from_month).map(hitEvent),
303
+ },
304
+ {
305
+ title: `${name}: elapsed lockout time`,
306
+ measures: [
307
+ {
308
+ label: "Elapsed lockout time",
309
+ kind: "duration",
310
+ reported: period.lockout_seconds,
311
+ // TOTAL in the view: 0 for a period with no lockout.
312
+ nullIsZero: true,
313
+ },
314
+ ],
315
+ events: intervals.all(period.period_kind, period.from_month).map((interval) => ({
316
+ at: interval.locked_from_utc,
317
+ description: `locked until ${interval.locked_until_utc} | ${interval.hits === 1 ? "1 hit" : `${interval.hits} hits`}`,
318
+ location: intervalHits.all(interval.interval_number)
319
+ .map(formatLocation)
320
+ .join(", "),
321
+ contributions: [interval.lockout_seconds],
322
+ })),
323
+ },
324
+ ];
325
+ }),
326
+ };
327
+ },
328
+ "not-resumed": (db) => ({
329
+ metric: "not-resumed",
330
+ title: "Sessions with no further request after a limit hit",
331
+ section: "observed",
332
+ groups: [
333
+ {
334
+ title: "Sessions, at their last limit hit",
335
+ measures: [
336
+ {
337
+ label: "Sessions not resumed",
338
+ kind: "count",
339
+ reported: scalar(db, "SELECT sessions_not_resumed AS v FROM obs_sessions_not_resumed"),
340
+ },
341
+ ],
342
+ events: db
343
+ .prepare(`SELECT e.session_id, e.last_hit_at_utc, e.reset_at_utc, ${LOCATION_COLUMNS}
344
+ FROM (SELECT *, last_hit_raw_line_id AS raw_line_id FROM obs_sessions_not_resumed_events) e ${LOCATION_JOIN}
345
+ ORDER BY e.last_hit_at_utc`)
346
+ .all().map((row) => ({
347
+ at: row.last_hit_at_utc,
348
+ description: `session ${row.session_id} | reset ${row.reset_at_utc ?? "unknown"} | no later request in the session`,
349
+ location: formatLocation(row),
350
+ contributions: [1],
351
+ })),
352
+ },
353
+ ],
354
+ }),
355
+ headroom: (db) => ({
356
+ metric: "headroom",
357
+ title: "Last observed usage before each window's reset",
358
+ section: "observed",
359
+ groups: db
360
+ .prepare(`SELECT window, reset_at_utc, last_used_percentage AS value, last_reading_raw_line_id AS reading
361
+ FROM obs_window_headroom ORDER BY reset_at_utc, window`)
362
+ .all().map((row) => ({
363
+ title: instanceTitle(row),
364
+ measures: [
365
+ { label: "Last observed usage (lower bound)", kind: "percent", reported: row.value },
366
+ ],
367
+ events: [...readingEvents(db, row, "last reading before the reset"), ...staleEvents(db, row)],
368
+ })),
369
+ }),
370
+ peak: (db) => ({
371
+ metric: "peak",
372
+ title: "Peak usage per window",
373
+ section: "observed",
374
+ groups: db
375
+ .prepare(`SELECT window, reset_at_utc, peak_used_percentage AS value, peak_reading_raw_line_id AS reading
376
+ FROM obs_window_peak ORDER BY reset_at_utc, window`)
377
+ .all().map((row) => ({
378
+ title: instanceTitle(row),
379
+ measures: [{ label: "Peak usage", kind: "percent", reported: row.value }],
380
+ events: readingEvents(db, row, "highest reading"),
381
+ })),
382
+ }),
383
+ // D-087 (step G3.2): each weekly window's first reading at or above 75% and 90%.
384
+ crossings: (db) => ({
385
+ metric: "crossings",
386
+ title: "Weekly windows at 75% and 90%",
387
+ section: "observed",
388
+ groups: db
389
+ .prepare(`SELECT window, reset_at_utc, threshold, seconds_before_reset,
390
+ used_percentage AS value, raw_line_id AS reading
391
+ FROM obs_weekly_crossings ORDER BY reset_at_utc, threshold`)
392
+ .all().map((row) => ({
393
+ title: `${instanceTitle(row)}: first reading at or above ${String(row.threshold)}%, ${formatNumber(row.seconds_before_reset, "duration")} before the reset`,
394
+ measures: [{ label: "Usage at that reading", kind: "percent", reported: row.value }],
395
+ events: readingEvents(db, row, `first reading at or above ${String(row.threshold)}%`),
396
+ })),
397
+ }),
398
+ unattributed: (db) => {
399
+ const pairs = db.prepare(`SELECT e.*, ${LOCATION_COLUMNS} FROM obs_unattributed_usage_events e ${LOCATION_JOIN}
400
+ WHERE e.window = ? AND e.reset_at_utc = ? ORDER BY e.observed_at_utc, e.raw_line_id`);
401
+ return {
402
+ metric: "unattributed",
403
+ title: "Usage rise with no Claude Code request between readings",
404
+ section: "observed",
405
+ groups: db
406
+ .prepare("SELECT window, reset_at_utc, unattributed_percentage_points AS value, readings FROM obs_unattributed_usage ORDER BY reset_at_utc, window")
407
+ .all().map((row) => ({
408
+ title: instanceTitle(row),
409
+ measures: [
410
+ {
411
+ label: "Rise with no request (lower bound)",
412
+ kind: "percentage_points",
413
+ reported: row.value,
414
+ // As in the view (D-025): with two or more readings, no qualifying pair means 0; with fewer, unknown.
415
+ nullIsZero: row.readings >= 2,
416
+ },
417
+ ],
418
+ events: pairs.all(row.window, row.reset_at_utc).map((pair) => ({
419
+ at: pair.observed_at_utc,
420
+ description: `rise of ${formatNumber(pair.change_percentage_points, "percentage_points")} since the observation at ${pair.previous_observed_at_utc}, no Claude Code request in between`,
421
+ location: formatLocation(pair),
422
+ contributions: [pair.change_percentage_points],
423
+ })),
424
+ })),
425
+ };
426
+ },
427
+ "by-model": (db) => {
428
+ const requests = db.prepare(`SELECT e.*, ${LOCATION_COLUMNS} FROM obs_usage_events e ${LOCATION_JOIN}
429
+ WHERE e.model = ? ORDER BY e.timestamp_utc, e.raw_line_id`);
430
+ return {
431
+ metric: "by-model",
432
+ title: "Claude Code tokens by model",
433
+ section: "observed",
434
+ groups: db
435
+ .prepare("SELECT * FROM obs_usage_by_model ORDER BY output_tokens DESC, model")
436
+ .all().map((summary) => {
437
+ const model = summary["model"];
438
+ return tokenGroup(model, summary, requests.all(model));
439
+ }),
440
+ };
441
+ },
442
+ "by-repo": (db) => {
443
+ const requests = db.prepare(`SELECT e.*, ${LOCATION_COLUMNS} FROM obs_usage_events e ${LOCATION_JOIN}
444
+ WHERE e.repository IS ? AND e.repo_kind = ? ORDER BY e.timestamp_utc, e.raw_line_id`);
445
+ return {
446
+ metric: "by-repo",
447
+ title: "Claude Code tokens by repository",
448
+ section: "observed",
449
+ groups: db
450
+ .prepare("SELECT * FROM obs_usage_by_repo ORDER BY output_tokens DESC, repository")
451
+ .all().map((summary) => {
452
+ const repository = summary["repository"];
453
+ const kind = summary["repo_kind"];
454
+ return tokenGroup(`${repository ?? "unknown (no working directory)"} | ${repoKindName(kind)}`, summary, requests.all(repository, kind));
455
+ }),
456
+ };
457
+ },
458
+ "burn-rate": (db) => ({
459
+ metric: "burn-rate",
460
+ title: "Burn rate",
461
+ section: "projected",
462
+ groups: db
463
+ .prepare(`SELECT b.window, b.reset_at_utc, b.last_used_percentage AS value, h.last_reading_raw_line_id AS reading
464
+ FROM proj_burn_rate b
465
+ JOIN obs_window_headroom h ON h.window = b.window AND h.reset_at_utc = b.reset_at_utc
466
+ ORDER BY b.reset_at_utc, b.window`)
467
+ .all().map((row) => ({
468
+ title: `${instanceTitle(row)}: projected from this reading, with the window's usage at 0% when it began`,
469
+ measures: [
470
+ {
471
+ label: "Last observed usage the projection starts from",
472
+ kind: "percent",
473
+ reported: row.value,
474
+ },
475
+ ],
476
+ events: readingEvents(db, row, "last reading before the reset"),
477
+ })),
478
+ }),
479
+ "api-list-price": (db) => {
480
+ const requests = db.prepare(`SELECT e.dedup_key, e.model, e.timestamp_utc, e.total_usd, e.unpriced_reason, ${LOCATION_COLUMNS}
481
+ FROM request_costs e ${LOCATION_JOIN}
482
+ WHERE strftime('%Y-%m', e.timestamp_utc, 'localtime') = ? ORDER BY e.timestamp_utc, e.raw_line_id`);
483
+ return {
484
+ metric: "api-list-price",
485
+ title: "Observed tokens at API list price, by month",
486
+ section: "projected",
487
+ groups: db
488
+ .prepare("SELECT month, api_list_price_usd, priced_requests, unpriced_requests FROM proj_api_list_price ORDER BY month")
489
+ .all().map((month) => ({
490
+ title: month.month,
491
+ measures: [
492
+ { label: "At API list price", kind: "usd", reported: month.api_list_price_usd },
493
+ {
494
+ label: "Priced requests",
495
+ kind: "count",
496
+ reported: month.priced_requests,
497
+ nullIsZero: true,
498
+ },
499
+ {
500
+ label: "Unpriced requests",
501
+ kind: "count",
502
+ reported: month.unpriced_requests,
503
+ nullIsZero: true,
504
+ },
505
+ ],
506
+ events: requests.all(month.month).map((row) => ({
507
+ at: row.timestamp_utc,
508
+ description: `request ${row.dedup_key ?? "without an ID"} | ${row.model} | ${row.unpriced_reason === null
509
+ ? formatNumber(row.total_usd, "usd")
510
+ : `unpriced (${row.unpriced_reason.replaceAll("_", " ")})`}`,
511
+ location: formatLocation(row),
512
+ contributions: [
513
+ row.total_usd,
514
+ row.unpriced_reason === null ? 1 : 0,
515
+ row.unpriced_reason === null ? 0 : 1,
516
+ ],
517
+ })),
518
+ })),
519
+ };
520
+ },
521
+ };
522
+ /** Every metric name, in report order. */
523
+ export const METRIC_NAMES = Object.keys(BUILDERS);
524
+ /**
525
+ * Builds the explanation for a metric.
526
+ * @param db - Open, migrated database.
527
+ * @param metric - A name from {@link METRIC_NAMES}.
528
+ * @returns The explanation.
529
+ * @throws {UnknownMetricError} If the name isn't one of {@link METRIC_NAMES}.
530
+ */
531
+ export function explain(db, metric) {
532
+ if (!Object.hasOwn(BUILDERS, metric)) {
533
+ throw new UnknownMetricError(metric);
534
+ }
535
+ return BUILDERS[metric](db);
536
+ }
537
+ /**
538
+ * Renders an explanation.
539
+ * @param explanation - From {@link explain}.
540
+ * @param timeZone - Display zone.
541
+ * @param home - Home directory to show as `~`.
542
+ * @param limit - Most events listed per group; `Infinity` lists all. Re-sums always use every event.
543
+ * @returns Lines.
544
+ */
545
+ export function renderExplanation(explanation, timeZone, home, limit) {
546
+ const section = explanation.section === "observed" ? "observed" : "projected: an estimate, not an observation";
547
+ const lines = [`Explain: ${explanation.title} (${section})`, `Times are in ${timeZone}.`];
548
+ if (explanation.groups.length === 0) {
549
+ lines.push("", "No data for this metric yet.");
550
+ }
551
+ for (const group of explanation.groups) {
552
+ // Group titles may carry UTC instants; show them in the display zone.
553
+ const title = group.title.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/g, (iso) => formatInstant(iso, timeZone));
554
+ // Titles, descriptions, and locations carry repository paths, model names, session IDs, and
555
+ // file names from the logs, so escape control characters before printing them (D-050).
556
+ lines.push("", printable(displayPath(title, home)));
557
+ group.measures.forEach((measure, index) => {
558
+ const fromEvents = resum(group, index);
559
+ const agrees = measure.reported === null || fromEvents === null
560
+ ? measure.reported === fromEvents
561
+ : Math.abs(measure.reported - fromEvents) <=
562
+ 1e-9 * Math.max(1, Math.abs(measure.reported));
563
+ lines.push(` ${measure.label}: report shows ${formatNumber(measure.reported, measure.kind)} | from the ${group.events.length === 1 ? "event" : `${formatNumber(group.events.length, "count")} events`} below: ${formatNumber(fromEvents, measure.kind)}${agrees ? "" : " | these don't match"}`);
564
+ });
565
+ for (const event of group.events.slice(0, limit)) {
566
+ lines.push(` ${formatInstant(event.at, timeZone)} ${printable(event.description)} ${printable(event.location)}`);
567
+ }
568
+ if (group.events.length > limit) {
569
+ lines.push(` ${formatNumber(group.events.length - limit, "count")} more events not listed; --all lists every event`);
570
+ }
571
+ }
572
+ return lines;
573
+ }
574
+ //# sourceMappingURL=explain.js.map