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,382 @@
1
+ /**
2
+ * @file Classifying raw log lines and extracting their fields (docs/development.md P4.4).
3
+ *
4
+ * Implements the rules in fixtures/README.md, which restate D-001 (dedup key), D-004 (limit hits
5
+ * from structured fields), D-007 (per-request timestamps), and D-019 (retry notices), plus the
6
+ * conversation links D-021 and D-022 read (not part of expected.json). Every function
7
+ * here is pure: bytes or a parsed object in, plain values out. Nothing touches the database, so the
8
+ * rules can be tested line by line, and the fidelity suite compares their combined output with an
9
+ * independent Python implementation of the same spec.
10
+ */
11
+ /** The seven line classes, in the order they are tested. */
12
+ export const LINE_CLASSES = [
13
+ "malformed",
14
+ "limit_hit",
15
+ "api_error",
16
+ "synthetic_other",
17
+ "request",
18
+ "retry_notice",
19
+ "model_fallback",
20
+ "ignored_type",
21
+ ];
22
+ /** Label used wherever a required string is absent or not a string. */
23
+ export const MISSING = "<missing>";
24
+ /**
25
+ * Parses a raw line as a JSON object.
26
+ * @param bytes - The exact line bytes.
27
+ * @returns The object, or null when the line isn't valid JSON or isn't an object (arrays and
28
+ * primitives included). An empty line is invalid JSON, so it's null too.
29
+ */
30
+ export function parseLine(bytes) {
31
+ let value;
32
+ try {
33
+ value = JSON.parse(bytes.toString("utf8"));
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ return isObject(value) ? value : null;
39
+ }
40
+ /**
41
+ * Reports whether a value is a plain JSON object.
42
+ * @param value - Any parsed JSON value.
43
+ * @returns True for objects; false for arrays, null, and primitives.
44
+ */
45
+ export function isObject(value) {
46
+ return typeof value === "object" && value !== null && !Array.isArray(value);
47
+ }
48
+ /**
49
+ * Reads a nested object field.
50
+ * @param container - The value to read from.
51
+ * @param key - Field name.
52
+ * @returns The field if it's an object, else null.
53
+ */
54
+ export function objectField(container, key) {
55
+ if (!isObject(container)) {
56
+ return null;
57
+ }
58
+ const value = container[key];
59
+ return isObject(value) ? value : null;
60
+ }
61
+ /**
62
+ * Reports whether a value is a JSON number. Booleans, strings, and null are not numbers here.
63
+ * @param value - Any parsed JSON value.
64
+ * @returns True only for numbers.
65
+ */
66
+ export function isNumber(value) {
67
+ return typeof value === "number";
68
+ }
69
+ /**
70
+ * Returns a string field, or the missing label when it's absent or not a string.
71
+ * @param value - Any parsed JSON value.
72
+ * @returns The string itself, or {@link MISSING}.
73
+ */
74
+ export function identityString(value) {
75
+ return typeof value === "string" ? value : MISSING;
76
+ }
77
+ /**
78
+ * Returns a string, or null when the value isn't a non-empty string.
79
+ * @param value - Any parsed JSON value.
80
+ * @returns The string, or null for absent, empty, or non-string values.
81
+ */
82
+ export function nonEmptyString(value) {
83
+ return typeof value === "string" && value !== "" ? value : null;
84
+ }
85
+ /**
86
+ * Classifies one line. The first matching rule wins (fixtures/README.md, Classification).
87
+ * @param line - The parsed object, or null for a line that didn't parse.
88
+ * @returns The line's class.
89
+ */
90
+ export function classifyLine(line) {
91
+ if (line === null) {
92
+ return "malformed";
93
+ }
94
+ // Error lines are tested before the request test: synthetic error lines carry a zero usage
95
+ // object and would otherwise look like requests (D-004).
96
+ if (line["isApiErrorMessage"] === true) {
97
+ return line["error"] === "rate_limit" ? "limit_hit" : "api_error";
98
+ }
99
+ const message = objectField(line, "message");
100
+ if (message?.["model"] === "<synthetic>") {
101
+ return "synthetic_other";
102
+ }
103
+ if (line["type"] === "assistant" && objectField(message, "usage") !== null) {
104
+ return "request";
105
+ }
106
+ if (line["type"] === "system" && line["subtype"] === "api_error") {
107
+ return "retry_notice";
108
+ }
109
+ // A request one model refused and another retried (D-080): the only record of the refusal.
110
+ if (line["type"] === "system" && line["subtype"] === "model_refusal_fallback") {
111
+ return "model_fallback";
112
+ }
113
+ return "ignored_type";
114
+ }
115
+ /**
116
+ * Builds the D-001 dedup key for a request line.
117
+ * @param line - A request line.
118
+ * @returns `"<session>/m/<message.id>"`, else `"<session>/r/<requestId>"`, else null (unkeyed).
119
+ */
120
+ export function dedupKey(line) {
121
+ const session = identityString(line["sessionId"]);
122
+ const messageId = nonEmptyString(objectField(line, "message")?.["id"]);
123
+ if (messageId !== null) {
124
+ return `${session}/m/${messageId}`;
125
+ }
126
+ const requestId = nonEmptyString(line["requestId"]);
127
+ // No invented key when both IDs are missing: the line passes through and gets reported.
128
+ return requestId === null ? null : `${session}/r/${requestId}`;
129
+ }
130
+ /** The three token fields whose absence is reported, with their log names. */
131
+ const REPORTED_TOKEN_FIELDS = [
132
+ ["inputTokens", "input_tokens"],
133
+ ["outputTokens", "output_tokens"],
134
+ ["cacheReadTokens", "cache_read_input_tokens"],
135
+ ];
136
+ /**
137
+ * Extracts request fields from a request line.
138
+ * @param line - A line classified as `request`.
139
+ * @returns The fields, with missing numbers defaulted and listed.
140
+ */
141
+ export function extractRequest(line) {
142
+ const message = objectField(line, "message");
143
+ const usage = objectField(message, "usage") ?? {};
144
+ const missingFields = [];
145
+ const counts = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
146
+ for (const [key, logName] of REPORTED_TOKEN_FIELDS) {
147
+ const value = usage[logName];
148
+ if (isNumber(value)) {
149
+ counts[key] = value;
150
+ }
151
+ else {
152
+ missingFields.push(logName);
153
+ }
154
+ }
155
+ const split = objectField(usage, "cache_creation");
156
+ /**
157
+ * Reads a cache-write count, defaulting silently to 0 (only the three token fields are reported).
158
+ * @param value - The raw value.
159
+ * @returns The number, or 0.
160
+ */
161
+ const cacheCount = (value) => (isNumber(value) ? value : 0);
162
+ const iterations = Array.isArray(usage["iterations"]) ? usage["iterations"] : [];
163
+ return {
164
+ dedupKey: dedupKey(line),
165
+ messageId: typeof message?.["id"] === "string" ? message["id"] : null,
166
+ requestId: typeof line["requestId"] === "string" ? line["requestId"] : null,
167
+ model: identityString(message?.["model"]),
168
+ ...counts,
169
+ // The split object decides the form: split fields, or one unsplit total from older logs.
170
+ cacheWrite5mTokens: split === null ? null : cacheCount(split["ephemeral_5m_input_tokens"]),
171
+ cacheWrite1hTokens: split === null ? null : cacheCount(split["ephemeral_1h_input_tokens"]),
172
+ cacheWriteUnsplitTokens: split === null ? cacheCount(usage["cache_creation_input_tokens"]) : null,
173
+ speed: typeof usage["speed"] === "string" ? usage["speed"] : null,
174
+ serviceTier: typeof usage["service_tier"] === "string" ? usage["service_tier"] : null,
175
+ inferenceGeo: typeof usage["inference_geo"] === "string" ? usage["inference_geo"] : null,
176
+ missingFields,
177
+ nonMessageIterations: iterations
178
+ .filter((iteration) => isObject(iteration) && iteration["type"] !== "message")
179
+ .map((iteration) => identityString(iteration["type"])),
180
+ };
181
+ }
182
+ /** Strict ISO-8601 pattern from fixtures/README.md: seconds required, uppercase Z or ±HH:MM. */
183
+ const TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|([+-])(\d{2}):(\d{2}))$/;
184
+ /**
185
+ * Parses a timestamp strictly.
186
+ * @param raw - The raw `timestamp` value.
187
+ * @returns The UTC instant and day, or null when the value isn't a string in the required format
188
+ * with every part in range (a real date, hour 00–23, minute and second 00–59, offset hour 00–23,
189
+ * offset minute 00–59).
190
+ * @example
191
+ * parseTimestamp("2026-09-02T01:30:00.000+02:00"); // { utc: "2026-09-01T23:30:00.000Z", day: "2026-09-01" }
192
+ */
193
+ export function parseTimestamp(raw) {
194
+ if (typeof raw !== "string") {
195
+ return null;
196
+ }
197
+ const match = TIMESTAMP.exec(raw);
198
+ if (match === null) {
199
+ return null;
200
+ }
201
+ const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number);
202
+ const fraction = match[7] ?? "";
203
+ const sign = match[9] === "-" ? -1 : 1;
204
+ const offsetHour = match[10] === undefined ? 0 : Number(match[10]);
205
+ const offsetMinute = match[11] === undefined ? 0 : Number(match[11]);
206
+ if (hour > 23 || minute > 59 || second > 59 || offsetHour > 23 || offsetMinute > 59) {
207
+ return null;
208
+ }
209
+ // Date.UTC silently rolls over invalid dates (Feb 30 → Mar 2); reading the parts back catches that.
210
+ const local = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
211
+ if (local.getUTCFullYear() !== year ||
212
+ local.getUTCMonth() !== month - 1 ||
213
+ local.getUTCDate() !== day) {
214
+ return null;
215
+ }
216
+ // Milliseconds from the first three fraction digits; finer precision is kept in the raw string.
217
+ const millis = Number(fraction.padEnd(3, "0").slice(0, 3));
218
+ const utcMs = local.getTime() + millis - sign * (offsetHour * 60 + offsetMinute) * 60_000;
219
+ const utc = new Date(utcMs).toISOString();
220
+ return { utc, day: utc.slice(0, 10) };
221
+ }
222
+ /**
223
+ * Joins a message's text the way fixtures/README.md defines it.
224
+ * @param line - Any parsed line.
225
+ * @returns Concatenated `content[].text` strings for list content, the string itself for string
226
+ * content, otherwise an empty string.
227
+ */
228
+ export function messageText(line) {
229
+ const content = objectField(line, "message")?.["content"];
230
+ if (typeof content === "string") {
231
+ return content;
232
+ }
233
+ if (!Array.isArray(content)) {
234
+ return "";
235
+ }
236
+ return content
237
+ .map((part) => (isObject(part) && typeof part["text"] === "string" ? part["text"] : ""))
238
+ .join("");
239
+ }
240
+ /**
241
+ * Parses a limit message's window and reset time. Matching ignores case.
242
+ * @param text - The message text.
243
+ * @returns The window (session and 5-hour wording checked before weekly) and the reset text.
244
+ * @example
245
+ * parseLimitText("You've hit your session limit · resets 6:10am (UTC)");
246
+ * // { window: "five_hour", resetText: "6:10am (UTC)" }
247
+ */
248
+ export function parseLimitText(text) {
249
+ const lower = text.toLowerCase();
250
+ const window = lower.includes("session limit") || lower.includes("5-hour limit")
251
+ ? "five_hour"
252
+ : lower.includes("weekly limit")
253
+ ? "seven_day"
254
+ : null;
255
+ const at = lower.indexOf("resets ");
256
+ const reset = at === -1 ? "" : text.slice(at + "resets ".length).trim();
257
+ return { window, resetText: reset === "" ? null : reset };
258
+ }
259
+ /** The largest `resetsAt` read: 9999-12-31T23:59:59Z, the last second a four-digit year can write. */
260
+ export const MAX_RESETS_AT = 253_402_300_799;
261
+ /**
262
+ * Reads the structured window and reset that Claude Code writes on limit hits from 2.1.281 on
263
+ * (observed 2026-09-24). Older lines have no `quotaLimits`, which is not a problem; a member that
264
+ * is present but can't be used is listed, so a change of shape is reported rather than guessed at.
265
+ * @param line - A limit-hit line.
266
+ * @returns The window, the reset time, and the unusable members.
267
+ * @example
268
+ * parseQuotaLimits({ quotaLimits: { rateLimitType: "five_hour", resetsAt: 1788329400 } });
269
+ * // { window: "five_hour", resetsAtUtc: "2026-09-02T06:10:00.000Z", unusable: [] }
270
+ */
271
+ export function parseQuotaLimits(line) {
272
+ if (!Object.hasOwn(line, "quotaLimits")) {
273
+ return { window: null, resetsAtUtc: null, unusable: [] };
274
+ }
275
+ const quota = line["quotaLimits"];
276
+ if (!isObject(quota)) {
277
+ return { window: null, resetsAtUtc: null, unusable: ["quotaLimits"] };
278
+ }
279
+ const unusable = [];
280
+ const type = quota["rateLimitType"];
281
+ // Only the two windows the rest of the tool knows; anything else (a per-model window, say) is
282
+ // reported, not mapped onto one of them.
283
+ const window = type === "five_hour" || type === "seven_day" ? type : null;
284
+ if (Object.hasOwn(quota, "rateLimitType") && window === null) {
285
+ unusable.push("rateLimitType");
286
+ }
287
+ const resetsAt = quota["resetsAt"];
288
+ // Whole seconds only: the observed values are integers, and a fraction would mean a unit change.
289
+ const resetsAtUtc = isNumber(resetsAt) && Number.isInteger(resetsAt) && resetsAt > 0 && resetsAt <= MAX_RESETS_AT
290
+ ? new Date(resetsAt * 1000).toISOString()
291
+ : null;
292
+ if (Object.hasOwn(quota, "resetsAt") && resetsAtUtc === null) {
293
+ unusable.push("resetsAt");
294
+ }
295
+ return { window, resetsAtUtc, unusable };
296
+ }
297
+ /**
298
+ * Extracts event fields from a line of an event class.
299
+ * @param line - The parsed line.
300
+ * @param lineClass - Its class: `limit_hit`, `api_error`, `synthetic_other`, `retry_notice`, or
301
+ * `model_fallback`.
302
+ * @returns The event fields.
303
+ */
304
+ export function extractEvent(line, lineClass) {
305
+ const errorObject = objectField(line, "error");
306
+ const status = isNumber(line["apiErrorStatus"])
307
+ ? line["apiErrorStatus"]
308
+ : lineClass === "retry_notice" && isNumber(errorObject?.["status"])
309
+ ? errorObject["status"]
310
+ : null;
311
+ const limit = lineClass === "limit_hit"
312
+ ? parseLimitText(messageText(line))
313
+ : { window: null, resetText: null };
314
+ const error = typeof line["error"] === "string" ? line["error"] : null;
315
+ return {
316
+ error,
317
+ apiErrorStatus: status,
318
+ window: limit.window,
319
+ resetText: limit.resetText,
320
+ quota: lineClass === "limit_hit"
321
+ ? parseQuotaLimits(line)
322
+ : { window: null, resetsAtUtc: null, unusable: [] },
323
+ unknownErrorKey: lineClass === "api_error" && error !== "server_error" ? (error ?? MISSING) : null,
324
+ retryRateLimitsPresent: lineClass === "retry_notice" &&
325
+ errorObject !== null &&
326
+ Object.hasOwn(errorObject, "rateLimits") &&
327
+ errorObject["rateLimits"] !== null,
328
+ fallback: lineClass === "model_fallback"
329
+ ? {
330
+ originalModel: stringOrNull(line["originalModel"]),
331
+ fallbackModel: stringOrNull(line["fallbackModel"]),
332
+ requestId: stringOrNull(line["requestId"]),
333
+ refusalCategory: stringOrNull(line["apiRefusalCategory"]),
334
+ }
335
+ : { originalModel: null, fallbackModel: null, requestId: null, refusalCategory: null },
336
+ };
337
+ }
338
+ /**
339
+ * Keeps a value only when it's a string.
340
+ * @param value - Any JSON value.
341
+ * @returns The string, or null for anything else, including a missing member.
342
+ */
343
+ function stringOrNull(value) {
344
+ return typeof value === "string" ? value : null;
345
+ }
346
+ /**
347
+ * Extracts a line's conversation links.
348
+ * @param line - Any parsed line.
349
+ * @returns The links; absent or mistyped fields are null (or false for `isMeta`).
350
+ */
351
+ export function extractLinks(line) {
352
+ const content = objectField(line, "message")?.["content"];
353
+ let userContent = null;
354
+ if (line["type"] === "user") {
355
+ if (typeof content === "string") {
356
+ userContent = "prompt";
357
+ }
358
+ else if (Array.isArray(content)) {
359
+ userContent = content.some((block) => isObject(block) && block["type"] === "tool_result")
360
+ ? "tool_result"
361
+ : "prompt";
362
+ }
363
+ }
364
+ const originKind = objectField(line, "origin")?.["kind"];
365
+ return {
366
+ uuid: nonEmptyString(line["uuid"]),
367
+ parentUuid: nonEmptyString(line["parentUuid"]),
368
+ originKind: typeof originKind === "string" ? originKind : null,
369
+ userContent,
370
+ isMeta: line["isMeta"] === true,
371
+ };
372
+ }
373
+ /**
374
+ * Builds the ignored-type label for a line.
375
+ * @param line - A line classified as `ignored_type`.
376
+ * @returns `type`, or `type/subtype` when `subtype` is a string; a missing type is {@link MISSING}.
377
+ */
378
+ export function ignoredTypeKey(line) {
379
+ const type = identityString(line["type"]);
380
+ return typeof line["subtype"] === "string" ? `${type}/${line["subtype"]}` : type;
381
+ }
382
+ //# sourceMappingURL=classify.js.map
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @file The `ingest` operation behind the CLI command and `init`'s backfill (docs/development.md P4.8).
3
+ *
4
+ * One call: open the database in the data directory, read new session-log lines and spool lines,
5
+ * bring derived tables and repository attribution up to date, and summarize what's stored. The
6
+ * summary counts come from SQL over the stored data, so a second run reports the same totals.
7
+ */
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { openDatabase } from "../db/database.js";
11
+ import { resolveDataDir } from "../install/locations.js";
12
+ import { securePrivateDataDir } from "../install/private-files.js";
13
+ import { loadPriceTable, syncPrices } from "../pricing/prices.js";
14
+ import { resolveRepositories } from "./attribution.js";
15
+ import { ensureDerived } from "./derive.js";
16
+ import { resolveRoots } from "./discover.js";
17
+ import { ingestLogs } from "./ingest.js";
18
+ /** File name of the database inside the data directory. */
19
+ export const DATABASE_FILE = "usage.db";
20
+ /** File name of the hook's error log inside the data directory (hooks/statusline.sh). */
21
+ export const HOOK_ERRORS_FILE = "hook-errors.log";
22
+ /**
23
+ * Counts lines in the hook's error log.
24
+ * @param dataDir - The data directory.
25
+ * @returns Number of non-empty lines; 0 when the log doesn't exist.
26
+ */
27
+ export function countHookErrors(dataDir) {
28
+ const path = join(dataDir, HOOK_ERRORS_FILE);
29
+ if (!existsSync(path)) {
30
+ return 0;
31
+ }
32
+ return readFileSync(path, "utf8")
33
+ .split("\n")
34
+ .filter((line) => line.trim() !== "").length;
35
+ }
36
+ /**
37
+ * Runs one ingest and summarizes the stored data.
38
+ * @param options - Locations, environment, mode, and clock.
39
+ * @returns Paths, this run's counts, and totals over everything stored.
40
+ * @throws {import("./discover.js").DiscoveryError} If `CLAUDE_CONFIG_DIR` names no usable directory.
41
+ * @throws {import("../pricing/prices.js").PriceTableError} If `core/pricing/prices.json` is invalid.
42
+ * @throws {Error} Whatever opening the database or reading a file throws.
43
+ */
44
+ export function runIngestCommand(options) {
45
+ const dataDir = resolveDataDir({
46
+ home: options.home,
47
+ env: options.env,
48
+ override: options.dataDirOverride,
49
+ });
50
+ const roots = resolveRoots(options.env, options.home);
51
+ // Load prices before opening the database, so an invalid price file fails before anything is written.
52
+ const prices = loadPriceTable(join(options.packageRoot, "core", "pricing", "prices.json"));
53
+ // Before anything is read or written: the data directory holds copies of session content (D-043).
54
+ const permissionsTightened = securePrivateDataDir(dataDir);
55
+ const databasePath = join(dataDir, DATABASE_FILE);
56
+ const db = openDatabase(databasePath, join(options.packageRoot, "core", "schema"));
57
+ try {
58
+ const run = ingestLogs(db, {
59
+ roots,
60
+ mode: options.full ? "full" : "incremental",
61
+ now: options.now,
62
+ spoolDir: dataDir,
63
+ });
64
+ syncPrices(db, prices);
65
+ const derived = ensureDerived(db);
66
+ const repositoriesResolved = resolveRepositories(db, options.now);
67
+ const row = db
68
+ .prepare(`SELECT
69
+ (SELECT COUNT(*) FROM requests_dedup) AS requests,
70
+ (SELECT COUNT(*) FROM requests WHERE dedup_key IS NULL) AS unkeyedRequests,
71
+ (SELECT COUNT(*) FROM events WHERE class = 'limit_hit') AS limitHits,
72
+ (SELECT COUNT(*) FROM events WHERE class NOT IN ('limit_hit', 'model_fallback')) AS otherEvents,
73
+ (SELECT COUNT(*) FROM events WHERE class = 'model_fallback') AS modelFallbacks,
74
+ (SELECT COUNT(*) FROM parsed_lines WHERE class = 'malformed') AS malformedLines,
75
+ (SELECT COUNT(*) FROM status_readings WHERE status = 'ok') AS statusReadings,
76
+ (SELECT COUNT(*) FROM status_readings WHERE status <> 'ok') AS malformedReadings,
77
+ (SELECT COUNT(*) FROM rate_limit_windows WHERE validity <> 'valid') AS invalidWindows,
78
+ (SELECT MIN(timestamp_utc) FROM requests_dedup) AS firstRequestUtc,
79
+ (SELECT MAX(timestamp_utc) FROM requests_dedup) AS lastRequestUtc`)
80
+ .get();
81
+ return {
82
+ databasePath,
83
+ roots,
84
+ run,
85
+ derived: { lines: derived.derived, rebuilt: derived.rebuilt },
86
+ repositoriesResolved,
87
+ totals: { ...row, hookErrors: countHookErrors(dataDir) },
88
+ permissionsTightened,
89
+ };
90
+ }
91
+ finally {
92
+ db.close();
93
+ }
94
+ }
95
+ //# sourceMappingURL=command.js.map