nilometer 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/LICENSE +21 -0
  3. package/README.md +462 -2
  4. package/core/pricing/prices.json +348 -0
  5. package/core/schema/001_ingestion.sql +57 -0
  6. package/core/schema/002_line_count.sql +7 -0
  7. package/core/schema/003_derived.sql +70 -0
  8. package/core/schema/004_requests_dedup.sql +50 -0
  9. package/core/schema/005_repositories.sql +25 -0
  10. package/core/schema/006_status_readings.sql +33 -0
  11. package/core/schema/007_pricing.sql +95 -0
  12. package/core/schema/008_interruptions.sql +263 -0
  13. package/core/schema/009_headroom.sql +84 -0
  14. package/core/schema/010_attribution.sql +106 -0
  15. package/core/schema/011_projections.sql +83 -0
  16. package/core/schema/012_unsplit_cache_writes.sql +49 -0
  17. package/core/schema/013_repository_revalidation.sql +104 -0
  18. package/core/schema/014_observed_readings.sql +204 -0
  19. package/core/schema/015_response_observations.sql +127 -0
  20. package/core/schema/016_windows_repository_paths.sql +41 -0
  21. package/core/schema/017_unattributed_usage_once.sql +47 -0
  22. package/core/schema/018_request_time_index.sql +21 -0
  23. package/core/schema/019_dedup_earliest_of_equals.sql +61 -0
  24. package/core/schema/020_limit_quota_fields.sql +131 -0
  25. package/core/schema/021_plan_periods.sql +121 -0
  26. package/core/schema/022_attachment_walk.sql +223 -0
  27. package/core/schema/023_auto_continuations.sql +129 -0
  28. package/core/schema/024_counted_limit_groups.sql +29 -0
  29. package/core/schema/025_model_fallback.sql +77 -0
  30. package/core/schema/026_changed_readings.sql +106 -0
  31. package/core/schema/027_weekly_crossings.sql +32 -0
  32. package/core/schema/028_stale_readings.sql +196 -0
  33. package/core/schema/029_lockout_sessions.sql +68 -0
  34. package/core/schema/030_repository_aliases.sql +81 -0
  35. package/core/schema/031_cte_cursor_budget.sql +153 -0
  36. package/dist/cli/main.cli.js +39 -0
  37. package/dist/cli/program.js +801 -0
  38. package/dist/core/db/database.js +182 -0
  39. package/dist/core/db/fingerprint.js +71 -0
  40. package/dist/core/ingest/attribution.js +131 -0
  41. package/dist/core/ingest/classify.js +382 -0
  42. package/dist/core/ingest/command.js +95 -0
  43. package/dist/core/ingest/derive.js +212 -0
  44. package/dist/core/ingest/discover.js +126 -0
  45. package/dist/core/ingest/ingest.js +160 -0
  46. package/dist/core/ingest/reader.js +89 -0
  47. package/dist/core/ingest/reset-time.js +97 -0
  48. package/dist/core/ingest/results.js +201 -0
  49. package/dist/core/ingest/spool.js +165 -0
  50. package/dist/core/ingest/store.js +99 -0
  51. package/dist/core/install/install.js +185 -0
  52. package/dist/core/install/locations.js +89 -0
  53. package/dist/core/install/private-files.js +259 -0
  54. package/dist/core/install/record.js +103 -0
  55. package/dist/core/plans/plan-prices.js +128 -0
  56. package/dist/core/plans/repository-aliases.js +136 -0
  57. package/dist/core/pricing/prices.js +147 -0
  58. package/dist/core/settings/settings-file.js +215 -0
  59. package/dist/core/settings/statusline.js +240 -0
  60. package/dist/core/util/commands.js +91 -0
  61. package/dist/core/util/node-version.js +58 -0
  62. package/dist/core/util/open-page.js +69 -0
  63. package/dist/core/util/paths.js +36 -0
  64. package/dist/core/verify/compare.js +77 -0
  65. package/dist/core/verify/verify.js +220 -0
  66. package/dist/viewer/explain.js +574 -0
  67. package/dist/viewer/format.js +150 -0
  68. package/dist/viewer/html-charts.js +1292 -0
  69. package/dist/viewer/html-contract.js +2 -0
  70. package/dist/viewer/html-data.js +718 -0
  71. package/dist/viewer/html.js +1654 -0
  72. package/dist/viewer/queries.js +128 -0
  73. package/dist/viewer/render.js +602 -0
  74. package/dist/viewer/report.js +120 -0
  75. package/dist/viewer/save.js +110 -0
  76. package/docs/requirements.md +113 -0
  77. package/hooks/statusline.sh +104 -0
  78. package/package.json +61 -4
@@ -0,0 +1,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,185 @@
1
+ /**
2
+ * @file The `init` and `uninstall` operations: register the status line hook, and reverse it.
3
+ *
4
+ * Implements README § How it works ("Chaining, not clobbering"), docs/development.md P1.3, and the
5
+ * `statusline-collector` skill's `init` steps: back up first, wrap an existing command, write
6
+ * atomically, be idempotent, and make `uninstall` restore the original exactly.
7
+ *
8
+ * Both operations return a plain outcome object. Wording for the user belongs to the CLI layer,
9
+ * so outcomes can be tested without asserting on prose.
10
+ */
11
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { isDeepStrictEqual } from "node:util";
14
+ import { backupSettings, locateSettings, readSettings, serializeSettings, writeFileAtomic, } from "../settings/settings-file.js";
15
+ import { buildHookCommand, dataDirFromHookCommand, isCommandEntry, planInstall, planUninstall, } from "../settings/statusline.js";
16
+ import { HOOK_RELATIVE_PATH, INSTALLED_HOOK_FILE, resolveDataDir } from "./locations.js";
17
+ import { ensurePrivateDir, installHook, removePath } from "./private-files.js";
18
+ import { readInstallRecord, removeInstallRecord, writeInstallRecord } from "./record.js";
19
+ /** Layout used when `init` creates a settings file from scratch: what Claude Code writes. */
20
+ export const DEFAULT_FORMAT = { indent: 2, trailingNewline: true };
21
+ /**
22
+ * Resolves the settings path, data directory, and hook command for a run.
23
+ * @param options - Install options.
24
+ * @returns The three resolved locations.
25
+ */
26
+ export function resolveTargets(options) {
27
+ const settingsPath = locateSettings({
28
+ home: options.home,
29
+ override: options.settingsOverride,
30
+ claudeConfigDir: options.env.CLAUDE_CONFIG_DIR,
31
+ });
32
+ const dataDir = resolveDataDir({
33
+ home: options.home,
34
+ env: options.env,
35
+ override: options.dataDirOverride,
36
+ });
37
+ // The command names the copy in the data directory, which nothing moves, not the one in the
38
+ // package, whose path changes with the Node version or the checkout's location (D-056).
39
+ const hookPath = join(dataDir, INSTALLED_HOOK_FILE);
40
+ const hookCommand = buildHookCommand(hookPath, dataDir);
41
+ return { settingsPath, dataDir, hookCommand, hookPath };
42
+ }
43
+ /**
44
+ * Registers the status line hook in Claude Code's settings. Safe to run repeatedly.
45
+ * @param options - Locations, environment, and clock.
46
+ * @returns What happened, with the paths involved.
47
+ * @throws {import("../settings/settings-file.js").SettingsError} If the settings file can't be
48
+ * parsed; nothing is changed.
49
+ * @throws {import("./record.js").InstallRecordError} If an existing install record is corrupt.
50
+ */
51
+ export function runInit(options) {
52
+ const { settingsPath, dataDir, hookCommand, hookPath } = resolveTargets(options);
53
+ const read = readSettings(settingsPath);
54
+ const settings = read.kind === "ok" ? read.settings : null;
55
+ const plan = planInstall(settings, hookCommand);
56
+ // Before the settings can name it, and on every run: a package whose hook changed needs the copy
57
+ // refreshed, even when the command itself is already correct (D-056).
58
+ const platform = options.platform ?? process.platform;
59
+ let hookRefreshed = false;
60
+ if (plan.action !== "refuse") {
61
+ ensurePrivateDir(dataDir, platform);
62
+ hookRefreshed = installHook(join(options.packageRoot, HOOK_RELATIVE_PATH), hookPath, platform);
63
+ }
64
+ const base = { settingsPath, dataDir, hookRefreshed };
65
+ switch (plan.action) {
66
+ case "none":
67
+ return { ...base, action: "already-installed", backupPath: null, wrappedCommand: wrapped() };
68
+ case "refuse":
69
+ return { ...base, action: "refused-unsupported", backupPath: null, wrappedCommand: null };
70
+ case "update-command": {
71
+ // The record lives in the data directory. If this data directory has none, the hook was
72
+ // installed with another one, and moving on would lose the user's original command.
73
+ if (readInstallRecord(dataDir) === null) {
74
+ return { ...base, action: "refused-other-install", backupPath: null, wrappedCommand: null };
75
+ }
76
+ const backupPath = backupSettings(settingsPath, options.now);
77
+ writeSettings(settingsPath, plan.settings, read.kind === "ok" ? read.format : DEFAULT_FORMAT);
78
+ return { ...base, action: "updated", backupPath, wrappedCommand: wrapped() };
79
+ }
80
+ case "install": {
81
+ const backupPath = backupSettings(settingsPath, options.now);
82
+ // Record first: once settings point at the hook, its next run must find the original command.
83
+ writeInstallRecord(dataDir, {
84
+ version: 1,
85
+ settingsPath,
86
+ settingsExisted: read.kind === "ok",
87
+ backupPath,
88
+ original: plan.original,
89
+ installedAt: options.now.toISOString(),
90
+ });
91
+ writeSettings(settingsPath, plan.settings, read.kind === "ok" ? read.format : DEFAULT_FORMAT);
92
+ return { ...base, action: "installed", backupPath, wrappedCommand: wrapped() };
93
+ }
94
+ }
95
+ /**
96
+ * Reads which original command the hook wraps, from the record in this data directory.
97
+ * @returns The wrapped command, or null if there is none.
98
+ */
99
+ function wrapped() {
100
+ return readInstallRecord(dataDir)?.original?.command ?? null;
101
+ }
102
+ }
103
+ /**
104
+ * Serializes settings and writes them atomically, creating the directory if needed.
105
+ * @param path - Settings file path.
106
+ * @param settings - Settings to write.
107
+ * @param format - Layout to use.
108
+ */
109
+ function writeSettings(path, settings, format) {
110
+ mkdirSync(dirname(path), { recursive: true });
111
+ writeFileAtomic(path, serializeSettings(settings, format));
112
+ }
113
+ /**
114
+ * Removes the hook from Claude Code's settings and restores what `init` replaced.
115
+ * Collected data (spool, error log) is kept.
116
+ * @param options - Locations, environment, and clock.
117
+ * @returns What happened, with the paths involved.
118
+ * @throws {import("../settings/settings-file.js").SettingsError} If the settings file can't be
119
+ * parsed; nothing is changed.
120
+ * @throws {import("./record.js").InstallRecordError} If the install record is corrupt.
121
+ */
122
+ export function runUninstall(options) {
123
+ const targets = resolveTargets(options);
124
+ let dataDir = targets.dataDir;
125
+ let record = readInstallRecord(dataDir);
126
+ // The record knows which file was changed, even if flags or environment differ today.
127
+ const settingsPath = record?.settingsPath ?? targets.settingsPath;
128
+ const read = readSettings(settingsPath);
129
+ if (record === null && read.kind === "ok") {
130
+ // `init --data-dir X` followed by a plain `uninstall` looks for the record in the default
131
+ // directory and doesn't find it. Without the record, the plan below would delete the user's
132
+ // own status line instead of restoring it, so recover the directory from the command that's
133
+ // about to be removed — the one other place `init` wrote that path down (D-050).
134
+ const entry = read.settings["statusLine"];
135
+ const installedAt = isCommandEntry(entry) ? dataDirFromHookCommand(entry.command) : null;
136
+ const recovered = installedAt === null ? null : readInstallRecord(installedAt);
137
+ if (installedAt !== null && recovered !== null) {
138
+ record = recovered;
139
+ dataDir = installedAt;
140
+ }
141
+ }
142
+ const settings = read.kind === "ok" ? read.settings : null;
143
+ const plan = planUninstall(settings, record?.original ?? null);
144
+ const base = {
145
+ settingsPath,
146
+ dataDir,
147
+ recordMissing: record === null,
148
+ restoredCommand: record?.original?.command ?? null,
149
+ notRemoved: [],
150
+ };
151
+ if (plan.action === "none" || read.kind !== "ok") {
152
+ const action = plan.action === "none" ? plan.reason : "not-installed";
153
+ return { ...base, action, backupPath: null, exactBytes: false };
154
+ }
155
+ const backupPath = backupSettings(settingsPath, options.now);
156
+ // init created this file and nothing else was added since: the faithful undo is no file at all.
157
+ if (record !== null && !record.settingsExisted && Object.keys(plan.settings).length === 0) {
158
+ // Verified, not assumed: a removal that silently does nothing would leave the hook registered
159
+ // while `uninstall` reported success, and it would keep recording (D-061).
160
+ const gone = removePath(settingsPath);
161
+ removeInstallRecord(dataDir);
162
+ return {
163
+ ...base,
164
+ action: "removed-settings-file",
165
+ backupPath,
166
+ exactBytes: true,
167
+ notRemoved: gone ? [] : [settingsPath],
168
+ };
169
+ }
170
+ // If the result equals the pre-install file, write its exact bytes: a re-serialization could
171
+ // differ in whitespace the JSON parser discarded.
172
+ const original = record?.backupPath ?? null;
173
+ if (original !== null && existsSync(original)) {
174
+ const originalRaw = readFileSync(original, "utf8");
175
+ if (isDeepStrictEqual(JSON.parse(originalRaw), plan.settings)) {
176
+ writeFileAtomic(settingsPath, originalRaw);
177
+ removeInstallRecord(dataDir);
178
+ return { ...base, action: "restored", backupPath, exactBytes: true };
179
+ }
180
+ }
181
+ writeFileAtomic(settingsPath, serializeSettings(plan.settings, read.format));
182
+ removeInstallRecord(dataDir);
183
+ return { ...base, action: "restored", backupPath, exactBytes: false };
184
+ }
185
+ //# sourceMappingURL=install.js.map