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.
- package/CHANGELOG.md +63 -0
- package/LICENSE +21 -0
- package/README.md +462 -2
- package/core/pricing/prices.json +348 -0
- package/core/schema/001_ingestion.sql +57 -0
- package/core/schema/002_line_count.sql +7 -0
- package/core/schema/003_derived.sql +70 -0
- package/core/schema/004_requests_dedup.sql +50 -0
- package/core/schema/005_repositories.sql +25 -0
- package/core/schema/006_status_readings.sql +33 -0
- package/core/schema/007_pricing.sql +95 -0
- package/core/schema/008_interruptions.sql +263 -0
- package/core/schema/009_headroom.sql +84 -0
- package/core/schema/010_attribution.sql +106 -0
- package/core/schema/011_projections.sql +83 -0
- package/core/schema/012_unsplit_cache_writes.sql +49 -0
- package/core/schema/013_repository_revalidation.sql +104 -0
- package/core/schema/014_observed_readings.sql +204 -0
- package/core/schema/015_response_observations.sql +127 -0
- package/core/schema/016_windows_repository_paths.sql +41 -0
- package/core/schema/017_unattributed_usage_once.sql +47 -0
- package/core/schema/018_request_time_index.sql +21 -0
- package/core/schema/019_dedup_earliest_of_equals.sql +61 -0
- package/core/schema/020_limit_quota_fields.sql +131 -0
- package/core/schema/021_plan_periods.sql +121 -0
- package/core/schema/022_attachment_walk.sql +223 -0
- package/core/schema/023_auto_continuations.sql +129 -0
- package/core/schema/024_counted_limit_groups.sql +29 -0
- package/core/schema/025_model_fallback.sql +77 -0
- package/core/schema/026_changed_readings.sql +106 -0
- package/core/schema/027_weekly_crossings.sql +32 -0
- package/core/schema/028_stale_readings.sql +196 -0
- package/core/schema/029_lockout_sessions.sql +68 -0
- package/core/schema/030_repository_aliases.sql +81 -0
- package/core/schema/031_cte_cursor_budget.sql +153 -0
- package/dist/cli/main.cli.js +39 -0
- package/dist/cli/program.js +801 -0
- package/dist/core/db/database.js +182 -0
- package/dist/core/db/fingerprint.js +71 -0
- package/dist/core/ingest/attribution.js +131 -0
- package/dist/core/ingest/classify.js +382 -0
- package/dist/core/ingest/command.js +95 -0
- package/dist/core/ingest/derive.js +212 -0
- package/dist/core/ingest/discover.js +126 -0
- package/dist/core/ingest/ingest.js +160 -0
- package/dist/core/ingest/reader.js +89 -0
- package/dist/core/ingest/reset-time.js +97 -0
- package/dist/core/ingest/results.js +201 -0
- package/dist/core/ingest/spool.js +165 -0
- package/dist/core/ingest/store.js +99 -0
- package/dist/core/install/install.js +185 -0
- package/dist/core/install/locations.js +89 -0
- package/dist/core/install/private-files.js +259 -0
- package/dist/core/install/record.js +103 -0
- package/dist/core/plans/plan-prices.js +128 -0
- package/dist/core/plans/repository-aliases.js +136 -0
- package/dist/core/pricing/prices.js +147 -0
- package/dist/core/settings/settings-file.js +215 -0
- package/dist/core/settings/statusline.js +240 -0
- package/dist/core/util/commands.js +91 -0
- package/dist/core/util/node-version.js +58 -0
- package/dist/core/util/open-page.js +69 -0
- package/dist/core/util/paths.js +36 -0
- package/dist/core/verify/compare.js +77 -0
- package/dist/core/verify/verify.js +220 -0
- package/dist/viewer/explain.js +574 -0
- package/dist/viewer/format.js +150 -0
- package/dist/viewer/html-charts.js +1292 -0
- package/dist/viewer/html-contract.js +2 -0
- package/dist/viewer/html-data.js +718 -0
- package/dist/viewer/html.js +1654 -0
- package/dist/viewer/queries.js +128 -0
- package/dist/viewer/render.js +602 -0
- package/dist/viewer/report.js +120 -0
- package/dist/viewer/save.js +110 -0
- package/docs/requirements.md +113 -0
- package/hooks/statusline.sh +104 -0
- 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
|