nilometer 0.1.0 → 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.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,11 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0 (unreleased)
3
+ ## 0.2.0 (2026-10-22)
4
+
5
+ - **`nilometer view`:** shows the report page in your browser with a **Refresh** button that reads new log lines and rebuilds the page in place, on the tab you're on. It runs until you press Ctrl+C, listening on 127.0.0.1 only, behind a random address made for each run; a refresh must come from that page. `report --save --html` still writes a page you can keep or share, with no button.
6
+ - **Reports in seconds:** on three months of real data, `report` went from 23 s to under 2 s, and `report --save --html` from 53 s to under 7 s, with the same output. Ingest now stores the two building blocks every report query read, instead of each query recomputing them. The first `ingest` after updating takes a few seconds longer, once.
7
+
8
+ ## 0.1.0 (2026-10-22)
4
9
 
5
10
  First public version.
6
11
 
package/README.md CHANGED
@@ -104,7 +104,7 @@ Usage percentages exist only from the moment the collector is installed, and onl
104
104
 
105
105
  ## MVP scope
106
106
 
107
- One screen. No daemon, no desktop app, no live updating. Every metric below is retrospective.
107
+ One screen. No daemon, no desktop app, no live updating: `nilometer view` serves the page from your own computer only while you run it, and its Refresh button rebuilds the page when you ask ([D-097](decisions.md)). Every metric below is retrospective.
108
108
 
109
109
  ### Observed: "Is my plan too small?"
110
110
 
@@ -200,9 +200,10 @@ There's no GUI, installer, or auto-updater. Configuration was always the hard pa
200
200
  ```sh
201
201
  npm install -g nilometer
202
202
  nilometer init
203
+ nilometer view
203
204
  ```
204
205
 
205
- Installing compiles nothing: the database is the SQLite built into Node.js ([D-093](decisions.md)). To try a command without installing, use `npx nilometer <command>`.
206
+ `init` installs the hook and reads the session logs already on disk. `view` opens the report page in your browser; press **Refresh** on it whenever you want it brought up to date. Installing compiles nothing: the database is the SQLite built into Node.js ([D-093](decisions.md)). To try a command without installing, use `npx nilometer <command>`.
206
207
 
207
208
  - **macOS:** tested.
208
209
  - **Linux:** the test suite and an install from the package pass in CI.
@@ -230,6 +231,7 @@ On Node 24 before 24.15, `npm ci` prints `EBADENGINE` warnings for three lint pl
230
231
  | `nilometer init` | Install the hook (wrapping any existing status line), then backfill from session logs |
231
232
  | `nilometer ingest` | Read new session log lines and status line readings into the database. `--full` rereads everything; `--json` prints the outcome as JSON |
232
233
  | `nilometer report` | Print what was observed, then projections in a separate section. `--json` prints unrounded rows and every label. `--save` also writes a dated copy (`report_YYYY-MM-DD_HHMMSS.txt` and `.json`) to `~/.local/share/nilometer/reports/`; `--save --html` adds a self-contained `.html` page with the same name, which works offline and makes no network requests. The page opens on an overview, has one tab per chart, and keeps the projections in their own labelled tabs. `--save --html --open` also opens the saved page in your browser; if it can't, the command says where the page is and still succeeds |
234
+ | `nilometer view` | Show the report page in your browser from your own computer, with a **Refresh** button that reads new log lines and rebuilds the page in place, on the tab you're on. Runs until you press Ctrl+C. It listens on 127.0.0.1 only, behind a random address made for each run. `--port <n>` picks the port; `--no-open` prints the address without opening a browser |
233
235
  | `nilometer explain <metric>` | List the events behind a number in the report, with the file, line, and byte each came from, and add them back up. Metrics: `limit-hits`, `mid-task`, `lockout`, `by-plan`, `not-resumed`, `headroom`, `peak`, `crossings`, `unattributed`, `by-model`, `by-repo`, `burn-rate`, `api-list-price` |
234
236
  | `nilometer plan-price set <YYYY-MM> <usd> --name <plan>` | Record your plan's USD list price per month from that month on, shown beside API list price. `plan-price list` shows what's entered |
235
237
  | `nilometer alias set <old-path> <new-path>` | Count a renamed or moved project's old path under its new one, so it shows as one repository. The report lists every alias you entered under the repository table. `alias list` shows them; `alias remove <old-path>` undoes one ([D-092](decisions.md)) |
@@ -244,7 +246,7 @@ On Node 24 before 24.15, `npm ci` prints `EBADENGINE` warnings for three lint pl
244
246
 
245
247
  ### Privacy
246
248
 
247
- - **Everything stays on your computer.** Nilometer reads Claude Code's session logs and status line payload, and copies them into a local SQLite database. It makes no network requests when it runs, and it sends no telemetry.
249
+ - **Everything stays on your computer.** Nilometer reads Claude Code's session logs and status line payload, and copies them into a local SQLite database. It makes no network requests when it runs, and it sends no telemetry. `nilometer view` listens on 127.0.0.1 for your own browser while it runs, and nothing else can reach it.
248
250
  - **The database holds copies of your session logs:** prompts, code, and file paths. That's why the data directory is readable only by your account.
249
251
  - **Reports contain spend figures and project names.** Think before sharing one. `nilometer explain` output also names session files and IDs.
250
252
 
@@ -0,0 +1,233 @@
1
+ -- 032_stored_views: requests_dedup and window_readings become tables that ingest fills from their views (D-096, step G6.1).
2
+ --
3
+ -- Every report statement recomputed both from the raw tables: window_readings pairs each of tens of thousands of
4
+ -- rate-limit readings with its API response through correlated lookups, and requests_dedup ranks every request line.
5
+ -- On real data the report and page ran about 20 statements built on them and took 45-50 s. Their inputs (requests,
6
+ -- parsed_lines, raw_lines, source_files, status_readings, rate_limit_windows) are written only by ingest, so ingest
7
+ -- stores their rows once (core/ingest/stored-views.ts) and everything downstream reads the stored rows. The
8
+ -- definitions are unchanged and keep running as views under *_computed, so the rows trace back exactly as before,
9
+ -- and a test fails if a stored table ever differs from its view.
10
+ --
11
+ -- Columns are ANY in a STRICT table: no type affinity, so every value is stored exactly as the view returned it.
12
+
13
+ DROP VIEW requests_dedup;
14
+
15
+ -- Observed: one row per distinct API response (winners) plus every unkeyed request line, as 019 computed it; ingest stores its rows in requests_dedup (D-096).
16
+ CREATE VIEW requests_dedup_computed AS
17
+ WITH ranked AS (
18
+ SELECT
19
+ r.raw_line_id,
20
+ r.dedup_key,
21
+ r.message_id,
22
+ r.request_id,
23
+ r.model,
24
+ r.input_tokens,
25
+ r.output_tokens,
26
+ r.cache_read_tokens,
27
+ r.cache_write_5m_tokens,
28
+ r.cache_write_1h_tokens,
29
+ r.cache_write_unsplit_tokens,
30
+ r.speed,
31
+ p.session_id,
32
+ p.timestamp_raw,
33
+ p.timestamp_utc,
34
+ p.cwd,
35
+ p.is_sidechain,
36
+ f.root,
37
+ f.relative_path,
38
+ l.first_run_id,
39
+ l.line_number,
40
+ ROW_NUMBER() OVER (
41
+ PARTITION BY r.dedup_key
42
+ ORDER BY r.output_tokens DESC, p.is_sidechain ASC,
43
+ -- Among lines tied on output, the earliest: when those numbers first existed
44
+ -- (D-065, the same rule D-045 applies to readings). NULLs last, so an unparsed
45
+ -- timestamp never wins.
46
+ (p.timestamp_utc IS NULL) ASC, p.timestamp_utc ASC,
47
+ f.root DESC, f.relative_path DESC, l.first_run_id DESC, l.line_number DESC
48
+ ) AS candidate_rank
49
+ FROM requests r
50
+ JOIN parsed_lines p ON p.raw_line_id = r.raw_line_id
51
+ JOIN raw_lines l ON l.id = r.raw_line_id
52
+ JOIN source_files f ON f.id = l.source_file_id
53
+ )
54
+ SELECT
55
+ raw_line_id, dedup_key, message_id, request_id, model,
56
+ input_tokens, output_tokens, cache_read_tokens,
57
+ cache_write_5m_tokens, cache_write_1h_tokens, cache_write_unsplit_tokens, speed,
58
+ session_id, timestamp_raw, timestamp_utc, cwd, is_sidechain,
59
+ root, relative_path, first_run_id, line_number
60
+ FROM ranked
61
+ WHERE dedup_key IS NULL OR candidate_rank = 1;
62
+
63
+ CREATE TABLE requests_dedup (
64
+ raw_line_id ANY, -- derived: requests_dedup_computed.raw_line_id
65
+ dedup_key ANY, -- derived: requests_dedup_computed.dedup_key
66
+ message_id ANY, -- derived: requests_dedup_computed.message_id
67
+ request_id ANY, -- derived: requests_dedup_computed.request_id
68
+ model ANY, -- derived: requests_dedup_computed.model
69
+ input_tokens ANY, -- derived: requests_dedup_computed.input_tokens
70
+ output_tokens ANY, -- derived: requests_dedup_computed.output_tokens
71
+ cache_read_tokens ANY, -- derived: requests_dedup_computed.cache_read_tokens
72
+ cache_write_5m_tokens ANY, -- derived: requests_dedup_computed.cache_write_5m_tokens
73
+ cache_write_1h_tokens ANY, -- derived: requests_dedup_computed.cache_write_1h_tokens
74
+ cache_write_unsplit_tokens ANY, -- derived: requests_dedup_computed.cache_write_unsplit_tokens
75
+ speed ANY, -- derived: requests_dedup_computed.speed
76
+ session_id ANY, -- derived: requests_dedup_computed.session_id
77
+ timestamp_raw ANY, -- derived: requests_dedup_computed.timestamp_raw
78
+ timestamp_utc ANY, -- derived: requests_dedup_computed.timestamp_utc
79
+ cwd ANY, -- derived: requests_dedup_computed.cwd
80
+ is_sidechain ANY, -- derived: requests_dedup_computed.is_sidechain
81
+ root ANY, -- derived: requests_dedup_computed.root
82
+ relative_path ANY, -- derived: requests_dedup_computed.relative_path
83
+ first_run_id ANY, -- derived: requests_dedup_computed.first_run_id
84
+ line_number ANY -- derived: requests_dedup_computed.line_number
85
+ ) STRICT;
86
+
87
+ CREATE INDEX requests_dedup_raw_line_id ON requests_dedup (raw_line_id);
88
+ CREATE INDEX requests_dedup_dedup_key ON requests_dedup (dedup_key);
89
+ CREATE INDEX requests_dedup_time ON requests_dedup (timestamp_utc);
90
+ CREATE INDEX requests_dedup_session_time ON requests_dedup (session_id, timestamp_utc);
91
+ CREATE INDEX requests_dedup_cwd ON requests_dedup (cwd);
92
+
93
+ INSERT INTO requests_dedup (raw_line_id, dedup_key, message_id, request_id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_5m_tokens, cache_write_1h_tokens, cache_write_unsplit_tokens, speed, session_id, timestamp_raw, timestamp_utc, cwd, is_sidechain, root, relative_path, first_run_id, line_number)
94
+ SELECT raw_line_id, dedup_key, message_id, request_id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_5m_tokens, cache_write_1h_tokens, cache_write_unsplit_tokens, speed, session_id, timestamp_raw, timestamp_utc, cwd, is_sidechain, root, relative_path, first_run_id, line_number FROM requests_dedup_computed;
95
+
96
+ DROP VIEW window_readings;
97
+
98
+ -- Observed: one row per observation of a plan window, as 028 computed it; ingest stores its rows in window_readings (D-096). As in 026 (D-044, D-045, D-086), with stale = 1 when a counted reading is more than 1 point below an earlier counted one in its window (D-089).
99
+ CREATE VIEW window_readings_computed AS
100
+ WITH readings AS (
101
+ SELECT
102
+ w.raw_line_id, w.window, w.resets_at, w.used_percentage, s.session_id, s.captured_at_s,
103
+ -- The response behind the latest request line in this session, inside this window, at or before
104
+ -- the capture (+1 s for whole-second captures). An unkeyed line is its own response.
105
+ (SELECT COALESCE(rq.dedup_key, 'line:' || rq.raw_line_id)
106
+ FROM parsed_lines p
107
+ JOIN requests rq ON rq.raw_line_id = p.raw_line_id
108
+ WHERE p.session_id = s.session_id AND p.class = 'request'
109
+ AND p.timestamp_utc > strftime('%Y-%m-%dT%H:%M:%fZ',
110
+ w.resets_at - CASE w.window WHEN 'five_hour' THEN 5 * 3600 ELSE 7 * 86400 END, 'unixepoch')
111
+ AND p.timestamp_utc < strftime('%Y-%m-%dT%H:%M:%fZ', s.captured_at_s + 1, 'unixepoch')
112
+ ORDER BY p.timestamp_utc DESC, p.raw_line_id DESC
113
+ LIMIT 1) AS response_key
114
+ FROM rate_limit_windows w
115
+ JOIN status_readings s ON s.raw_line_id = w.raw_line_id
116
+ WHERE s.status = 'ok' AND w.validity = 'valid' AND w.window IN ('five_hour', 'seven_day')
117
+ ),
118
+ dated AS (
119
+ SELECT
120
+ r.*,
121
+ -- The response's first line: when its numbers first existed.
122
+ (SELECT MIN(p.timestamp_utc)
123
+ FROM requests rq
124
+ JOIN parsed_lines p ON p.raw_line_id = rq.raw_line_id
125
+ WHERE (r.response_key LIKE 'line:%' AND rq.raw_line_id = CAST(substr(r.response_key, 6) AS INTEGER))
126
+ OR rq.dedup_key = r.response_key) AS response_first_utc,
127
+ -- The previous capture of the same response, in capture order.
128
+ LAG(r.used_percentage) OVER captures AS previous_used_percentage
129
+ FROM readings r
130
+ -- A capture-time fallback is its own group: NULL response keys must not merge (D-044).
131
+ WINDOW captures AS (
132
+ PARTITION BY r.window, r.resets_at, r.session_id, r.response_key,
133
+ CASE WHEN r.response_key IS NULL THEN r.raw_line_id END
134
+ ORDER BY r.captured_at_s, r.raw_line_id
135
+ )
136
+ ),
137
+ runs AS (
138
+ SELECT
139
+ d.*,
140
+ -- A new run starts at the first capture and at every change of more than 0.01 points: payloads
141
+ -- carry computed values such as 7.000000000000001, which are the same number (D-044, D-086).
142
+ SUM(CASE WHEN d.previous_used_percentage IS NULL
143
+ OR abs(d.used_percentage - d.previous_used_percentage) > 0.01 THEN 1 ELSE 0 END)
144
+ OVER (
145
+ PARTITION BY d.window, d.resets_at, d.session_id, d.response_key,
146
+ CASE WHEN d.response_key IS NULL THEN d.raw_line_id END
147
+ ORDER BY d.captured_at_s, d.raw_line_id
148
+ ROWS UNBOUNDED PRECEDING
149
+ ) AS run
150
+ FROM dated d
151
+ ),
152
+ observed AS (
153
+ SELECT
154
+ u.*,
155
+ -- A later run's value didn't come from the response, so it's dated when it was first seen.
156
+ CASE
157
+ WHEN u.response_first_utc IS NULL THEN strftime('%Y-%m-%dT%H:%M:%fZ', u.captured_at_s, 'unixepoch')
158
+ WHEN u.run = 1 THEN u.response_first_utc
159
+ ELSE strftime('%Y-%m-%dT%H:%M:%fZ', MIN(u.captured_at_s) OVER run_captures, 'unixepoch')
160
+ END AS observed_at_utc,
161
+ ROW_NUMBER() OVER run_captures_latest AS copy_rank
162
+ FROM runs u
163
+ WINDOW
164
+ run_captures AS (
165
+ PARTITION BY u.window, u.resets_at, u.session_id, u.response_key,
166
+ CASE WHEN u.response_key IS NULL THEN u.raw_line_id END, u.run
167
+ ),
168
+ run_captures_latest AS (
169
+ PARTITION BY u.window, u.resets_at, u.session_id, u.response_key,
170
+ CASE WHEN u.response_key IS NULL THEN u.raw_line_id END, u.run
171
+ ORDER BY u.captured_at_s DESC, u.raw_line_id DESC
172
+ )
173
+ ),
174
+ counted AS (
175
+ SELECT
176
+ raw_line_id,
177
+ window,
178
+ resets_at,
179
+ strftime('%Y-%m-%dT%H:%M:%fZ', resets_at, 'unixepoch') AS reset_at_utc,
180
+ used_percentage,
181
+ session_id,
182
+ captured_at_s,
183
+ strftime('%Y-%m-%dT%H:%M:%fZ', captured_at_s, 'unixepoch') AS captured_at_utc,
184
+ observed_at_utc,
185
+ unixepoch(observed_at_utc, 'subsec') AS observed_at_s,
186
+ CASE
187
+ WHEN response_key IS NULL THEN 'capture'
188
+ WHEN run = 1 THEN 'last_request'
189
+ ELSE 'changed_capture'
190
+ END AS observed_via,
191
+ unixepoch(observed_at_utc, 'subsec') > resets_at AS after_reset
192
+ FROM observed
193
+ WHERE copy_rank = 1
194
+ )
195
+ SELECT
196
+ c.*,
197
+ -- Usage within one window only rises, so a counted reading more than 1 point below an earlier one
198
+ -- in the same window, from any session, carries a number from before that earlier reading (D-089).
199
+ CASE
200
+ WHEN c.after_reset = 0 AND c.used_percentage < MAX(c.used_percentage) OVER (
201
+ PARTITION BY c.window, c.resets_at ORDER BY c.observed_at_s, c.raw_line_id
202
+ ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
203
+ ) - 1 THEN 1
204
+ ELSE 0
205
+ END AS stale
206
+ FROM counted c;
207
+
208
+ CREATE TABLE window_readings (
209
+ raw_line_id ANY, -- derived: window_readings_computed.raw_line_id
210
+ window ANY, -- derived: window_readings_computed.window
211
+ resets_at ANY, -- derived: window_readings_computed.resets_at
212
+ reset_at_utc ANY, -- derived: window_readings_computed.reset_at_utc
213
+ used_percentage ANY, -- derived: window_readings_computed.used_percentage
214
+ session_id ANY, -- derived: window_readings_computed.session_id
215
+ captured_at_s ANY, -- derived: window_readings_computed.captured_at_s
216
+ captured_at_utc ANY, -- derived: window_readings_computed.captured_at_utc
217
+ observed_at_utc ANY, -- derived: window_readings_computed.observed_at_utc
218
+ observed_at_s ANY, -- derived: window_readings_computed.observed_at_s
219
+ observed_via ANY, -- derived: window_readings_computed.observed_via
220
+ after_reset ANY, -- derived: window_readings_computed.after_reset
221
+ stale ANY -- derived: window_readings_computed.stale
222
+ ) STRICT;
223
+
224
+ CREATE INDEX window_readings_raw_line ON window_readings (raw_line_id);
225
+ CREATE INDEX window_readings_instance ON window_readings (window, reset_at_utc, observed_at_s);
226
+ CREATE INDEX window_readings_session_time ON window_readings (session_id, observed_at_s);
227
+ CREATE INDEX window_readings_time ON window_readings (observed_at_utc);
228
+
229
+ INSERT INTO window_readings (raw_line_id, window, resets_at, reset_at_utc, used_percentage, session_id, captured_at_s, captured_at_utc, observed_at_utc, observed_at_s, observed_via, after_reset, stale)
230
+ SELECT raw_line_id, window, resets_at, reset_at_utc, used_percentage, session_id, captured_at_s, captured_at_utc, observed_at_utc, observed_at_s, observed_via, after_reset, stale FROM window_readings_computed;
231
+
232
+ -- Statistics for the query planner, so the first report after this migration is fast too (D-096).
233
+ ANALYZE;
File without changes
@@ -7,6 +7,7 @@
7
7
  * calls it, and turns outcomes into words. All wording here follows CLAUDE.md "Wording is part of
8
8
  * correctness": messages state what happened, never advice or judgment.
9
9
  */
10
+ import { randomBytes } from "node:crypto";
10
11
  import { dirname } from "node:path";
11
12
  import { Command, CommanderError } from "commander";
12
13
  import { transaction } from "../core/db/database.js";
@@ -21,6 +22,7 @@ import { runInit, runUninstall, } from "../core/install/install.js";
21
22
  import { SettingsError, locateSettings } from "../core/settings/settings-file.js";
22
23
  import { CCUSAGE_VERSION, runCcusage, runVerify, } from "../core/verify/verify.js";
23
24
  import { openPage } from "../core/util/open-page.js";
25
+ import { listenView } from "../core/view/server.js";
24
26
  import { RepositoryAliasError, listAliases, normalizeRepositoryPath, removeAlias, setAlias, validateAlias, } from "../core/plans/repository-aliases.js";
25
27
  import { METRIC_NAMES, UnknownMetricError, explain, renderExplanation } from "../viewer/explain.js";
26
28
  import { formatNumber } from "../viewer/format.js";
@@ -28,11 +30,25 @@ import { NoDatabaseError, readReport, summarizeStoredData, withReportDatabase, }
28
30
  import { loadHtmlData } from "../viewer/html-data.js";
29
31
  import { renderHtmlReport } from "../viewer/html.js";
30
32
  import { saveReport } from "../viewer/save.js";
31
- import { displayPath, plural, renderReport, reportJson } from "../viewer/render.js";
33
+ import { displayPath, plural, renderReport, reportJson, } from "../viewer/render.js";
32
34
  /** The ccusage release `verify` compares against; pinned so two runs compare the same way. */
33
35
  const CCUSAGE_PIN = CCUSAGE_VERSION;
34
36
  /** Readings need a terminal session (D-029); said at install so empty usage windows aren't a surprise. */
35
37
  export const TERMINAL_ONLY_NOTE = "Readings are recorded only when Claude Code runs in a terminal; the VS Code extension doesn't run the status line.";
38
+ /** Refusal for a `--port` that isn't a port number. */
39
+ export const BAD_PORT = "--port needs a whole number from 1 to 65535, or leave it out for any free port. Nothing was changed.";
40
+ /**
41
+ * Reads `--port`.
42
+ * @param value - The flag's text, if given.
43
+ * @returns The port, 0 for any free one, or undefined when the text isn't a port.
44
+ */
45
+ export function parsePort(value) {
46
+ if (value === undefined) {
47
+ return 0;
48
+ }
49
+ const port = /^\d{1,5}$/.test(value) ? Number(value) : NaN;
50
+ return port >= 1 && port <= 65_535 ? port : undefined;
51
+ }
36
52
  /**
37
53
  * Refusal for `--open` without both `--save` and `--html`: only a saved page exists to open (D-082).
38
54
  * Checked first, so it names everything `--open` needs.
@@ -491,6 +507,55 @@ function openSaved(deps, htmlPath) {
491
507
  `Couldn't open the page (${opened.reason}). It's saved at ${displayPath(htmlPath, deps.home)}`,
492
508
  ];
493
509
  }
510
+ /**
511
+ * Reads the report, and the page when asked, from one snapshot of the database: one read
512
+ * transaction, so an ingest finishing between the two reads can't make them disagree (D-070: "the
513
+ * same views, so the two can't disagree"). Shared by `report` and `view` (D-097).
514
+ * @param deps - Program dependencies.
515
+ * @param flags - The command's flags.
516
+ * @param flags.dataDir - `--data-dir`, if given.
517
+ * @param reportedAt - The report's time: a window is open only until its reset, as of now (D-083).
518
+ * @param withPage - Whether to render the page too.
519
+ * @param serving - How `view` serves the page; omitted for a saved page.
520
+ * @returns The text report's input, and the page when asked.
521
+ */
522
+ function readSnapshot(deps, flags, reportedAt, withPage, serving) {
523
+ const reportOptions = {
524
+ ...toPlanDatabaseOptions(flags, deps),
525
+ timeZone: deps.timeZone,
526
+ reportedAt,
527
+ // Where init would look, so the warning checks the same file it would fix (D-081).
528
+ settingsPath: locateSettings({
529
+ home: deps.home,
530
+ claudeConfigDir: deps.env["CLAUDE_CONFIG_DIR"],
531
+ }),
532
+ };
533
+ return withReportDatabase(reportOptions, (db, databasePath) => transaction(db, () => {
534
+ const read = readReport(db, databasePath, reportOptions);
535
+ return {
536
+ input: read,
537
+ html: withPage
538
+ ? renderHtmlReport(
539
+ // Reuses what readReport just read from this snapshot, so the costliest views are
540
+ // computed once (D-070). The home directory shortens repository paths to ~/..., as
541
+ // in the text report.
542
+ loadHtmlData(db, {
543
+ timeZone: deps.timeZone,
544
+ generatedAt: reportedAt,
545
+ home: deps.home,
546
+ missingHookPath: read.missingHookPath,
547
+ }, read), serving)
548
+ : undefined,
549
+ };
550
+ }),
551
+ /**
552
+ * Says on stderr that data paths were made owner-only (D-043).
553
+ * @param tightened - Paths that were tightened.
554
+ */
555
+ (tightened) => {
556
+ noteTightened(deps, tightened);
557
+ });
558
+ }
494
559
  /**
495
560
  * Builds the command-line program.
496
561
  * @param deps - Program dependencies.
@@ -579,49 +644,11 @@ export function buildProgram(deps, setExitCode) {
579
644
  if (flags.html === true && flags.save !== true) {
580
645
  return { exitCode: 1, lines: [], errors: [HTML_NEEDS_SAVE] };
581
646
  }
582
- /**
583
- * Says on stderr that data paths were made owner-only.
584
- * @param tightened - Paths that were tightened.
585
- */
586
- const onTightened = (tightened) => {
587
- noteTightened(deps, tightened);
588
- };
589
647
  // One instant is the report's time (D-083: a window is open only until its reset, as
590
648
  // of now), names the saved files, and dates the page, so none of them can disagree.
591
649
  const reportedAt = deps.now();
592
- const reportOptions = {
593
- ...toPlanDatabaseOptions(flags, deps),
594
- timeZone: deps.timeZone,
595
- reportedAt,
596
- // Where init would look, so the warning checks the same file it would fix (D-081).
597
- settingsPath: locateSettings({
598
- home: deps.home,
599
- claudeConfigDir: deps.env["CLAUDE_CONFIG_DIR"],
600
- }),
601
- };
602
650
  const at = flags.save === true ? reportedAt : undefined;
603
- const { input, html } = withReportDatabase(reportOptions, (db, databasePath) =>
604
- // One read transaction for the text and the page: they come from one snapshot of the
605
- // database, so an ingest finishing between the two reads can't make them disagree
606
- // (D-070: "the same views, so the two can't disagree").
607
- transaction(db, () => {
608
- const read = readReport(db, databasePath, reportOptions);
609
- return {
610
- input: read,
611
- html: at !== undefined && flags.html === true
612
- ? renderHtmlReport(
613
- // Reuses what readReport just read from this snapshot, so the costliest
614
- // views are computed once (D-070).
615
- // The home directory shortens repository paths to ~/..., as in the text report.
616
- loadHtmlData(db, {
617
- timeZone: deps.timeZone,
618
- generatedAt: at,
619
- home: deps.home,
620
- missingHookPath: read.missingHookPath,
621
- }, read))
622
- : undefined,
623
- };
624
- }), onTightened);
651
+ const { input, html } = readSnapshot(deps, flags, reportedAt, at !== undefined && flags.html === true);
625
652
  const printed = flags.json === true ? JSON.stringify(reportJson(input), null, 2) : renderReport(input);
626
653
  if (at === undefined) {
627
654
  return { exitCode: 0, lines: [printed] };
@@ -643,6 +670,59 @@ export function buildProgram(deps, setExitCode) {
643
670
  return { exitCode: 0, lines: [printed, "", note], errors: notOpened };
644
671
  }));
645
672
  });
673
+ program
674
+ .command("view")
675
+ .description("Show the report page in your browser from your own computer (127.0.0.1), with a Refresh button that reads new log lines and rebuilds it. Runs until you press Ctrl+C.")
676
+ .option("--data-dir <path>", "data directory holding the database and spool")
677
+ .option("--port <number>", "port on 127.0.0.1 (default: any free port)")
678
+ .option("--no-open", "print the address without opening a browser")
679
+ .action((flags) => {
680
+ setExitCode(report(deps, () => {
681
+ const port = parsePort(flags.port);
682
+ if (port === undefined) {
683
+ return { exitCode: 1, lines: [], errors: [BAD_PORT] };
684
+ }
685
+ const token = (deps.viewToken ?? (() => randomBytes(24).toString("base64url")))();
686
+ const serving = { refreshPath: `/${token}/refresh` };
687
+ /**
688
+ * Ingests, then renders the page from what's stored now.
689
+ * @returns The page, and how many new lines the ingest stored.
690
+ */
691
+ const build = () => {
692
+ const outcome = runIngestCommand(toIngestOptions(flags, deps));
693
+ const { html } = readSnapshot(deps, flags, deps.now(), true, serving);
694
+ return { page: html ?? "", stored: outcome.run.linesStored };
695
+ };
696
+ let current = build();
697
+ (deps.serveView ?? listenView)({
698
+ token,
699
+ port,
700
+ page: () => current.page,
701
+ refresh: () => {
702
+ current = build();
703
+ return {
704
+ ok: true,
705
+ message: `${plural(current.stored, "new line", "new lines")} read`,
706
+ };
707
+ },
708
+ onListening: (url) => {
709
+ deps.print(`Showing the report at ${url}`);
710
+ deps.print("Refresh on the page reads new log lines and rebuilds it. Press Ctrl+C here to stop.");
711
+ if (flags.open !== false) {
712
+ const opened = (deps.openPage ?? openPage)(url);
713
+ if (!opened.opened) {
714
+ deps.printError(`Couldn't open a browser (${opened.reason}). Open the address above.`);
715
+ }
716
+ }
717
+ },
718
+ onError: (message) => {
719
+ deps.printError(`nilometer view stopped: ${message}`);
720
+ process.exitCode = 1;
721
+ },
722
+ });
723
+ return { exitCode: 0, lines: [] };
724
+ }));
725
+ });
646
726
  program
647
727
  .command("explain <metric>")
648
728
  .description(`List the events behind a number in the report, with where each was read from, and re-add them. Metrics: ${METRIC_NAMES.join(", ")}.`)
@@ -6,6 +6,7 @@
6
6
  * in SQL views over these tables.
7
7
  */
8
8
  import { blobBytes, transaction } from "../db/database.js";
9
+ import { refreshStoredViews } from "./stored-views.js";
9
10
  import { classifyLine, extractEvent, extractLinks, extractRequest, identityString, parseLine, parseTimestamp, } from "./classify.js";
10
11
  import { resolveResetTime } from "./reset-time.js";
11
12
  import { deriveNewSpoolLines } from "./spool.js";
@@ -198,15 +199,21 @@ export function rebuildDerived(db) {
198
199
  }
199
200
  /**
200
201
  * Brings derived tables up to date: a full rebuild if the parser version changed, otherwise only
201
- * the raw lines not yet derived.
202
+ * the raw lines not yet derived. Then refills the stored views when their inputs changed (D-096).
202
203
  * @param db - Open, migrated database.
203
204
  * @returns Number of raw lines derived and whether a rebuild happened.
204
205
  */
205
206
  export function ensureDerived(db) {
206
207
  const row = db.prepare("SELECT value FROM derive_meta WHERE key = 'parser_version'").get();
207
208
  if (row?.value !== PARSER_VERSION) {
208
- return { derived: rebuildDerived(db), rebuilt: true };
209
+ const derived = rebuildDerived(db);
210
+ // Every derived row was replaced, so the stored views are refilled even if the counts match (D-096).
211
+ refreshStoredViews(db, true);
212
+ return { derived, rebuilt: true };
209
213
  }
210
- return { derived: deriveNewLines(db) + deriveNewSpoolLines(db), rebuilt: false };
214
+ const derived = deriveNewLines(db) + deriveNewSpoolLines(db);
215
+ // After deriving, so the report's stored rows include this run's lines (D-096).
216
+ refreshStoredViews(db);
217
+ return { derived, rebuilt: false };
211
218
  }
212
219
  //# sourceMappingURL=derive.js.map
@@ -0,0 +1,65 @@
1
+ import { transaction } from "../db/database.js";
2
+ /** Each stored table and the view whose rows it holds. */
3
+ export const STORED_VIEWS = [
4
+ { table: "requests_dedup", view: "requests_dedup_computed" },
5
+ { table: "window_readings", view: "window_readings_computed" },
6
+ ];
7
+ /** Where the inputs' signature from the last refresh is kept, in `derive_meta`. */
8
+ const SIGNATURE_KEY = "stored_views_input";
9
+ /** Tables the computed views read, each written only by ingest. */
10
+ const INPUT_TABLES = [
11
+ "raw_lines",
12
+ "parsed_lines",
13
+ "requests",
14
+ "status_readings",
15
+ "rate_limit_windows",
16
+ ];
17
+ /**
18
+ * Describes the computed views' inputs cheaply enough to check on every ingest: each input table's
19
+ * row count and highest rowid, and every source file's root and path (requests_dedup carries them).
20
+ * Rows in those tables are only inserted, or all deleted and derived again, which a rebuild forces
21
+ * a refresh for anyway; a path changes only when Windows separators are normalized.
22
+ * @param db - Open, migrated database.
23
+ * @returns A string that changes whenever an input does.
24
+ */
25
+ export function inputSignature(db) {
26
+ const counts = INPUT_TABLES.map((table) => {
27
+ const row = db
28
+ .prepare(`SELECT COUNT(*) AS n, COALESCE(MAX(rowid), 0) AS top FROM ${table}`)
29
+ .get();
30
+ return `${table}:${row.n}:${row.top}`;
31
+ });
32
+ const paths = db
33
+ .prepare("SELECT COUNT(*) AS n, COALESCE(group_concat(id || '|' || root || '|' || relative_path, '\n'), '') AS list FROM (SELECT id, root, relative_path FROM source_files ORDER BY id)")
34
+ .get();
35
+ return [...counts, `source_files:${paths.n}:${paths.list}`].join("\n");
36
+ }
37
+ /**
38
+ * Refills each stored table from its view when an input changed since the last refresh.
39
+ * @param db - Open, migrated database.
40
+ * @param force - Refill even if the inputs look unchanged (after a full rebuild of derived rows).
41
+ * @returns Whether the tables were refilled.
42
+ */
43
+ export function refreshStoredViews(db, force = false) {
44
+ const signature = inputSignature(db);
45
+ const stored = db.prepare("SELECT value FROM derive_meta WHERE key = ?").get(SIGNATURE_KEY);
46
+ if (!force && stored?.value === signature) {
47
+ return false;
48
+ }
49
+ transaction(db, () => {
50
+ for (const { table, view } of STORED_VIEWS) {
51
+ // Named columns, in the table's order, so the copy can't depend on the view's column order.
52
+ const columns = db.prepare("SELECT name FROM pragma_table_info(?) ORDER BY cid").all(table)
53
+ .map((c) => c.name)
54
+ .join(", ");
55
+ db.exec(`DELETE FROM ${table}`);
56
+ db.exec(`INSERT INTO ${table} (${columns}) SELECT ${columns} FROM ${view}`);
57
+ }
58
+ db.prepare("INSERT INTO derive_meta (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value").run(SIGNATURE_KEY, signature);
59
+ });
60
+ // Table statistics for the query planner: on real data they halved the page's queries again, for
61
+ // about 0.4 s here. They change how a query runs, never what it returns.
62
+ db.exec("ANALYZE");
63
+ return true;
64
+ }
65
+ //# sourceMappingURL=stored-views.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
@@ -28,6 +28,13 @@ export const EVENT_LIST_LIMIT = 200;
28
28
  */
29
29
  export const HTML_LABELS = {
30
30
  privacy: "Privacy: this file holds session IDs and the paths of your session logs, like nilometer explain output, and it makes no network requests. Think before sharing it.",
31
+ // D-097 (step G6.2): the page as nilometer view serves it.
32
+ privacyServed: "Privacy: nilometer view shows this page from your own computer (127.0.0.1) while it runs. Refresh asks it to read new log lines and rebuild the page; nothing leaves this computer. The page holds session IDs and the paths of your session logs.",
33
+ refresh: "Refresh",
34
+ refreshHint: "Read new log lines and status line readings, then rebuild this page",
35
+ refreshing: "Reading new log lines and rebuilding the page",
36
+ refreshFailed: "Refresh failed",
37
+ refreshStopped: "Couldn't reach nilometer view. Is it still running in your terminal?",
31
38
  // 2026-10-22 click-through: "What each number covers" with two bars didn't say why they differ.
32
39
  coverage: "Where the numbers come from",
33
40
  coverageWhy: "Session logs give tokens, models and limit hits. Usage percentages come only from status line readings, so they start later: when the collector was installed.",
@@ -353,9 +360,10 @@ function legend(entries) {
353
360
  /**
354
361
  * Renders the header: title, zone, last ingest, and the privacy note (README section Privacy).
355
362
  * @param data - Report data.
363
+ * @param serving - How `nilometer view` serves the page; omitted for a saved page.
356
364
  * @returns The header HTML.
357
365
  */
358
- function renderHeader(data) {
366
+ function renderHeader(data, serving) {
359
367
  const zone = data.timeZone;
360
368
  const ingest = data.lastIngestAt === null ? "none yet" : formatInstant(data.lastIngestAt, zone);
361
369
  const meta = `Times are in ${zone}. Last ingest: ${ingest}. Report produced: ${formatInstant(data.generatedAtUtc, zone)}.`;
@@ -368,12 +376,22 @@ function renderHeader(data) {
368
376
  para(`${LABELS.hookMissingPath}: ${displayPath(data.missingHookPath, data.home)}`, "meta"),
369
377
  "</div>",
370
378
  ];
379
+ // Served by nilometer view: the button and where its progress shows (D-097).
380
+ const refresh = serving === undefined
381
+ ? []
382
+ : [
383
+ '<div class="nm-refresh">',
384
+ `<button type="button" id="nm-refresh" data-path="${escapeHtml(serving.refreshPath)}" title="${escapeHtml(HTML_LABELS.refreshHint)}">${escapeHtml(HTML_LABELS.refresh)}</button>`,
385
+ '<span id="nm-refresh-status" class="meta" role="status" aria-live="polite"></span>',
386
+ "</div>",
387
+ ];
371
388
  return [
372
389
  '<header class="nm-head">',
373
390
  `<h1>${escapeHtml(LABELS.title)}</h1>`,
374
391
  para(meta, "meta"),
392
+ ...refresh,
375
393
  ...hook,
376
- para(HTML_LABELS.privacy, "privacy"),
394
+ para(serving === undefined ? HTML_LABELS.privacy : HTML_LABELS.privacyServed, "privacy"),
377
395
  "</header>",
378
396
  ].join("\n");
379
397
  }
@@ -1282,6 +1300,10 @@ p { margin: 0; }
1282
1300
  .nm-head { display: grid; gap: 6px; }
1283
1301
  .meta, .span, .note { font-family: var(--mono); font-size: 12px; color: var(--muted); }
1284
1302
  .privacy { font-size: 13px; color: var(--ink-2); }
1303
+ .nm-refresh { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
1304
+ .nm-refresh button { font: inherit; font-size: 14px; font-weight: 600; color: var(--ink); background: var(--surface); border: 1px solid var(--ink-2); border-radius: 6px; padding: 6px 14px; cursor: pointer; }
1305
+ .nm-refresh button:disabled { cursor: progress; opacity: .6; }
1306
+ @media print { .nm-refresh { display: none; } }
1285
1307
  .hook-warning { display: grid; gap: 4px; border-left: 3px solid var(--ink); padding: 8px 12px; background: var(--surface); font-weight: 600; }
1286
1308
  .eyebrow { font-family: var(--mono); font-size: 12px; letter-spacing: 0.07em; text-transform: uppercase; color: var(--water); font-weight: 600; }
1287
1309
  .eyebrow.projected { color: var(--silt); }
@@ -1595,15 +1617,54 @@ if (typeof document !== "undefined") {
1595
1617
  })();
1596
1618
  }
1597
1619
  `;
1620
+ /**
1621
+ * The Refresh button's script, for a page `nilometer view` serves (D-097): it posts to the server,
1622
+ * counts the seconds while the server ingests and rebuilds, then reloads the same address, so the
1623
+ * tab in the address (#windows, #interruptions...) stays open.
1624
+ * @returns The script, with its wording from {@link HTML_LABELS}.
1625
+ */
1626
+ function refreshScript() {
1627
+ const text = JSON.stringify({
1628
+ refreshing: HTML_LABELS.refreshing,
1629
+ failed: HTML_LABELS.refreshFailed,
1630
+ stopped: HTML_LABELS.refreshStopped,
1631
+ }).replace(/</g, "\\u003c");
1632
+ return `
1633
+ "use strict";
1634
+ (function () {
1635
+ var TEXT = ${text};
1636
+ var button = document.getElementById("nm-refresh");
1637
+ var status = document.getElementById("nm-refresh-status");
1638
+ if (button === null || status === null) { return; }
1639
+ button.addEventListener("click", function () {
1640
+ var started = Date.now();
1641
+ button.disabled = true;
1642
+ function tick() { status.textContent = TEXT.refreshing + " (" + Math.round((Date.now() - started) / 1000) + " s)"; }
1643
+ tick();
1644
+ var timer = setInterval(tick, 1000);
1645
+ function stop(message) { clearInterval(timer); button.disabled = false; status.textContent = message; }
1646
+ fetch(button.getAttribute("data-path"), { method: "POST" })
1647
+ .then(function (response) {
1648
+ return response.json().then(function (body) {
1649
+ if (response.ok && body.ok) { clearInterval(timer); location.reload(); return; }
1650
+ stop(TEXT.failed + ": " + body.message);
1651
+ });
1652
+ })
1653
+ .catch(function () { stop(TEXT.stopped); });
1654
+ });
1655
+ })();
1656
+ `;
1657
+ }
1598
1658
  /**
1599
1659
  * Renders the whole page (step G1.3, D-070): header, coverage, the observed sections, the seam,
1600
1660
  * and the projected monthly section, in the text report's order, with the event table embedded for
1601
1661
  * the script and a table view under every chart.
1602
1662
  * @param data - Everything the report draws, from `loadHtmlData`.
1663
+ * @param serving - How `nilometer view` serves the page (D-097); omitted for a saved page.
1603
1664
  * @returns One self-contained, ASCII-only HTML document; never throws for well-formed data.
1604
1665
  * @see D-070
1605
1666
  */
1606
- function renderPage(data) {
1667
+ function renderPage(data, serving) {
1607
1668
  const options = { timeZone: data.timeZone, width: CHART_WIDTH };
1608
1669
  const embedded = embedJson({ timeZone: data.timeZone, files: data.files, events: data.events });
1609
1670
  return [
@@ -1617,7 +1678,7 @@ function renderPage(data) {
1617
1678
  "</head>",
1618
1679
  "<body>",
1619
1680
  "<main>",
1620
- renderHeader(data),
1681
+ renderHeader(data, serving),
1621
1682
  // Navigation only: its labels name the tabs, so the projected group's label sits above the
1622
1683
  // seam, while every projected panel stays below it in the document (principle 2, D-084).
1623
1684
  renderTabBar(),
@@ -1639,6 +1700,8 @@ function renderPage(data) {
1639
1700
  "</main>",
1640
1701
  `<script type="application/json" id="nm-data">${embedded}</script>`,
1641
1702
  `<script>${PAGE_SCRIPT}</script>`,
1703
+ // Only when served: a saved page makes no network requests (D-097).
1704
+ ...(serving === undefined ? [] : [`<script>${refreshScript()}</script>`]),
1642
1705
  "</body>",
1643
1706
  "</html>",
1644
1707
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nilometer",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Reports the observed cost of a Claude subscription: interruptions when the plan is too small, unused headroom when it is too big.",
5
5
  "license": "MIT",
6
6
  "author": "Mark Fariñas",