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,212 @@
1
+ /**
2
+ * @file Filling the derived tables from raw lines (docs/development.md P4.4, D-002).
3
+ *
4
+ * Each raw line derives independently of every other line, so new lines can be derived as they
5
+ * arrive, and a full rebuild produces the same rows. Cross-line logic (dedup winners, totals) lives
6
+ * in SQL views over these tables.
7
+ */
8
+ import { blobBytes, transaction } from "../db/database.js";
9
+ import { classifyLine, extractEvent, extractLinks, extractRequest, identityString, parseLine, parseTimestamp, } from "./classify.js";
10
+ import { resolveResetTime } from "./reset-time.js";
11
+ import { deriveNewSpoolLines } from "./spool.js";
12
+ /**
13
+ * Version of the derivation rules. Bump it whenever classify.ts or this file changes what a raw line
14
+ * derives to; the next open rebuilds every derived row.
15
+ */
16
+ export const PARSER_VERSION = "7";
17
+ /** Classes that produce an event row. */
18
+ const EVENT_CLASSES = new Set([
19
+ "limit_hit",
20
+ "api_error",
21
+ "synthetic_other",
22
+ "retry_notice",
23
+ "model_fallback",
24
+ ]);
25
+ /** Links of a line that didn't parse. */
26
+ const NO_LINKS = {
27
+ uuid: null,
28
+ parentUuid: null,
29
+ originKind: null,
30
+ userContent: null,
31
+ isMeta: false,
32
+ };
33
+ /**
34
+ * Returns a value when it's a string, else null.
35
+ * @param value - Any parsed JSON value.
36
+ * @returns The string or null.
37
+ */
38
+ function stringOrNull(value) {
39
+ return typeof value === "string" ? value : null;
40
+ }
41
+ /**
42
+ * Derives every row one raw line produces. Pure: no database access.
43
+ * @param bytes - The raw line's exact bytes.
44
+ * @returns Parsed fields, optional request and event fields, and problems.
45
+ */
46
+ export function deriveLine(bytes) {
47
+ const line = parseLine(bytes);
48
+ const lineClass = classifyLine(line);
49
+ if (line === null) {
50
+ return {
51
+ parsed: {
52
+ lineClass,
53
+ sessionId: null,
54
+ type: null,
55
+ subtype: null,
56
+ timestampRaw: null,
57
+ timestampUtc: null,
58
+ cwd: null,
59
+ gitBranch: null,
60
+ ccVersion: null,
61
+ isSidechain: false,
62
+ },
63
+ links: NO_LINKS,
64
+ request: null,
65
+ event: null,
66
+ resetAtUtc: null,
67
+ problems: [],
68
+ };
69
+ }
70
+ const timestamp = parseTimestamp(line["timestamp"]);
71
+ const problems = [];
72
+ let request = null;
73
+ if (lineClass === "request") {
74
+ request = extractRequest(line);
75
+ for (const field of request.missingFields) {
76
+ problems.push(["missing_field", field]);
77
+ }
78
+ // The raw value is kept as JSON text so a number, null, or absent timestamp stays distinguishable.
79
+ if (timestamp === null) {
80
+ problems.push(["unparsed_timestamp", JSON.stringify(line["timestamp"] ?? null)]);
81
+ }
82
+ for (const type of request.nonMessageIterations) {
83
+ problems.push(["non_message_iteration", type]);
84
+ }
85
+ }
86
+ const event = EVENT_CLASSES.has(lineClass) ? extractEvent(line, lineClass) : null;
87
+ // Only limit hits carry reset text, so every other event resolves to null here.
88
+ const textResetAtUtc = resolveResetTime(event?.resetText ?? null, timestamp?.utc ?? null);
89
+ if (event !== null) {
90
+ problems.push(...quotaProblems(event, textResetAtUtc));
91
+ }
92
+ return {
93
+ parsed: {
94
+ lineClass,
95
+ sessionId: identityString(line["sessionId"]),
96
+ type: identityString(line["type"]),
97
+ subtype: stringOrNull(line["subtype"]),
98
+ timestampRaw: line["timestamp"] === undefined ? null : JSON.stringify(line["timestamp"]),
99
+ timestampUtc: timestamp?.utc ?? null,
100
+ cwd: stringOrNull(line["cwd"]),
101
+ gitBranch: stringOrNull(line["gitBranch"]),
102
+ ccVersion: stringOrNull(line["version"]),
103
+ isSidechain: line["isSidechain"] === true,
104
+ },
105
+ links: extractLinks(line),
106
+ request,
107
+ event,
108
+ resetAtUtc: textResetAtUtc,
109
+ problems,
110
+ };
111
+ }
112
+ /**
113
+ * Lists what's wrong with a limit hit's `quotaLimits` (D-067): unusable members, and a window or
114
+ * reset that disagrees with the message text. Nothing is corrected here; the views prefer the
115
+ * structured fields, and these problems say where the two sources differed.
116
+ * @param event - The line's event fields.
117
+ * @param textResetAtUtc - The reset text resolved against the hit's timestamp, or null.
118
+ * @returns `[problem, detail]` pairs; empty for lines that aren't limit hits.
119
+ */
120
+ export function quotaProblems(event, textResetAtUtc) {
121
+ const problems = event.quota.unusable.map((field) => [
122
+ "unusable_quota_field",
123
+ field,
124
+ ]);
125
+ const quotaWindow = event.quota.window;
126
+ if (quotaWindow !== null && event.window !== null && quotaWindow !== event.window) {
127
+ problems.push(["quota_window_disagrees", `quotaLimits=${quotaWindow} text=${event.window}`]);
128
+ }
129
+ const quotaReset = event.quota.resetsAtUtc;
130
+ // Reset text has minute precision, so only a gap of a minute or more is a disagreement.
131
+ if (quotaReset !== null &&
132
+ textResetAtUtc !== null &&
133
+ Math.abs(Date.parse(quotaReset) - Date.parse(textResetAtUtc)) >= 60_000) {
134
+ problems.push(["quota_reset_disagrees", `quotaLimits=${quotaReset} text=${textResetAtUtc}`]);
135
+ }
136
+ return problems;
137
+ }
138
+ /**
139
+ * Derives and stores rows for every session-log raw line that has none yet. Spool lines are
140
+ * derived by `deriveNewSpoolLines` instead.
141
+ * @param db - Open, migrated database.
142
+ * @returns Number of log lines derived.
143
+ */
144
+ export function deriveNewLines(db) {
145
+ const pending = db
146
+ .prepare(`SELECT l.id, l.bytes FROM raw_lines l
147
+ JOIN source_files f ON f.id = l.source_file_id AND f.kind = 'log'
148
+ LEFT JOIN parsed_lines p ON p.raw_line_id = l.id
149
+ WHERE p.raw_line_id IS NULL ORDER BY l.id`)
150
+ .all();
151
+ const insertParsed = db.prepare("INSERT INTO parsed_lines (raw_line_id, class, session_id, type, subtype, timestamp_raw, timestamp_utc, cwd, git_branch, cc_version, is_sidechain, uuid, parent_uuid, origin_kind, user_content, is_meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
152
+ const insertRequest = db.prepare("INSERT INTO requests (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, service_tier, inference_geo) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
153
+ const insertEvent = db.prepare("INSERT INTO events (raw_line_id, class, error, api_error_status, window, reset_text, unknown_error_key, retry_rate_limits_present, reset_at_utc, quota_window, quota_resets_at_utc, fallback_original_model, fallback_model, fallback_request_id, refusal_category) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
154
+ const insertProblem = db.prepare("INSERT INTO line_problems (raw_line_id, problem, detail) VALUES (?, ?, ?)");
155
+ transaction(db, () => {
156
+ for (const { id, bytes } of pending) {
157
+ const derived = deriveLine(blobBytes(bytes));
158
+ const p = derived.parsed;
159
+ insertParsed.run(id, p.lineClass, p.sessionId, p.type, p.subtype, p.timestampRaw, p.timestampUtc, p.cwd, p.gitBranch, p.ccVersion, p.isSidechain ? 1 : 0, derived.links.uuid, derived.links.parentUuid, derived.links.originKind, derived.links.userContent, derived.links.isMeta ? 1 : 0);
160
+ const r = derived.request;
161
+ if (r !== null) {
162
+ insertRequest.run(id, r.dedupKey, r.messageId, r.requestId, r.model, r.inputTokens, r.outputTokens, r.cacheReadTokens, r.cacheWrite5mTokens, r.cacheWrite1hTokens, r.cacheWriteUnsplitTokens, r.speed, r.serviceTier, r.inferenceGeo);
163
+ }
164
+ const e = derived.event;
165
+ if (e !== null) {
166
+ insertEvent.run(id, p.lineClass, e.error, e.apiErrorStatus, e.window, e.resetText, e.unknownErrorKey, e.retryRateLimitsPresent ? 1 : 0, derived.resetAtUtc, e.quota.window, e.quota.resetsAtUtc, e.fallback.originalModel, e.fallback.fallbackModel, e.fallback.requestId, e.fallback.refusalCategory);
167
+ }
168
+ for (const [problem, detail] of derived.problems) {
169
+ insertProblem.run(id, problem, detail);
170
+ }
171
+ }
172
+ });
173
+ return pending.length;
174
+ }
175
+ /** Derived tables, in the order they're cleared (children before parents). */
176
+ export const DERIVED_TABLES = [
177
+ "line_problems",
178
+ "events",
179
+ "requests",
180
+ "parsed_lines",
181
+ "rate_limit_windows",
182
+ "status_readings",
183
+ ];
184
+ /**
185
+ * Deletes every derived row and derives again from raw lines, recording the parser version.
186
+ * @param db - Open, migrated database.
187
+ * @returns Number of raw lines derived (log and spool).
188
+ */
189
+ export function rebuildDerived(db) {
190
+ return transaction(db, () => {
191
+ for (const table of DERIVED_TABLES) {
192
+ db.prepare(`DELETE FROM ${table}`).run();
193
+ }
194
+ const count = deriveNewLines(db) + deriveNewSpoolLines(db);
195
+ db.prepare("INSERT INTO derive_meta (key, value) VALUES ('parser_version', ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value").run(PARSER_VERSION);
196
+ return count;
197
+ });
198
+ }
199
+ /**
200
+ * Brings derived tables up to date: a full rebuild if the parser version changed, otherwise only
201
+ * the raw lines not yet derived.
202
+ * @param db - Open, migrated database.
203
+ * @returns Number of raw lines derived and whether a rebuild happened.
204
+ */
205
+ export function ensureDerived(db) {
206
+ const row = db.prepare("SELECT value FROM derive_meta WHERE key = 'parser_version'").get();
207
+ if (row?.value !== PARSER_VERSION) {
208
+ return { derived: rebuildDerived(db), rebuilt: true };
209
+ }
210
+ return { derived: deriveNewLines(db) + deriveNewSpoolLines(db), rebuilt: false };
211
+ }
212
+ //# sourceMappingURL=derive.js.map
@@ -0,0 +1,126 @@
1
+ /**
2
+ * @file Finding Claude Code session logs (docs/development.md P4.1, `ingest-session-logs` skill § 1).
3
+ *
4
+ * Roots follow ccusage's resolution so both tools read the same logs (D-011): an explicit
5
+ * `CLAUDE_CONFIG_DIR` (comma-separated) wins; otherwise `$XDG_CONFIG_HOME/claude` and `~/.claude`.
6
+ * Each root's `projects/` tree is walked with no depth limit, because subagent transcripts live
7
+ * below session directories and a depth cap silently misses them (haasonsaas).
8
+ */
9
+ import { readdirSync, statSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { expandHome } from "../util/paths.js";
12
+ /** `CLAUDE_CONFIG_DIR` is set but names no usable directory. Nothing is read when this is raised. */
13
+ export class DiscoveryError extends Error {
14
+ /**
15
+ * Creates a discovery error.
16
+ * @param message - What is wrong.
17
+ */
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = "DiscoveryError";
21
+ }
22
+ }
23
+ /**
24
+ * Reports whether a path is an existing directory.
25
+ * @param path - Path to test.
26
+ * @returns True for a directory; false if missing, not a directory, or unreadable.
27
+ */
28
+ export function isDirectory(path) {
29
+ try {
30
+ return statSync(path).isDirectory();
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ /**
37
+ * Resolves the roots whose `projects/` directories hold session logs.
38
+ * @param env - Relevant environment variables.
39
+ * @param home - The user's home directory.
40
+ * @param isDir - Directory test, injectable for tests.
41
+ * @returns Roots in priority order, each containing a `projects/` directory; may be empty.
42
+ * @throws {DiscoveryError} If `CLAUDE_CONFIG_DIR` is set and none of its entries has `projects/`.
43
+ */
44
+ export function resolveRoots(env, home, isDir = isDirectory) {
45
+ const explicit = env.CLAUDE_CONFIG_DIR?.trim();
46
+ if (explicit !== undefined && explicit !== "") {
47
+ const entries = explicit
48
+ .split(",")
49
+ .map((entry) => entry.trim())
50
+ .filter((entry) => entry !== "")
51
+ .map((entry) => expandHome(entry, home));
52
+ const roots = unique(entries.filter((root) => isDir(join(root, "projects"))));
53
+ // An explicit setting that matches nothing is a mistake worth stopping for, not an empty report.
54
+ if (roots.length === 0) {
55
+ throw new DiscoveryError(`CLAUDE_CONFIG_DIR is set but none of its entries contains a projects/ directory: ${explicit}`);
56
+ }
57
+ return roots;
58
+ }
59
+ const candidates = [];
60
+ const xdg = env.XDG_CONFIG_HOME;
61
+ if (xdg !== undefined && xdg.startsWith("/")) {
62
+ candidates.push(join(xdg, "claude"));
63
+ }
64
+ candidates.push(join(home, ".claude"));
65
+ return unique(candidates.filter((root) => isDir(join(root, "projects"))));
66
+ }
67
+ /**
68
+ * Removes repeated strings, keeping the first occurrence.
69
+ * @param values - Values in priority order.
70
+ * @returns The values without repeats, order preserved.
71
+ */
72
+ export function unique(values) {
73
+ return [...new Set(values)];
74
+ }
75
+ /**
76
+ * Compares two strings by their UTF-8 bytes, the order fixtures/README.md defines for files.
77
+ * @param a - First string.
78
+ * @param b - Second string.
79
+ * @returns Negative, zero, or positive, like `Array.prototype.sort` expects.
80
+ */
81
+ export function compareBytes(a, b) {
82
+ // JavaScript's default string order is by UTF-16 code unit, which differs from byte order
83
+ // for characters outside the Basic Multilingual Plane.
84
+ return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
85
+ }
86
+ /**
87
+ * Lists every `.jsonl` file under a root's `projects/` directory.
88
+ * @param root - A root from {@link resolveRoots}.
89
+ * @param listDir - Directory listing; defaults to the real filesystem.
90
+ * @param onUnreadable - Called with the path of a folder that can't be listed (deleted mid-run, or
91
+ * permission denied). The folder is skipped instead of failing the run (D-050).
92
+ * @returns Paths relative to the root (starting `projects/`), joined with `/` on every platform and
93
+ * sorted by UTF-8 bytes.
94
+ */
95
+ export function discoverLogFiles(root, listDir = (path) => readdirSync(path, { withFileTypes: true }), onUnreadable = () => undefined) {
96
+ const found = [];
97
+ /**
98
+ * Walks one directory, collecting `.jsonl` files.
99
+ * @param relative - Directory path relative to the root.
100
+ */
101
+ const walk = (relative) => {
102
+ let entries;
103
+ try {
104
+ entries = listDir(join(root, relative));
105
+ }
106
+ catch {
107
+ onUnreadable(join(root, relative));
108
+ return;
109
+ }
110
+ for (const entry of entries) {
111
+ // "/" on every platform, so stored paths, fixtures, and explain's locations match on Windows (D-049).
112
+ const child = `${relative}/${entry.name}`;
113
+ if (entry.isDirectory()) {
114
+ walk(child);
115
+ }
116
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
117
+ // Symlinks are neither files nor directories to Dirent, so they're skipped: following
118
+ // them could loop or read outside the root.
119
+ found.push(child);
120
+ }
121
+ }
122
+ };
123
+ walk("projects");
124
+ return found.sort(compareBytes);
125
+ }
126
+ //# sourceMappingURL=discover.js.map
@@ -0,0 +1,160 @@
1
+ /**
2
+ * @file One ingest run over session logs: discover, read new lines, store them (docs/development.md P4.3).
3
+ *
4
+ * Implements README § How it works (copy logs into SQLite before Claude Code deletes them) with
5
+ * D-002 and D-003. Derived tables (classification, requests, events) are refreshed from raw lines by
6
+ * later steps; this module only makes sure every complete line on disk is stored exactly once.
7
+ */
8
+ import { existsSync, statSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { transaction } from "../db/database.js";
11
+ import { discoverLogFiles } from "./discover.js";
12
+ import { DEFAULT_CHUNK_SIZE, readCompleteLines, readStart } from "./reader.js";
13
+ import { finishRun, findOrCreateSourceFile, rawLineWriter, recordProblem, saveFileState, startRun, } from "./store.js";
14
+ /**
15
+ * Reads a file's size and inode.
16
+ * @param path - File path.
17
+ * @param statImpl - Stat function, injectable so a zero inode can be tested.
18
+ * @returns Size in bytes and inode (null when the platform reports 0).
19
+ */
20
+ export function statFile(path, statImpl = statSync) {
21
+ const stats = statImpl(path);
22
+ // Some filesystems report inode 0 when they have none; treat that as unknown, not as a value.
23
+ return { size: stats.size, inode: stats.ino === 0 ? null : stats.ino };
24
+ }
25
+ /** File name of the hook's spool inside the data directory (hooks/statusline.sh). */
26
+ export const SPOOL_FILE = "statusline.spool.jsonl";
27
+ /**
28
+ * Rewrites log paths stored with Windows separators to use `/` (D-049).
29
+ *
30
+ * Until 2026-09-17, discovery joined relative paths with the platform separator, so a database
31
+ * built on Windows holds `projects\...`. Discovery now always uses `/`; without this rewrite every
32
+ * existing file would look new and its lines would be stored again.
33
+ * @param db - Open, migrated database.
34
+ * @param platform - The platform. Only Windows rows are rewritten: `\` can't appear in a Windows
35
+ * file name, so there it is always a separator, but it can be part of a POSIX file name.
36
+ * @returns How many rows were rewritten.
37
+ */
38
+ export function normalizeWindowsRelativePaths(db, platform) {
39
+ if (platform !== "win32") {
40
+ return 0;
41
+ }
42
+ // node:sqlite types changes as number or bigint; a row count here never nears 2^53.
43
+ return Number(db
44
+ .prepare("UPDATE source_files SET relative_path = replace(relative_path, char(92), '/') WHERE kind = 'log' AND instr(relative_path, char(92)) > 0")
45
+ .run().changes);
46
+ }
47
+ /**
48
+ * Reads one file's new complete lines into raw_lines and saves its position, in one transaction.
49
+ * @param db - Open, migrated database.
50
+ * @param file - Kind, root, and path below the root.
51
+ * @param run - The current run's ID, options, stat function, and counters to update.
52
+ */
53
+ function ingestFile(db, file, run) {
54
+ const { runId, options, counts } = run;
55
+ const path = join(file.root, file.relativePath);
56
+ const current = run.stat(path);
57
+ counts.files += 1;
58
+ if (file.kind === "log") {
59
+ counts.logFiles += 1;
60
+ }
61
+ // One transaction per file: its lines and its new offset commit together, so a crash can
62
+ // never save an offset past lines that weren't stored.
63
+ transaction(db, () => {
64
+ const row = findOrCreateSourceFile(db, { ...file, stat: current }, runId);
65
+ const start = options.mode === "full"
66
+ ? { byteOffset: 0, lineCount: 0, rewritten: false }
67
+ : readStart(row.state, current);
68
+ if (start.rewritten) {
69
+ counts.filesRewritten += 1;
70
+ recordProblem(db, {
71
+ runId,
72
+ fileId: row.id,
73
+ problem: "file_rewritten",
74
+ detail: `size ${current.size} is below the stored offset ${row.state?.byteOffset ?? 0}, or the inode changed; reread from the start`,
75
+ });
76
+ }
77
+ const store = rawLineWriter(db, row.id, runId);
78
+ const result = readCompleteLines(path, start, (line) => {
79
+ if (store(line)) {
80
+ counts.linesStored += 1;
81
+ }
82
+ }, options.chunkSize ?? DEFAULT_CHUNK_SIZE);
83
+ counts.linesRead += result.linesRead;
84
+ saveFileState(db, row.id, {
85
+ byteOffset: result.byteOffset,
86
+ lineCount: result.lineCount,
87
+ size: current.size,
88
+ inode: current.inode,
89
+ }, runId);
90
+ });
91
+ }
92
+ /**
93
+ * Runs one ingest over every log file under the given roots, then the spool if one is configured.
94
+ * @param db - Open, migrated database.
95
+ * @param options - Roots, mode, clock, optional spool directory, chunk size, and stat.
96
+ * @returns Counts describing the run.
97
+ * @throws {Error} Whatever reading a file throws; each file is written in its own transaction, so
98
+ * files finished before the error stay stored, and the run is left unfinished.
99
+ */
100
+ export function ingestLogs(db, options) {
101
+ // Before comparing discovered paths with stored ones, so a Windows database's files aren't read twice.
102
+ normalizeWindowsRelativePaths(db, options.platform ?? process.platform);
103
+ const stat = options.stat ?? ((path) => statFile(path));
104
+ const runId = startRun(db, options.mode, options.now());
105
+ const counts = {
106
+ logFiles: 0,
107
+ files: 0,
108
+ unreadable: 0,
109
+ linesRead: 0,
110
+ linesStored: 0,
111
+ filesRewritten: 0,
112
+ };
113
+ const run = { runId, options, stat, counts };
114
+ for (const root of options.roots) {
115
+ for (const relativePath of discoverLogFiles(root, options.listDir, () => {
116
+ counts.unreadable += 1;
117
+ })) {
118
+ readFileOrCount(db, { kind: "log", root, relativePath }, run);
119
+ }
120
+ }
121
+ const spoolRead = options.spoolDir !== undefined && existsSync(join(options.spoolDir, SPOOL_FILE));
122
+ if (spoolRead) {
123
+ readFileOrCount(db, { kind: "spool", root: options.spoolDir, relativePath: SPOOL_FILE }, run);
124
+ }
125
+ finishRun(db, runId, options.now());
126
+ return {
127
+ runId,
128
+ files: counts.files,
129
+ logFiles: counts.logFiles,
130
+ spoolRead,
131
+ linesRead: counts.linesRead,
132
+ linesStored: counts.linesStored,
133
+ linesAlreadyStored: counts.linesRead - counts.linesStored,
134
+ filesRewritten: counts.filesRewritten,
135
+ unreadable: counts.unreadable,
136
+ };
137
+ }
138
+ /**
139
+ * Ingests one file, counting it as unreadable instead of failing the run (D-050).
140
+ *
141
+ * A session log can be deleted between discovery and open (Claude Code removes them after 30 days),
142
+ * or be unreadable. Either would otherwise abort the run with a stack trace, leaving the run
143
+ * unfinished and, during `init`, landing after settings.json was already changed.
144
+ * @param db - Open, migrated database.
145
+ * @param file - Kind, root, and path below the root.
146
+ * @param run - The current run's context.
147
+ * @throws {unknown} Anything that isn't a filesystem error, since that would be a bug.
148
+ */
149
+ function readFileOrCount(db, file, run) {
150
+ try {
151
+ ingestFile(db, file, run);
152
+ }
153
+ catch (error) {
154
+ if (error.code === undefined) {
155
+ throw error;
156
+ }
157
+ run.counts.unreadable += 1;
158
+ }
159
+ }
160
+ //# sourceMappingURL=ingest.js.map
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @file Reading complete lines from a file, starting at a stored byte offset (docs/development.md P4.2, D-003).
3
+ *
4
+ * The reader never loads a whole file: it reads fixed-size chunks and hands each complete line to
5
+ * a callback. A trailing fragment without a newline isn't consumed, so the next run reads it once
6
+ * it's complete. Counting a half-written line and then skipping it forever is how phuryn's scanner
7
+ * lost turns.
8
+ */
9
+ import { closeSync, openSync, readSync } from "node:fs";
10
+ /**
11
+ * Decides where to resume reading a file.
12
+ * @param state - What was stored after the last read, or null for a file never seen.
13
+ * @param stat - The file's current size and inode.
14
+ * @returns The offset and line count to start from, and whether a rewrite was detected.
15
+ */
16
+ export function readStart(state, stat) {
17
+ if (state === null) {
18
+ return { byteOffset: 0, lineCount: 0, rewritten: false };
19
+ }
20
+ // A shorter file or a different inode means the bytes before our offset are no longer the ones
21
+ // we read. Restart; raw-line identity makes rereading unchanged lines a no-op (D-002).
22
+ const inodeChanged = state.inode !== null && stat.inode !== null && state.inode !== stat.inode;
23
+ if (stat.size < state.byteOffset || inodeChanged) {
24
+ return { byteOffset: 0, lineCount: 0, rewritten: true };
25
+ }
26
+ return { byteOffset: state.byteOffset, lineCount: state.lineCount, rewritten: false };
27
+ }
28
+ /** Default chunk size: large enough to be fast, small enough to bound memory on huge files. */
29
+ export const DEFAULT_CHUNK_SIZE = 64 * 1024;
30
+ /** The real filesystem implementation of {@link ReaderFs}. */
31
+ export const NODE_READER_FS = {
32
+ open: (path) => openSync(path, "r"),
33
+ read: (fd, buffer, length, position) => readSync(fd, buffer, 0, length, position),
34
+ close: (fd) => {
35
+ closeSync(fd);
36
+ },
37
+ };
38
+ /**
39
+ * Reads every complete line after `start`, calling `onLine` for each, in file order.
40
+ * @param path - File to read.
41
+ * @param start - Offset and line count to resume from.
42
+ * @param onLine - Receives each complete line.
43
+ * @param chunkSize - Bytes requested per read.
44
+ * @param fs - File operations; defaults to the real filesystem.
45
+ * @returns The new offset and line count, and how many lines were read.
46
+ * @throws {Error} Whatever the filesystem throws opening or reading; the descriptor is always closed.
47
+ */
48
+ export function readCompleteLines(path, start, onLine, chunkSize = DEFAULT_CHUNK_SIZE, fs = NODE_READER_FS) {
49
+ const fd = fs.open(path);
50
+ try {
51
+ const chunk = Buffer.alloc(chunkSize);
52
+ // Bytes read but not yet ending in a newline: the start of a line that continues in the next chunk.
53
+ let pending = Buffer.alloc(0);
54
+ let pendingOffset = start.byteOffset;
55
+ let position = start.byteOffset;
56
+ let lineCount = start.lineCount;
57
+ let linesRead = 0;
58
+ for (;;) {
59
+ const bytesRead = fs.read(fd, chunk, chunkSize, position);
60
+ if (bytesRead === 0) {
61
+ break;
62
+ }
63
+ position += bytesRead;
64
+ const data = pending.length === 0
65
+ ? chunk.subarray(0, bytesRead)
66
+ : Buffer.concat([pending, chunk.subarray(0, bytesRead)]);
67
+ let lineStart = 0;
68
+ for (let newline = data.indexOf(0x0a); newline !== -1; newline = data.indexOf(0x0a, lineStart)) {
69
+ lineCount += 1;
70
+ linesRead += 1;
71
+ // Copy: `data` may be the reusable chunk buffer, which the next read overwrites.
72
+ onLine({
73
+ bytes: Buffer.from(data.subarray(lineStart, newline)),
74
+ offset: pendingOffset + lineStart,
75
+ lineNumber: lineCount,
76
+ });
77
+ lineStart = newline + 1;
78
+ }
79
+ pendingOffset += lineStart;
80
+ pending = Buffer.from(data.subarray(lineStart));
81
+ }
82
+ // Whatever is still pending has no newline yet; the offset stays before it.
83
+ return { byteOffset: pendingOffset, lineCount, linesRead };
84
+ }
85
+ finally {
86
+ fs.close(fd);
87
+ }
88
+ }
89
+ //# sourceMappingURL=reader.js.map
@@ -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