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,97 @@
1
+ /**
2
+ * @file Resolving a limit hit's reset text to a UTC instant (D-023).
3
+ *
4
+ * Claude Code writes the reset time as wall-clock text in a named zone: "6:10am (<IANA zone>)".
5
+ * The text has no date, so the instant is the first matching wall-clock minute at or after the hit.
6
+ * Scanning whole UTC minutes, rather than converting local time to UTC arithmetically, gives one
7
+ * answer at DST changes without special cases: a repeated local time matches its first occurrence,
8
+ * and a skipped local time never matches that day, so the next day's occurrence is found.
9
+ */
10
+ /** Reset text this module understands: `H[:MM]am|pm (Zone)`, case-insensitive. */
11
+ const RESET_TEXT = /^(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(([^()]+)\)$/i;
12
+ /** How far ahead of the hit the scan looks. A time of day recurs within 24 hours; DST adds at most one more. */
13
+ export const RESET_SEARCH_MINUTES = 48 * 60;
14
+ /** One minute in milliseconds. */
15
+ const MINUTE_MS = 60_000;
16
+ /**
17
+ * Builds a formatter that reads the 24-hour wall-clock hour and minute in a zone.
18
+ * @param zone - IANA zone name as written in the log.
19
+ * @returns The formatter, or null when the runtime doesn't know the zone.
20
+ */
21
+ function wallClockFormatter(zone) {
22
+ try {
23
+ return new Intl.DateTimeFormat("en-US", {
24
+ timeZone: zone,
25
+ hourCycle: "h23",
26
+ hour: "2-digit",
27
+ minute: "2-digit",
28
+ });
29
+ }
30
+ catch {
31
+ // An unknown zone throws RangeError; the reset stays unknown rather than guessed.
32
+ return null;
33
+ }
34
+ }
35
+ /**
36
+ * Reads the wall-clock hour and minute of an instant.
37
+ * @param formatter - From {@link wallClockFormatter}.
38
+ * @param ms - Epoch milliseconds.
39
+ * @returns Hour 0–23 and minute 0–59 as the zone shows them.
40
+ */
41
+ function wallClock(formatter, ms) {
42
+ let hour = -1;
43
+ let minute = -1;
44
+ for (const part of formatter.formatToParts(ms)) {
45
+ if (part.type === "hour") {
46
+ hour = Number(part.value);
47
+ }
48
+ else if (part.type === "minute") {
49
+ minute = Number(part.value);
50
+ }
51
+ }
52
+ return { hour, minute };
53
+ }
54
+ /**
55
+ * Resolves reset text to the UTC instant it names.
56
+ * @param resetText - The limit hit's reset text (`events.reset_text`), or null.
57
+ * @param hitUtc - The hit's UTC timestamp (`YYYY-MM-DDTHH:MM:SS.sssZ`), or null when it didn't parse.
58
+ * @returns `YYYY-MM-DDTHH:MM:00.000Z`, or null when either input is missing, the hit time doesn't
59
+ * parse, the text isn't a 12-hour time with a zone, the hour or minute is out of range, the zone
60
+ * is unknown, or no match is found within {@link RESET_SEARCH_MINUTES}.
61
+ * @example
62
+ * resolveResetTime("6:10am (UTC)", "2026-09-01T03:00:00.000Z"); // "2026-09-01T06:10:00.000Z"
63
+ */
64
+ export function resolveResetTime(resetText, hitUtc) {
65
+ if (resetText === null || hitUtc === null) {
66
+ return null;
67
+ }
68
+ const match = RESET_TEXT.exec(resetText.trim());
69
+ if (match === null) {
70
+ return null;
71
+ }
72
+ const hour12 = Number(match[1]);
73
+ const minute = match[2] === undefined ? 0 : Number(match[2]);
74
+ if (hour12 < 1 || hour12 > 12 || minute > 59) {
75
+ return null;
76
+ }
77
+ const formatter = wallClockFormatter(match[4].trim());
78
+ if (formatter === null) {
79
+ return null;
80
+ }
81
+ // 12am is hour 0 and 12pm is hour 12; every other pm hour adds 12.
82
+ const hour = (hour12 % 12) + (match[3].toLowerCase() === "pm" ? 12 : 0);
83
+ // Start at the hit's own minute: text shows minutes only, so a reset in the hit's minute is that minute.
84
+ const start = Math.floor(Date.parse(hitUtc) / MINUTE_MS) * MINUTE_MS;
85
+ if (Number.isNaN(start)) {
86
+ return null;
87
+ }
88
+ for (let step = 0; step <= RESET_SEARCH_MINUTES; step += 1) {
89
+ const ms = start + step * MINUTE_MS;
90
+ const clock = wallClock(formatter, ms);
91
+ if (clock.hour === hour && clock.minute === minute) {
92
+ return new Date(ms).toISOString();
93
+ }
94
+ }
95
+ return null;
96
+ }
97
+ //# sourceMappingURL=reset-time.js.map
@@ -0,0 +1,201 @@
1
+ /** Every line class, so the report lists zero counts too. */
2
+ const CLASSES = [
3
+ "malformed",
4
+ "limit_hit",
5
+ "api_error",
6
+ "synthetic_other",
7
+ "request",
8
+ "retry_notice",
9
+ "model_fallback",
10
+ "ignored_type",
11
+ ];
12
+ /** SQL selecting the seven total columns for a group; NULL cache writes count as 0. */
13
+ const TOTALS_COLUMNS = `
14
+ COUNT(*) AS requests,
15
+ SUM(input_tokens) AS input_tokens,
16
+ SUM(output_tokens) AS output_tokens,
17
+ SUM(cache_read_tokens) AS cache_read_tokens,
18
+ COALESCE(SUM(cache_write_5m_tokens), 0) AS cache_write_5m_tokens,
19
+ COALESCE(SUM(cache_write_1h_tokens), 0) AS cache_write_1h_tokens,
20
+ COALESCE(SUM(cache_write_unsplit_tokens), 0) AS cache_write_unsplit_tokens`;
21
+ /** SQL joining a derived row to its source position. `x` must alias a table with raw_line_id. */
22
+ const POSITION_JOIN = `
23
+ JOIN raw_lines l ON l.id = x.raw_line_id
24
+ JOIN source_files f ON f.id = l.source_file_id`;
25
+ /** SQL ordering by (file, first run, line), the canonical order. */
26
+ const CANONICAL_ORDER = "ORDER BY f.root, f.relative_path, l.first_run_id, l.line_number";
27
+ /**
28
+ * Parses a stored raw timestamp back to its JSON value.
29
+ * @param text - JSON text, or null when the line had no timestamp.
30
+ * @returns The original value; null when absent.
31
+ */
32
+ export function rawTimestamp(text) {
33
+ return text === null ? null : JSON.parse(text);
34
+ }
35
+ /**
36
+ * Removes the totals columns' group key and returns the totals object.
37
+ * @param row - A row with the seven totals columns.
38
+ * @returns Totals in the documented field order.
39
+ */
40
+ export function toTotals(row) {
41
+ return {
42
+ requests: row.requests,
43
+ input_tokens: row.input_tokens,
44
+ output_tokens: row.output_tokens,
45
+ cache_read_tokens: row.cache_read_tokens,
46
+ cache_write_5m_tokens: row.cache_write_5m_tokens,
47
+ cache_write_1h_tokens: row.cache_write_1h_tokens,
48
+ cache_write_unsplit_tokens: row.cache_write_unsplit_tokens,
49
+ };
50
+ }
51
+ /**
52
+ * Reads the full result for everything ingested so far.
53
+ * @param db - Open database with derived tables up to date.
54
+ * @returns Requests, totals, events, and report in the fixture shape.
55
+ */
56
+ export function readResult(db) {
57
+ const requests = db
58
+ .prepare("SELECT * FROM requests_dedup ORDER BY root, relative_path, first_run_id, line_number")
59
+ .all().map((row) => ({
60
+ dedup_key: row["dedup_key"],
61
+ file: row["relative_path"],
62
+ line: row["line_number"],
63
+ session_id: row["session_id"],
64
+ message_id: row["message_id"],
65
+ request_id: row["request_id"],
66
+ model: row["model"],
67
+ timestamp: rawTimestamp(row.timestamp_raw),
68
+ is_sidechain: row.is_sidechain === 1,
69
+ cwd: row["cwd"],
70
+ input_tokens: row["input_tokens"],
71
+ output_tokens: row["output_tokens"],
72
+ cache_read_tokens: row["cache_read_tokens"],
73
+ cache_write_5m_tokens: row["cache_write_5m_tokens"],
74
+ cache_write_1h_tokens: row["cache_write_1h_tokens"],
75
+ cache_write_unsplit_tokens: row["cache_write_unsplit_tokens"],
76
+ }));
77
+ const totalsByModel = {};
78
+ for (const row of db
79
+ .prepare(`SELECT model, ${TOTALS_COLUMNS} FROM requests_dedup GROUP BY model ORDER BY model`)
80
+ .all()) {
81
+ totalsByModel[row.model] = toTotals(row);
82
+ }
83
+ const totalsByDay = {};
84
+ for (const row of db
85
+ .prepare(`SELECT substr(timestamp_utc, 1, 10) AS day, model, ${TOTALS_COLUMNS}
86
+ FROM requests_dedup WHERE timestamp_utc IS NOT NULL
87
+ GROUP BY day, model ORDER BY day, model`)
88
+ .all()) {
89
+ (totalsByDay[row.day] ??= {})[row.model] = toTotals(row);
90
+ }
91
+ const events = db
92
+ .prepare(`SELECT x.class, p.session_id, p.timestamp_raw, f.relative_path, l.line_number,
93
+ x.error, x.api_error_status, x.window, x.reset_text,
94
+ x.quota_window, x.quota_resets_at_utc,
95
+ x.fallback_original_model, x.fallback_model, x.fallback_request_id
96
+ FROM events x JOIN parsed_lines p ON p.raw_line_id = x.raw_line_id ${POSITION_JOIN}
97
+ ${CANONICAL_ORDER}`)
98
+ .all().map((row) => ({
99
+ class: row.class,
100
+ session_id: row.session_id,
101
+ timestamp: rawTimestamp(row.timestamp_raw),
102
+ file: row.relative_path,
103
+ line: row.line_number,
104
+ error: row.error,
105
+ api_error_status: row.api_error_status,
106
+ window: row.window,
107
+ reset_text: row.reset_text,
108
+ quota_window: row.quota_window,
109
+ quota_resets_at: row.quota_resets_at_utc,
110
+ // The refusal category is stored but never shown (D-080), so it isn't part of this output.
111
+ fallback_original_model: row.fallback_original_model,
112
+ fallback_model: row.fallback_model,
113
+ fallback_request_id: row.fallback_request_id,
114
+ }));
115
+ return {
116
+ requests,
117
+ totals_by_model: totalsByModel,
118
+ totals_by_day_utc: totalsByDay,
119
+ events,
120
+ report: readReport(db),
121
+ };
122
+ }
123
+ /**
124
+ * Reads the report section.
125
+ * @param db - Open database with derived tables up to date.
126
+ * @returns Line counts by class and every reported problem, in canonical order.
127
+ */
128
+ export function readReport(db) {
129
+ const lines = Object.fromEntries(CLASSES.map((name) => [name, 0]));
130
+ for (const row of db
131
+ .prepare("SELECT class, COUNT(*) AS n FROM parsed_lines GROUP BY class")
132
+ .all()) {
133
+ lines[row.class] = row.n;
134
+ }
135
+ const malformed = db
136
+ .prepare(`SELECT f.relative_path AS file, l.line_number AS line
137
+ FROM parsed_lines x ${POSITION_JOIN} WHERE x.class = 'malformed' ${CANONICAL_ORDER}`)
138
+ .all();
139
+ const ignoredTypes = {};
140
+ for (const row of db
141
+ .prepare(`SELECT CASE WHEN subtype IS NULL THEN type ELSE type || '/' || subtype END AS label,
142
+ COUNT(*) AS n
143
+ FROM parsed_lines WHERE class = 'ignored_type' GROUP BY label ORDER BY label`)
144
+ .all()) {
145
+ ignoredTypes[row.label] = row.n;
146
+ }
147
+ const unknownErrors = {};
148
+ for (const row of db
149
+ .prepare(`SELECT unknown_error_key AS label, COUNT(*) AS n FROM events
150
+ WHERE unknown_error_key IS NOT NULL GROUP BY label ORDER BY label`)
151
+ .all()) {
152
+ unknownErrors[row.label] = row.n;
153
+ }
154
+ /**
155
+ * Lists one kind of line problem in canonical order.
156
+ * @param problem - The problem code.
157
+ * @returns File, line, and detail for each occurrence.
158
+ */
159
+ const problems = (problem) => db
160
+ .prepare(`SELECT f.relative_path AS file, l.line_number AS line, x.detail
161
+ FROM line_problems x ${POSITION_JOIN} WHERE x.problem = ? ${CANONICAL_ORDER}, x.id`)
162
+ .all(problem);
163
+ const counts = db
164
+ .prepare(`SELECT (SELECT COUNT(*) FROM requests WHERE dedup_key IS NULL) AS unkeyed,
165
+ (SELECT COALESCE(SUM(retry_rate_limits_present), 0) FROM events) AS retry_rate_limits`)
166
+ .get();
167
+ return {
168
+ lines,
169
+ malformed,
170
+ ignored_types: ignoredTypes,
171
+ unkeyed_requests: counts.unkeyed,
172
+ unknown_error_values: unknownErrors,
173
+ missing_fields: problems("missing_field").map(({ file, line, detail }) => ({
174
+ file,
175
+ line,
176
+ field: detail,
177
+ })),
178
+ unparsed_timestamps: problems("unparsed_timestamp").map(({ file, line, detail }) => ({
179
+ file,
180
+ line,
181
+ raw: JSON.parse(detail),
182
+ })),
183
+ non_message_iterations: problems("non_message_iteration").map(({ file, line, detail }) => ({
184
+ file,
185
+ line,
186
+ type: detail,
187
+ })),
188
+ retry_rate_limits_present: counts.retry_rate_limits,
189
+ unusable_quota_fields: problems("unusable_quota_field").map(({ file, line, detail }) => ({
190
+ file,
191
+ line,
192
+ field: detail,
193
+ })),
194
+ // The detail holds both windows for the database's own report; the fixture shape is the line.
195
+ quota_window_disagreements: problems("quota_window_disagrees").map(({ file, line }) => ({
196
+ file,
197
+ line,
198
+ })),
199
+ };
200
+ }
201
+ //# sourceMappingURL=results.js.map
@@ -0,0 +1,165 @@
1
+ /**
2
+ * @file Decoding and validating status line spool lines (docs/development.md P4.7, D-008, D-018).
3
+ *
4
+ * The hook writes `{"captured_at_s":N,"hook_version":N,"payload_b64":"..."}` per turn without
5
+ * looking inside the payload. Every check happens here, and every problem is recorded rather than
6
+ * repaired (`statusline-collector` skill, "Ingesting the spool"): a malformed or interleaved line, a
7
+ * payload that isn't base64 or isn't JSON, and window values out of range. Monitor clamps
8
+ * percentages up to 101 down to 100; this module doesn't.
9
+ */
10
+ import { blobBytes, transaction } from "../db/database.js";
11
+ import { isNumber, isObject, objectField } from "./classify.js";
12
+ /** Rate-limit windows the payload is documented to carry. */
13
+ export const KNOWN_WINDOWS = ["five_hour", "seven_day", "spend_limit"];
14
+ /** Strict base64: standard alphabet, correct padding, length a multiple of four. */
15
+ const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
16
+ /**
17
+ * Decodes one spool line.
18
+ * @param bytes - The raw spool line.
19
+ * @returns The status and, as far as decoding got, the capture time, hook version, and payload.
20
+ */
21
+ export function decodeSpoolLine(bytes) {
22
+ let line;
23
+ try {
24
+ line = JSON.parse(bytes.toString("utf8"));
25
+ }
26
+ catch {
27
+ line = null;
28
+ }
29
+ // Lines from concurrent sessions can interleave (D-018); they fail here and are reported.
30
+ if (!isObject(line) ||
31
+ !isNumber(line["captured_at_s"]) ||
32
+ !isNumber(line["hook_version"]) ||
33
+ typeof line["payload_b64"] !== "string") {
34
+ return { status: "malformed_line", capturedAtS: null, hookVersion: null, payload: null };
35
+ }
36
+ const header = { capturedAtS: line["captured_at_s"], hookVersion: line["hook_version"] };
37
+ const encoded = line["payload_b64"];
38
+ // Buffer.from(..., "base64") silently skips invalid characters, so validate first.
39
+ if (!BASE64.test(encoded)) {
40
+ return { status: "malformed_payload_encoding", ...header, payload: null };
41
+ }
42
+ let payload;
43
+ try {
44
+ payload = JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
45
+ }
46
+ catch {
47
+ payload = null;
48
+ }
49
+ if (!isObject(payload)) {
50
+ return { status: "malformed_payload", ...header, payload: null };
51
+ }
52
+ return { status: "ok", ...header, payload };
53
+ }
54
+ /** Epoch seconds below this are implausible for a reset time (2001-09-09). */
55
+ export const MIN_EPOCH_SECONDS = 1_000_000_000;
56
+ /** Epoch seconds at or above this are implausible for a reset time (2286-11-20). */
57
+ export const MAX_EPOCH_SECONDS = 10_000_000_000;
58
+ /**
59
+ * Validates one window's values.
60
+ * @param window - The window name.
61
+ * @param value - The value under `rate_limits.<window>`.
62
+ * @returns The values as written with a validity label.
63
+ */
64
+ export function validateWindow(window, value) {
65
+ const percentage = value["used_percentage"];
66
+ const resets = value["resets_at"];
67
+ const usedPercentage = isNumber(percentage) ? percentage : null;
68
+ const resetsAt = isNumber(resets) ? resets : null;
69
+ /**
70
+ * Builds the reading with a validity label.
71
+ * @param validity - The label.
72
+ * @returns The window reading.
73
+ */
74
+ const reading = (validity) => ({
75
+ window,
76
+ usedPercentage,
77
+ resetsAt,
78
+ validity,
79
+ });
80
+ if (!KNOWN_WINDOWS.includes(window)) {
81
+ return reading("unknown_window");
82
+ }
83
+ if (usedPercentage === null) {
84
+ return reading("invalid_percentage");
85
+ }
86
+ // Claude Code bug #52326 (reported via Monitor): the reset epoch sometimes lands in the percentage.
87
+ if (usedPercentage >= MIN_EPOCH_SECONDS) {
88
+ return reading("invalid_epoch_in_percentage");
89
+ }
90
+ // Above 100 is a real reading, not an error: Claude Code 2.1.281 reported a 5-hour window just
91
+ // above 100% while it was over its limit (2026-09-24, D-068), as the docs already said a spend
92
+ // limit can. Only a negative value is out of range; the epoch check above catches the one known
93
+ // corruption, and nothing is clamped.
94
+ if (usedPercentage < 0) {
95
+ return reading("invalid_percentage");
96
+ }
97
+ if (resetsAt === null || resetsAt < MIN_EPOCH_SECONDS || resetsAt >= MAX_EPOCH_SECONDS) {
98
+ return reading("invalid_resets_at");
99
+ }
100
+ return reading("valid");
101
+ }
102
+ /**
103
+ * Returns a value when it's a string, else null.
104
+ * @param value - Any parsed JSON value.
105
+ * @returns The string or null.
106
+ */
107
+ function stringOrNull(value) {
108
+ return typeof value === "string" ? value : null;
109
+ }
110
+ /**
111
+ * Derives the reading and its windows from one spool line. Pure: no database access.
112
+ * @param bytes - The raw spool line.
113
+ * @returns The reading row and one window row per object-valued key under `rate_limits`.
114
+ */
115
+ export function deriveSpoolLine(bytes) {
116
+ const decoded = decodeSpoolLine(bytes);
117
+ const payload = decoded.payload ?? {};
118
+ const rateLimits = objectField(payload, "rate_limits");
119
+ const cost = objectField(payload, "cost")?.["total_cost_usd"];
120
+ return {
121
+ reading: {
122
+ status: decoded.status,
123
+ capturedAtS: decoded.capturedAtS,
124
+ hookVersion: decoded.hookVersion,
125
+ sessionId: stringOrNull(payload["session_id"]),
126
+ transcriptPath: stringOrNull(payload["transcript_path"]),
127
+ modelId: stringOrNull(objectField(payload, "model")?.["id"]),
128
+ ccVersion: stringOrNull(payload["version"]),
129
+ costTotalUsd: isNumber(cost) ? cost : null,
130
+ hasRateLimits: rateLimits !== null,
131
+ },
132
+ // A missing window means it reset or isn't reported: no row, and no carried-forward value.
133
+ windows: rateLimits === null
134
+ ? []
135
+ : Object.entries(rateLimits)
136
+ .filter((entry) => isObject(entry[1]))
137
+ .map(([window, value]) => validateWindow(window, value)),
138
+ };
139
+ }
140
+ /**
141
+ * Derives and stores readings for every spool raw line that has none yet.
142
+ * @param db - Open, migrated database.
143
+ * @returns Number of spool lines derived.
144
+ */
145
+ export function deriveNewSpoolLines(db) {
146
+ const pending = db
147
+ .prepare(`SELECT l.id, l.bytes FROM raw_lines l
148
+ JOIN source_files f ON f.id = l.source_file_id AND f.kind = 'spool'
149
+ LEFT JOIN status_readings s ON s.raw_line_id = l.id
150
+ WHERE s.raw_line_id IS NULL ORDER BY l.id`)
151
+ .all();
152
+ const insertReading = db.prepare("INSERT INTO status_readings (raw_line_id, status, captured_at_s, hook_version, session_id, transcript_path, model_id, cc_version, cost_total_usd, has_rate_limits) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
153
+ const insertWindow = db.prepare("INSERT INTO rate_limit_windows (raw_line_id, window, used_percentage, resets_at, validity) VALUES (?, ?, ?, ?, ?)");
154
+ transaction(db, () => {
155
+ for (const { id, bytes } of pending) {
156
+ const { reading: r, windows } = deriveSpoolLine(blobBytes(bytes));
157
+ insertReading.run(id, r.status, r.capturedAtS, r.hookVersion, r.sessionId, r.transcriptPath, r.modelId, r.ccVersion, r.costTotalUsd, r.hasRateLimits ? 1 : 0);
158
+ for (const w of windows) {
159
+ insertWindow.run(id, w.window, w.usedPercentage, w.resetsAt, w.validity);
160
+ }
161
+ }
162
+ });
163
+ return pending.length;
164
+ }
165
+ //# sourceMappingURL=spool.js.map
@@ -0,0 +1,99 @@
1
+ /**
2
+ * @file Writing ingest runs, file positions, and raw lines to the database (docs/development.md P4.3, D-002).
3
+ *
4
+ * Raw lines are append-only and identified by (file, SHA-256 of their bytes). Storing a line that is
5
+ * already there does nothing, which makes every read safe to repeat: after a rewrite, a `--full`
6
+ * rescan, or a crash mid-run.
7
+ */
8
+ import { createHash } from "node:crypto";
9
+ /**
10
+ * Hashes line bytes for identity.
11
+ * @param bytes - The exact line bytes.
12
+ * @returns Lowercase hex SHA-256.
13
+ */
14
+ export function contentHash(bytes) {
15
+ return createHash("sha256").update(bytes).digest("hex");
16
+ }
17
+ /**
18
+ * Records the start of an ingest run.
19
+ * @param db - Open database.
20
+ * @param mode - `incremental` or `full`.
21
+ * @param now - Current time.
22
+ * @returns The new run's ID.
23
+ */
24
+ export function startRun(db, mode, now) {
25
+ const result = db
26
+ .prepare("INSERT INTO ingest_runs (started_at, mode) VALUES (?, ?)")
27
+ .run(now.toISOString(), mode);
28
+ return Number(result.lastInsertRowid);
29
+ }
30
+ /**
31
+ * Records the end of an ingest run. A run left without `finished_at` crashed or is still running.
32
+ * @param db - Open database.
33
+ * @param runId - The run to close.
34
+ * @param now - Current time.
35
+ */
36
+ export function finishRun(db, runId, now) {
37
+ db.prepare("UPDATE ingest_runs SET finished_at = ? WHERE id = ?").run(now.toISOString(), runId);
38
+ }
39
+ /**
40
+ * Finds a source file's row, creating it if this is the first time the file has been seen.
41
+ * @param db - Open database.
42
+ * @param file - Kind, root, path relative to the root, and current size and inode.
43
+ * @param runId - Current run, recorded as the first run for a new file.
44
+ * @returns The row ID and the previously stored state (null for a new file).
45
+ */
46
+ export function findOrCreateSourceFile(db, file, runId) {
47
+ const existing = db
48
+ .prepare("SELECT id, byte_offset, line_count, size, inode FROM source_files WHERE root = ? AND relative_path = ?")
49
+ .get(file.root, file.relativePath);
50
+ if (existing !== undefined) {
51
+ return {
52
+ id: existing.id,
53
+ state: {
54
+ byteOffset: existing.byte_offset,
55
+ lineCount: existing.line_count,
56
+ size: existing.size,
57
+ inode: existing.inode,
58
+ },
59
+ };
60
+ }
61
+ // A new file starts at offset 0 with no lines; its real position is saved after reading.
62
+ const result = db
63
+ .prepare("INSERT INTO source_files (kind, root, relative_path, byte_offset, line_count, size, inode, first_run_id, last_run_id) VALUES (?, ?, ?, 0, 0, ?, ?, ?, ?)")
64
+ .run(file.kind, file.root, file.relativePath, file.stat.size, file.stat.inode, runId, runId);
65
+ return { id: Number(result.lastInsertRowid), state: null };
66
+ }
67
+ /**
68
+ * Saves a file's reading position after this run read it.
69
+ * @param db - Open database.
70
+ * @param fileId - Source file row ID.
71
+ * @param state - New offset, line count, size, and inode.
72
+ * @param runId - Current run.
73
+ */
74
+ export function saveFileState(db, fileId, state, runId) {
75
+ db.prepare("UPDATE source_files SET byte_offset = ?, line_count = ?, size = ?, inode = ?, last_run_id = ? WHERE id = ?").run(state.byteOffset, state.lineCount, state.size, state.inode, runId, fileId);
76
+ }
77
+ /**
78
+ * Returns a function that stores raw lines for one file, preparing the statement once.
79
+ * @param db - Open database.
80
+ * @param fileId - Source file the lines belong to.
81
+ * @param runId - Current run, recorded as the first run for newly stored lines.
82
+ * @returns A function storing one line; it returns true if the line was new.
83
+ */
84
+ export function rawLineWriter(db, fileId, runId) {
85
+ // OR IGNORE leans on UNIQUE (source_file_id, content_hash): a line seen before keeps its first
86
+ // run and line number, which is exactly the identity rule in fixtures/README.md.
87
+ const insert = db.prepare("INSERT OR IGNORE INTO raw_lines (source_file_id, line_number, byte_offset, content_hash, bytes, first_run_id) VALUES (?, ?, ?, ?, ?, ?)");
88
+ return (line) => insert.run(fileId, line.lineNumber, line.offset, contentHash(line.bytes), line.bytes, runId)
89
+ .changes === 1;
90
+ }
91
+ /**
92
+ * Records a run-level or file-level problem in the ingest report.
93
+ * @param db - Open database.
94
+ * @param entry - Run, optional file, problem code, and optional detail.
95
+ */
96
+ export function recordProblem(db, entry) {
97
+ db.prepare("INSERT INTO ingest_report (run_id, source_file_id, problem, detail) VALUES (?, ?, ?, ?)").run(entry.runId, entry.fileId, entry.problem, entry.detail);
98
+ }
99
+ //# sourceMappingURL=store.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