nilometer 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/LICENSE +21 -0
- package/README.md +464 -2
- package/core/pricing/prices.json +348 -0
- package/core/schema/001_ingestion.sql +57 -0
- package/core/schema/002_line_count.sql +7 -0
- package/core/schema/003_derived.sql +70 -0
- package/core/schema/004_requests_dedup.sql +50 -0
- package/core/schema/005_repositories.sql +25 -0
- package/core/schema/006_status_readings.sql +33 -0
- package/core/schema/007_pricing.sql +95 -0
- package/core/schema/008_interruptions.sql +263 -0
- package/core/schema/009_headroom.sql +84 -0
- package/core/schema/010_attribution.sql +106 -0
- package/core/schema/011_projections.sql +83 -0
- package/core/schema/012_unsplit_cache_writes.sql +49 -0
- package/core/schema/013_repository_revalidation.sql +104 -0
- package/core/schema/014_observed_readings.sql +204 -0
- package/core/schema/015_response_observations.sql +127 -0
- package/core/schema/016_windows_repository_paths.sql +41 -0
- package/core/schema/017_unattributed_usage_once.sql +47 -0
- package/core/schema/018_request_time_index.sql +21 -0
- package/core/schema/019_dedup_earliest_of_equals.sql +61 -0
- package/core/schema/020_limit_quota_fields.sql +131 -0
- package/core/schema/021_plan_periods.sql +121 -0
- package/core/schema/022_attachment_walk.sql +223 -0
- package/core/schema/023_auto_continuations.sql +129 -0
- package/core/schema/024_counted_limit_groups.sql +29 -0
- package/core/schema/025_model_fallback.sql +77 -0
- package/core/schema/026_changed_readings.sql +106 -0
- package/core/schema/027_weekly_crossings.sql +32 -0
- package/core/schema/028_stale_readings.sql +196 -0
- package/core/schema/029_lockout_sessions.sql +68 -0
- package/core/schema/030_repository_aliases.sql +81 -0
- package/core/schema/031_cte_cursor_budget.sql +153 -0
- package/core/schema/032_stored_views.sql +233 -0
- package/dist/cli/main.cli.js +39 -0
- package/dist/cli/program.js +881 -0
- package/dist/core/db/database.js +182 -0
- package/dist/core/db/fingerprint.js +71 -0
- package/dist/core/ingest/attribution.js +131 -0
- package/dist/core/ingest/classify.js +382 -0
- package/dist/core/ingest/command.js +95 -0
- package/dist/core/ingest/derive.js +219 -0
- package/dist/core/ingest/discover.js +126 -0
- package/dist/core/ingest/ingest.js +160 -0
- package/dist/core/ingest/reader.js +89 -0
- package/dist/core/ingest/reset-time.js +97 -0
- package/dist/core/ingest/results.js +201 -0
- package/dist/core/ingest/spool.js +165 -0
- package/dist/core/ingest/store.js +99 -0
- package/dist/core/ingest/stored-views.js +65 -0
- package/dist/core/install/install.js +185 -0
- package/dist/core/install/locations.js +89 -0
- package/dist/core/install/private-files.js +259 -0
- package/dist/core/install/record.js +103 -0
- package/dist/core/plans/plan-prices.js +128 -0
- package/dist/core/plans/repository-aliases.js +136 -0
- package/dist/core/pricing/prices.js +147 -0
- package/dist/core/settings/settings-file.js +215 -0
- package/dist/core/settings/statusline.js +240 -0
- package/dist/core/util/commands.js +91 -0
- package/dist/core/util/node-version.js +58 -0
- package/dist/core/util/open-page.js +69 -0
- package/dist/core/util/paths.js +36 -0
- package/dist/core/verify/compare.js +77 -0
- package/dist/core/verify/verify.js +220 -0
- package/dist/core/view/server.js +142 -0
- package/dist/viewer/explain.js +574 -0
- package/dist/viewer/format.js +150 -0
- package/dist/viewer/html-charts.js +1292 -0
- package/dist/viewer/html-contract.js +2 -0
- package/dist/viewer/html-data.js +718 -0
- package/dist/viewer/html.js +1717 -0
- package/dist/viewer/queries.js +128 -0
- package/dist/viewer/render.js +602 -0
- package/dist/viewer/report.js +120 -0
- package/dist/viewer/save.js +110 -0
- package/docs/requirements.md +113 -0
- package/hooks/statusline.sh +104 -0
- package/package.json +61 -4
|
@@ -0,0 +1,219 @@
|
|
|
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 { refreshStoredViews } from "./stored-views.js";
|
|
10
|
+
import { classifyLine, extractEvent, extractLinks, extractRequest, identityString, parseLine, parseTimestamp, } from "./classify.js";
|
|
11
|
+
import { resolveResetTime } from "./reset-time.js";
|
|
12
|
+
import { deriveNewSpoolLines } from "./spool.js";
|
|
13
|
+
/**
|
|
14
|
+
* Version of the derivation rules. Bump it whenever classify.ts or this file changes what a raw line
|
|
15
|
+
* derives to; the next open rebuilds every derived row.
|
|
16
|
+
*/
|
|
17
|
+
export const PARSER_VERSION = "7";
|
|
18
|
+
/** Classes that produce an event row. */
|
|
19
|
+
const EVENT_CLASSES = new Set([
|
|
20
|
+
"limit_hit",
|
|
21
|
+
"api_error",
|
|
22
|
+
"synthetic_other",
|
|
23
|
+
"retry_notice",
|
|
24
|
+
"model_fallback",
|
|
25
|
+
]);
|
|
26
|
+
/** Links of a line that didn't parse. */
|
|
27
|
+
const NO_LINKS = {
|
|
28
|
+
uuid: null,
|
|
29
|
+
parentUuid: null,
|
|
30
|
+
originKind: null,
|
|
31
|
+
userContent: null,
|
|
32
|
+
isMeta: false,
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Returns a value when it's a string, else null.
|
|
36
|
+
* @param value - Any parsed JSON value.
|
|
37
|
+
* @returns The string or null.
|
|
38
|
+
*/
|
|
39
|
+
function stringOrNull(value) {
|
|
40
|
+
return typeof value === "string" ? value : null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Derives every row one raw line produces. Pure: no database access.
|
|
44
|
+
* @param bytes - The raw line's exact bytes.
|
|
45
|
+
* @returns Parsed fields, optional request and event fields, and problems.
|
|
46
|
+
*/
|
|
47
|
+
export function deriveLine(bytes) {
|
|
48
|
+
const line = parseLine(bytes);
|
|
49
|
+
const lineClass = classifyLine(line);
|
|
50
|
+
if (line === null) {
|
|
51
|
+
return {
|
|
52
|
+
parsed: {
|
|
53
|
+
lineClass,
|
|
54
|
+
sessionId: null,
|
|
55
|
+
type: null,
|
|
56
|
+
subtype: null,
|
|
57
|
+
timestampRaw: null,
|
|
58
|
+
timestampUtc: null,
|
|
59
|
+
cwd: null,
|
|
60
|
+
gitBranch: null,
|
|
61
|
+
ccVersion: null,
|
|
62
|
+
isSidechain: false,
|
|
63
|
+
},
|
|
64
|
+
links: NO_LINKS,
|
|
65
|
+
request: null,
|
|
66
|
+
event: null,
|
|
67
|
+
resetAtUtc: null,
|
|
68
|
+
problems: [],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const timestamp = parseTimestamp(line["timestamp"]);
|
|
72
|
+
const problems = [];
|
|
73
|
+
let request = null;
|
|
74
|
+
if (lineClass === "request") {
|
|
75
|
+
request = extractRequest(line);
|
|
76
|
+
for (const field of request.missingFields) {
|
|
77
|
+
problems.push(["missing_field", field]);
|
|
78
|
+
}
|
|
79
|
+
// The raw value is kept as JSON text so a number, null, or absent timestamp stays distinguishable.
|
|
80
|
+
if (timestamp === null) {
|
|
81
|
+
problems.push(["unparsed_timestamp", JSON.stringify(line["timestamp"] ?? null)]);
|
|
82
|
+
}
|
|
83
|
+
for (const type of request.nonMessageIterations) {
|
|
84
|
+
problems.push(["non_message_iteration", type]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const event = EVENT_CLASSES.has(lineClass) ? extractEvent(line, lineClass) : null;
|
|
88
|
+
// Only limit hits carry reset text, so every other event resolves to null here.
|
|
89
|
+
const textResetAtUtc = resolveResetTime(event?.resetText ?? null, timestamp?.utc ?? null);
|
|
90
|
+
if (event !== null) {
|
|
91
|
+
problems.push(...quotaProblems(event, textResetAtUtc));
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
parsed: {
|
|
95
|
+
lineClass,
|
|
96
|
+
sessionId: identityString(line["sessionId"]),
|
|
97
|
+
type: identityString(line["type"]),
|
|
98
|
+
subtype: stringOrNull(line["subtype"]),
|
|
99
|
+
timestampRaw: line["timestamp"] === undefined ? null : JSON.stringify(line["timestamp"]),
|
|
100
|
+
timestampUtc: timestamp?.utc ?? null,
|
|
101
|
+
cwd: stringOrNull(line["cwd"]),
|
|
102
|
+
gitBranch: stringOrNull(line["gitBranch"]),
|
|
103
|
+
ccVersion: stringOrNull(line["version"]),
|
|
104
|
+
isSidechain: line["isSidechain"] === true,
|
|
105
|
+
},
|
|
106
|
+
links: extractLinks(line),
|
|
107
|
+
request,
|
|
108
|
+
event,
|
|
109
|
+
resetAtUtc: textResetAtUtc,
|
|
110
|
+
problems,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Lists what's wrong with a limit hit's `quotaLimits` (D-067): unusable members, and a window or
|
|
115
|
+
* reset that disagrees with the message text. Nothing is corrected here; the views prefer the
|
|
116
|
+
* structured fields, and these problems say where the two sources differed.
|
|
117
|
+
* @param event - The line's event fields.
|
|
118
|
+
* @param textResetAtUtc - The reset text resolved against the hit's timestamp, or null.
|
|
119
|
+
* @returns `[problem, detail]` pairs; empty for lines that aren't limit hits.
|
|
120
|
+
*/
|
|
121
|
+
export function quotaProblems(event, textResetAtUtc) {
|
|
122
|
+
const problems = event.quota.unusable.map((field) => [
|
|
123
|
+
"unusable_quota_field",
|
|
124
|
+
field,
|
|
125
|
+
]);
|
|
126
|
+
const quotaWindow = event.quota.window;
|
|
127
|
+
if (quotaWindow !== null && event.window !== null && quotaWindow !== event.window) {
|
|
128
|
+
problems.push(["quota_window_disagrees", `quotaLimits=${quotaWindow} text=${event.window}`]);
|
|
129
|
+
}
|
|
130
|
+
const quotaReset = event.quota.resetsAtUtc;
|
|
131
|
+
// Reset text has minute precision, so only a gap of a minute or more is a disagreement.
|
|
132
|
+
if (quotaReset !== null &&
|
|
133
|
+
textResetAtUtc !== null &&
|
|
134
|
+
Math.abs(Date.parse(quotaReset) - Date.parse(textResetAtUtc)) >= 60_000) {
|
|
135
|
+
problems.push(["quota_reset_disagrees", `quotaLimits=${quotaReset} text=${textResetAtUtc}`]);
|
|
136
|
+
}
|
|
137
|
+
return problems;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Derives and stores rows for every session-log raw line that has none yet. Spool lines are
|
|
141
|
+
* derived by `deriveNewSpoolLines` instead.
|
|
142
|
+
* @param db - Open, migrated database.
|
|
143
|
+
* @returns Number of log lines derived.
|
|
144
|
+
*/
|
|
145
|
+
export function deriveNewLines(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 = 'log'
|
|
149
|
+
LEFT JOIN parsed_lines p ON p.raw_line_id = l.id
|
|
150
|
+
WHERE p.raw_line_id IS NULL ORDER BY l.id`)
|
|
151
|
+
.all();
|
|
152
|
+
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
|
153
|
+
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
|
154
|
+
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
|
155
|
+
const insertProblem = db.prepare("INSERT INTO line_problems (raw_line_id, problem, detail) VALUES (?, ?, ?)");
|
|
156
|
+
transaction(db, () => {
|
|
157
|
+
for (const { id, bytes } of pending) {
|
|
158
|
+
const derived = deriveLine(blobBytes(bytes));
|
|
159
|
+
const p = derived.parsed;
|
|
160
|
+
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);
|
|
161
|
+
const r = derived.request;
|
|
162
|
+
if (r !== null) {
|
|
163
|
+
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);
|
|
164
|
+
}
|
|
165
|
+
const e = derived.event;
|
|
166
|
+
if (e !== null) {
|
|
167
|
+
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);
|
|
168
|
+
}
|
|
169
|
+
for (const [problem, detail] of derived.problems) {
|
|
170
|
+
insertProblem.run(id, problem, detail);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
return pending.length;
|
|
175
|
+
}
|
|
176
|
+
/** Derived tables, in the order they're cleared (children before parents). */
|
|
177
|
+
export const DERIVED_TABLES = [
|
|
178
|
+
"line_problems",
|
|
179
|
+
"events",
|
|
180
|
+
"requests",
|
|
181
|
+
"parsed_lines",
|
|
182
|
+
"rate_limit_windows",
|
|
183
|
+
"status_readings",
|
|
184
|
+
];
|
|
185
|
+
/**
|
|
186
|
+
* Deletes every derived row and derives again from raw lines, recording the parser version.
|
|
187
|
+
* @param db - Open, migrated database.
|
|
188
|
+
* @returns Number of raw lines derived (log and spool).
|
|
189
|
+
*/
|
|
190
|
+
export function rebuildDerived(db) {
|
|
191
|
+
return transaction(db, () => {
|
|
192
|
+
for (const table of DERIVED_TABLES) {
|
|
193
|
+
db.prepare(`DELETE FROM ${table}`).run();
|
|
194
|
+
}
|
|
195
|
+
const count = deriveNewLines(db) + deriveNewSpoolLines(db);
|
|
196
|
+
db.prepare("INSERT INTO derive_meta (key, value) VALUES ('parser_version', ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value").run(PARSER_VERSION);
|
|
197
|
+
return count;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Brings derived tables up to date: a full rebuild if the parser version changed, otherwise only
|
|
202
|
+
* the raw lines not yet derived. Then refills the stored views when their inputs changed (D-096).
|
|
203
|
+
* @param db - Open, migrated database.
|
|
204
|
+
* @returns Number of raw lines derived and whether a rebuild happened.
|
|
205
|
+
*/
|
|
206
|
+
export function ensureDerived(db) {
|
|
207
|
+
const row = db.prepare("SELECT value FROM derive_meta WHERE key = 'parser_version'").get();
|
|
208
|
+
if (row?.value !== PARSER_VERSION) {
|
|
209
|
+
const derived = rebuildDerived(db);
|
|
210
|
+
// Every derived row was replaced, so the stored views are refilled even if the counts match (D-096).
|
|
211
|
+
refreshStoredViews(db, true);
|
|
212
|
+
return { derived, rebuilt: true };
|
|
213
|
+
}
|
|
214
|
+
const derived = deriveNewLines(db) + deriveNewSpoolLines(db);
|
|
215
|
+
// After deriving, so the report's stored rows include this run's lines (D-096).
|
|
216
|
+
refreshStoredViews(db);
|
|
217
|
+
return { derived, rebuilt: false };
|
|
218
|
+
}
|
|
219
|
+
//# 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
|