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,220 @@
1
+ /**
2
+ * @file `nilometer verify`: check a user's own numbers against ccusage, on their own machine.
3
+ *
4
+ * The audit compares Nilometer against ccusage on committed fixtures (check A7), which proves the
5
+ * reading rules on data everyone can see. It cannot prove them on anyone's real logs. This runs the
6
+ * same comparison where the logs actually are, and reports a verdict and any differing days —
7
+ * never a path, a repository name, a session id, or anything a prompt was in — so the result can be
8
+ * sent to someone without sending the data (D-064).
9
+ *
10
+ * Two deliberate limits:
11
+ * - **Tokens, not cost.** Cost depends on both tools' price tables agreeing; a model with no price
12
+ * row here would report a difference that is not a reading mistake.
13
+ * - **Only days both sides can see.** Ingestion keeps raw lines after Claude Code deletes a log
14
+ * (D-002), so Nilometer legitimately holds days ccusage cannot read. Those are counted and
15
+ * explained, never reported as differences.
16
+ */
17
+ import { spawnSync } from "node:child_process";
18
+ import { existsSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { commandFound, invocation } from "../util/commands.js";
21
+ import { dayOf, diffTotals, normalizeCcusage, } from "./compare.js";
22
+ /** The fields compared: tokens only, for the reason in this file's header. */
23
+ export const VERIFIED_FIELDS = ["input", "output", "cache_read", "cache_write"];
24
+ /**
25
+ * Reads Nilometer's own per-day, per-model token totals, from logs that still exist.
26
+ *
27
+ * Ingestion keeps raw lines after Claude Code deletes a log (D-002), which is the point of the
28
+ * database. ccusage reads the files that are there now. Comparing a request whose log is gone
29
+ * against a tool that cannot see it reports a difference that is the design working: on a real
30
+ * machine that was 23 of 79 files and 1,281 requests, enough to bury any real fault. So those
31
+ * requests are excluded and counted instead.
32
+ * @param db - Open, migrated database.
33
+ * @param exists - Whether a path is on disk; injected so tests need no filesystem.
34
+ * @returns Totals for responses ccusage can still see, and how many were left out.
35
+ */
36
+ export function ourTotals(db, exists = existsSync) {
37
+ // Which logs are still on disk. ccusage reads those; the database also holds ones it can't.
38
+ const files = db
39
+ .prepare("SELECT id, root, relative_path AS relativePath FROM source_files")
40
+ .all();
41
+ db.exec("CREATE TEMP TABLE IF NOT EXISTS verify_present_files (id INTEGER PRIMARY KEY)");
42
+ db.exec("DELETE FROM verify_present_files");
43
+ const remember = db.prepare("INSERT INTO verify_present_files (id) VALUES (?)");
44
+ for (const file of files) {
45
+ if (exists(join(file.root, file.relativePath))) {
46
+ remember.run(file.id);
47
+ }
48
+ }
49
+ // A response counts if ANY copy of it is still on disk, not just the copy dedup kept. One
50
+ // response is written to several lines and sometimes several files, and the largest-output
51
+ // winner can sit in a file that has since gone while a surviving copy is what ccusage reads.
52
+ // On a real machine that was 802 responses: excluding them made this tool look short by a third
53
+ // of a day.
54
+ const visible = `
55
+ SELECT DISTINCT r.raw_line_id, r.dedup_key
56
+ FROM requests r
57
+ JOIN raw_lines l ON l.id = r.raw_line_id
58
+ WHERE l.source_file_id IN (SELECT id FROM verify_present_files)`;
59
+ const rows = db
60
+ .prepare(`WITH visible AS (${visible})
61
+ SELECT substr(d.timestamp_utc, 1, 10) AS day, d.model,
62
+ SUM(d.input_tokens) AS input, SUM(d.output_tokens) AS output,
63
+ SUM(d.cache_read_tokens) AS cache_read,
64
+ SUM(COALESCE(d.cache_write_5m_tokens, 0) + COALESCE(d.cache_write_1h_tokens, 0)
65
+ + COALESCE(d.cache_write_unsplit_tokens, 0)) AS cache_write
66
+ FROM requests_dedup d
67
+ WHERE d.timestamp_utc IS NOT NULL
68
+ AND (d.raw_line_id IN (SELECT raw_line_id FROM visible)
69
+ OR (d.dedup_key IS NOT NULL
70
+ AND d.dedup_key IN (SELECT dedup_key FROM visible WHERE dedup_key IS NOT NULL)))
71
+ GROUP BY day, d.model`)
72
+ .all();
73
+ const gone = db
74
+ .prepare(`WITH visible AS (${visible})
75
+ SELECT COUNT(*) AS n FROM requests_dedup d
76
+ WHERE d.raw_line_id NOT IN (SELECT raw_line_id FROM visible)
77
+ AND (d.dedup_key IS NULL
78
+ OR d.dedup_key NOT IN (SELECT dedup_key FROM visible WHERE dedup_key IS NOT NULL))`)
79
+ .get();
80
+ const totals = {};
81
+ for (const row of rows) {
82
+ totals[`${row.day}|${row.model}`] = {
83
+ input: row.input,
84
+ output: row.output,
85
+ cache_read: row.cache_read,
86
+ cache_write: row.cache_write,
87
+ // Not compared; present so the shape matches.
88
+ cost_usd: 0,
89
+ };
90
+ }
91
+ return { totals, fromDeletedLogs: gone.n };
92
+ }
93
+ /**
94
+ * Lists models with requests but no price row, so the report can say the comparison ignored them.
95
+ * @param db - Open, migrated database.
96
+ * @returns Model names, sorted.
97
+ */
98
+ export function unpricedModels(db) {
99
+ return db.prepare("SELECT DISTINCT model FROM unpriced_requests ORDER BY model").all().map((row) => row.model);
100
+ }
101
+ /**
102
+ * Compares Nilometer's totals against ccusage's, over the days both sides can see.
103
+ * @param ours - From {@link ourTotals}.
104
+ * @param theirs - From `normalizeCcusage`.
105
+ * @param unpriced - From {@link unpricedModels}.
106
+ * @param today - The current UTC day, which is never compared; injected for tests.
107
+ * @returns What matched, what didn't, and what could not be compared.
108
+ */
109
+ export function compareTotals(ours, theirs, unpriced = [], today = new Date().toISOString().slice(0, 10)) {
110
+ const ourTotalsByKey = ours.totals;
111
+ const ourDays = new Set(Object.keys(ourTotalsByKey).map(dayOf));
112
+ const theirDays = new Set(Object.keys(theirs).map(dayOf));
113
+ // Today is never compared: ingestion is a snapshot, and Claude Code keeps writing after it. The
114
+ // two sides would be read at different moments, and the difference would be the clock (D-064).
115
+ const shared = new Set([...ourDays].filter((day) => theirDays.has(day) && day !== today));
116
+ /**
117
+ * Keeps only the entries on days both sides can see.
118
+ * @param totals - One side's totals.
119
+ * @returns The same totals, restricted to shared days.
120
+ */
121
+ const onSharedDays = (totals) => Object.fromEntries(Object.entries(totals).filter(([key]) => shared.has(dayOf(key))));
122
+ const oursShared = onSharedDays(ourTotalsByKey);
123
+ const theirsShared = onSharedDays(theirs);
124
+ return {
125
+ comparedDays: shared.size,
126
+ comparedKeys: new Set([...Object.keys(oursShared), ...Object.keys(theirsShared)]).size,
127
+ daysOnlyOurs: [...ourDays].filter((day) => !theirDays.has(day) && day !== today).length,
128
+ daysOnlyTheirs: [...theirDays].filter((day) => !ourDays.has(day) && day !== today).length,
129
+ differences: diffTotals(oursShared, theirsShared, VERIFIED_FIELDS),
130
+ unpricedModels: [...unpriced],
131
+ fromDeletedLogs: ours.fromDeletedLogs,
132
+ skippedToday: today,
133
+ };
134
+ }
135
+ /**
136
+ * Runs the comparison end to end.
137
+ * @param deps - How to reach ccusage, and the database to read.
138
+ * @returns What matched and what didn't.
139
+ * @throws {Error} If ccusage cannot be run or its output cannot be parsed; the message says which.
140
+ */
141
+ export function runVerify(deps) {
142
+ const stdout = deps.runCcusage();
143
+ let parsed;
144
+ try {
145
+ parsed = JSON.parse(stdout);
146
+ }
147
+ catch {
148
+ throw new Error("ccusage did not return JSON this understands; nothing was compared");
149
+ }
150
+ return compareTotals(ourTotals(deps.db), normalizeCcusage(parsed), unpricedModels(deps.db));
151
+ }
152
+ /** The ccusage release compared against; pinned so two runs compare the same way. */
153
+ export const CCUSAGE_VERSION = "20.0.20";
154
+ /**
155
+ * Arguments for the pinned ccusage.
156
+ * @returns `npx` arguments: pinned version, JSON, token-based pricing, offline, UTC days.
157
+ */
158
+ export function ccusageArgs() {
159
+ // --mode calculate ignores legacy costUSD fields; --offline freezes its embedded price table.
160
+ return [
161
+ "-y",
162
+ `ccusage@${CCUSAGE_VERSION}`,
163
+ "claude",
164
+ "daily",
165
+ "--json",
166
+ "--mode",
167
+ "calculate",
168
+ "--offline",
169
+ "-z",
170
+ "UTC",
171
+ "--breakdown",
172
+ ];
173
+ }
174
+ /**
175
+ * Spawns a command and collects what it returned, the Windows-safe way (D-051).
176
+ *
177
+ * With its default command this is the one thing in the installed tool that reaches the network,
178
+ * because `npx` fetches ccusage. The command is a parameter so the plumbing — quoting, encoding,
179
+ * status, stderr — can be proven against a local process instead of the network.
180
+ * @param args - Arguments for the command.
181
+ * @param command - What to run; defaults to npx.
182
+ * @returns What it returned.
183
+ */
184
+ export function spawnCollecting(args, command = "npx") {
185
+ const started = invocation(command, args);
186
+ const result = spawnSync(started.command, started.args, {
187
+ shell: started.shell,
188
+ encoding: "utf8",
189
+ maxBuffer: 64 * 1024 * 1024,
190
+ });
191
+ return {
192
+ status: result.status,
193
+ stdout: result.stdout ?? "",
194
+ stderr: result.stderr ?? result.error?.message ?? "",
195
+ };
196
+ }
197
+ /**
198
+ * Runs the pinned ccusage and returns its stdout.
199
+ *
200
+ * This is the one thing in the installed tool that uses the network: `npx` fetches ccusage if it
201
+ * isn't cached. Nothing else in Nilometer does, and `verify` is the only command that calls it.
202
+ * @param deps - Injected lookup and spawn, so the failure paths are testable offline.
203
+ * @returns ccusage's stdout.
204
+ * @throws {Error} If npx can't be found or ccusage exits non-zero, with what it printed.
205
+ */
206
+ export function runCcusage(deps = {}) {
207
+ const found = deps.found ?? commandFound;
208
+ // The function itself, not a wrapper: a wrapper would be a branch nothing offline can reach.
209
+ const run = deps.run ?? spawnCollecting;
210
+ if (!found("npx")) {
211
+ throw new Error("npx was not found, so ccusage could not be run; nothing was compared");
212
+ }
213
+ const result = run(ccusageArgs());
214
+ if (result.status !== 0) {
215
+ const reason = (result.stderr || "no output").trim().split("\n")[0];
216
+ throw new Error(`ccusage could not be run (${reason}); nothing was compared`);
217
+ }
218
+ return result.stdout;
219
+ }
220
+ //# sourceMappingURL=verify.js.map
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @file The local server behind `nilometer view` (D-097, step G6.2).
3
+ *
4
+ * A saved page is a file, and a browser won't let a file run programs, so a Refresh button on it can't
5
+ * ingest. While `view` runs, this server shows the page on 127.0.0.1 and, when the page asks, ingests and
6
+ * rebuilds it. It's the only way in, so it accepts as little as it can:
7
+ * - it listens on the loopback address only, never on a network interface;
8
+ * - every path starts with a random token made for this run, so another page in the browser can't guess it;
9
+ * - the Host header must name this server, which defeats DNS rebinding, where a site re-points its own
10
+ * name at 127.0.0.1;
11
+ * - a refresh must be a POST from this server's own origin, which a page on another site can't send;
12
+ * - it serves one page and does one thing, refresh; anything else is a 404.
13
+ */
14
+ import { timingSafeEqual } from "node:crypto";
15
+ import { createServer } from "node:http";
16
+ /**
17
+ * The hosts this server answers to: the loopback address and `localhost`, on its own port.
18
+ * @param port - The port it listens on.
19
+ * @returns Host header values, without a scheme.
20
+ */
21
+ export function allowedHosts(port) {
22
+ return [`127.0.0.1:${port}`, `localhost:${port}`];
23
+ }
24
+ /**
25
+ * Compares two strings in time that doesn't depend on where they differ.
26
+ * @param a - One string.
27
+ * @param b - The other.
28
+ * @returns Whether they're equal.
29
+ */
30
+ function sameSecret(a, b) {
31
+ const x = Buffer.from(a);
32
+ const y = Buffer.from(b);
33
+ return x.length === y.length && timingSafeEqual(x, y);
34
+ }
35
+ /**
36
+ * Decides what a request may do.
37
+ * @param request - Method, target, Host and Origin.
38
+ * @param token - This run's token.
39
+ * @param port - The port this server listens on.
40
+ * @returns The page, a refresh, or a refusal with its status.
41
+ */
42
+ export function route(request, token, port) {
43
+ const hosts = allowedHosts(port);
44
+ if (request.host === undefined || !hosts.includes(request.host.toLowerCase())) {
45
+ return { kind: "refuse", status: 403, reason: "unknown host" };
46
+ }
47
+ // Only the path counts; a query string never changes what's served.
48
+ const path = request.url.split("?")[0] ?? "";
49
+ const match = /^\/([^/]+)\/(refresh)?$/.exec(path);
50
+ if (match === null || !sameSecret(match[1] ?? "", token)) {
51
+ return { kind: "refuse", status: 404, reason: "not found" };
52
+ }
53
+ if (match[2] === undefined) {
54
+ return request.method === "GET" || request.method === "HEAD"
55
+ ? { kind: "page" }
56
+ : { kind: "refuse", status: 405, reason: "the page is only read" };
57
+ }
58
+ if (request.method !== "POST") {
59
+ return { kind: "refuse", status: 405, reason: "a refresh is a POST" };
60
+ }
61
+ // A browser sends Origin with every POST from a page; none, or another one, isn't this page.
62
+ if (request.origin === undefined || !hosts.map((h) => `http://${h}`).includes(request.origin)) {
63
+ return { kind: "refuse", status: 403, reason: "a refresh must come from this page" };
64
+ }
65
+ return { kind: "refresh" };
66
+ }
67
+ /**
68
+ * Headers for every response. The policy lets the page run its own inline script and style and talk
69
+ * only to this server; the token stays out of any Referer.
70
+ */
71
+ export const RESPONSE_HEADERS = {
72
+ "Cache-Control": "no-store",
73
+ "X-Content-Type-Options": "nosniff",
74
+ "Referrer-Policy": "no-referrer",
75
+ "Content-Security-Policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
76
+ };
77
+ /**
78
+ * Creates the server, not yet listening. Listen on 127.0.0.1 only.
79
+ * @param deps - Token, page, and refresh.
80
+ * @returns The server.
81
+ */
82
+ export function createViewServer(deps) {
83
+ const server = createServer((request, response) => {
84
+ const address = server.address();
85
+ const port = typeof address === "object" && address !== null ? address.port : 0;
86
+ const decided = route({
87
+ method: request.method ?? "GET",
88
+ url: request.url ?? "/",
89
+ host: request.headers.host,
90
+ origin: request.headers.origin,
91
+ }, deps.token, port);
92
+ // The body is never read: no route takes one.
93
+ request.resume();
94
+ if (decided.kind === "refuse") {
95
+ response.writeHead(decided.status, {
96
+ ...RESPONSE_HEADERS,
97
+ "Content-Type": "text/plain; charset=utf-8",
98
+ });
99
+ response.end(`${decided.reason}\n`);
100
+ return;
101
+ }
102
+ if (decided.kind === "page") {
103
+ response.writeHead(200, { ...RESPONSE_HEADERS, "Content-Type": "text/html; charset=utf-8" });
104
+ response.end(request.method === "HEAD" ? undefined : deps.page());
105
+ return;
106
+ }
107
+ let result;
108
+ try {
109
+ result = deps.refresh();
110
+ }
111
+ catch (error) {
112
+ result = { ok: false, message: error instanceof Error ? error.message : String(error) };
113
+ }
114
+ response.writeHead(result.ok ? 200 : 500, {
115
+ ...RESPONSE_HEADERS,
116
+ "Content-Type": "application/json; charset=utf-8",
117
+ });
118
+ response.end(JSON.stringify(result));
119
+ });
120
+ return server;
121
+ }
122
+ /**
123
+ * Starts the server on the loopback address only. It runs until the process ends (Ctrl+C).
124
+ * @param options - Token, page, refresh, port, and callbacks.
125
+ * @returns The listening server.
126
+ */
127
+ export function listenView(options) {
128
+ const server = createViewServer(options);
129
+ server.on("error", (error) => {
130
+ options.onError(error.code === "EADDRINUSE"
131
+ ? `port ${options.port} on 127.0.0.1 is in use; choose another with --port, or leave it out for any free port`
132
+ : error.message);
133
+ });
134
+ // 127.0.0.1 explicitly: listening on every interface would put the page on the network.
135
+ server.listen(options.port, "127.0.0.1", () => {
136
+ const address = server.address();
137
+ const port = typeof address === "object" && address !== null ? address.port : options.port;
138
+ options.onListening(`http://127.0.0.1:${port}/${options.token}/`);
139
+ });
140
+ return server;
141
+ }
142
+ //# sourceMappingURL=server.js.map