session-steward 0.3.0 → 0.5.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/lib/server.mjs CHANGED
@@ -6,6 +6,10 @@ import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
 
8
8
  import { getProvider, listProviders } from "./providers/index.mjs";
9
+ import {
10
+ DEFAULT_SESSION_EVENT_LIMIT,
11
+ MAX_SESSION_EVENT_LIMIT,
12
+ } from "./session-event-reader.mjs";
9
13
  import { createProviderSettings } from "./settings.mjs";
10
14
  import { classifyInstalledVersion } from "./version-support.mjs";
11
15
 
@@ -174,6 +178,35 @@ function getPositiveInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER)
174
178
  return Math.min(parsed, maximum);
175
179
  }
176
180
 
181
+ function getSessionEventLimit(value) {
182
+ if (value === null) return DEFAULT_SESSION_EVENT_LIMIT;
183
+ if (!/^\d+$/u.test(value)) {
184
+ throw new Error(`limit must be between 1 and ${MAX_SESSION_EVENT_LIMIT}.`);
185
+ }
186
+
187
+ const limit = Number(value);
188
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_SESSION_EVENT_LIMIT) {
189
+ throw new Error(`limit must be between 1 and ${MAX_SESSION_EVENT_LIMIT}.`);
190
+ }
191
+
192
+ return limit;
193
+ }
194
+
195
+ function getSessionId(value) {
196
+ if (
197
+ typeof value !== "string"
198
+ || value.length === 0
199
+ || value.length > 512
200
+ || value.trim() !== value
201
+ || value.includes("\0")
202
+ || /[\\/]/u.test(value)
203
+ ) {
204
+ throw new Error("Enter a valid session ID.");
205
+ }
206
+
207
+ return value;
208
+ }
209
+
177
210
  function getInactiveBeforeMs(value) {
178
211
  if (value === null || value === "") {
179
212
  return null;
@@ -294,6 +327,7 @@ function summarizeDeletionResult(result) {
294
327
  deletedTranscriptCount: result.deletedTranscriptPaths.length,
295
328
  recoveryBackupDeleted: false,
296
329
  skippedTranscriptCount: result.skippedTranscriptPaths.length,
330
+ unrecognizedLocationCount: result.unrecognizedLocationCount ?? 0,
297
331
  };
298
332
  }
299
333
 
@@ -810,10 +844,38 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
810
844
  return;
811
845
  }
812
846
 
847
+ if (request.method === "GET" && requestUrl.pathname === "/api/session-events") {
848
+ const controller = new AbortController();
849
+ const abortRead = () => controller.abort();
850
+ request.once("aborted", abortRead);
851
+ response.once("close", () => {
852
+ if (!response.writableEnded) abortRead();
853
+ });
854
+ const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
855
+ const provider = getProvider(providerId);
856
+ const id = getSessionId(requestUrl.searchParams.get("id"));
857
+ const result = await provider.readSessionEvents({
858
+ ...providerOptions(providerId, settings.getHome(providerId)),
859
+ id,
860
+ limit: getSessionEventLimit(requestUrl.searchParams.get("limit")),
861
+ signal: controller.signal,
862
+ });
863
+
864
+ if (controller.signal.aborted) return;
865
+
866
+ if (!result) {
867
+ sendJson(response, 404, { error: "Session not found." });
868
+ return;
869
+ }
870
+
871
+ sendJson(response, 200, result);
872
+ return;
873
+ }
874
+
813
875
  if (request.method === "GET" && requestUrl.pathname.startsWith("/api/sessions/")) {
814
876
  const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
815
877
  const provider = getProvider(providerId);
816
- const id = decodeURIComponent(requestUrl.pathname.slice("/api/sessions/".length));
878
+ const id = getSessionId(decodeURIComponent(requestUrl.pathname.slice("/api/sessions/".length)));
817
879
  const record = await provider.getSessionRecord({
818
880
  ...providerOptions(providerId, settings.getHome(providerId)),
819
881
  id,
@@ -864,9 +926,13 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
864
926
  deletionPlans.delete(deletionPlans.keys().next().value);
865
927
  }
866
928
  deletionPlans.set(id, savedPlan);
867
- const warnings = preflight.activeThreadDetection === "unavailable"
868
- ? [`The current ${provider.displayName} runtime cannot identify an active session. Confirm it is safe to delete the selected sessions.`]
869
- : [];
929
+ const warnings = [];
930
+ if (preflight.activeThreadDetection === "unavailable") {
931
+ warnings.push(`The current ${provider.displayName} runtime cannot identify an active session. Confirm it is safe to delete the selected sessions.`);
932
+ }
933
+ if (plan.unrecognizedLocationCount > 0) {
934
+ warnings.push(`${plan.unrecognizedLocationCount} ${plan.unrecognizedLocationCount === 1 ? "location" : "locations"} in your Claude folder ${plan.unrecognizedLocationCount === 1 ? "was" : "were"} not recognized and will not be examined.`);
935
+ }
870
936
  sendJson(response, 200, {
871
937
  plan: {
872
938
  ...summarizePlan(plan, preflight, scope),
@@ -0,0 +1,126 @@
1
+ import {
2
+ SESSION_EVENT_READ_MODE,
3
+ SESSION_EVENT_WINDOW_END,
4
+ } from "./session-events.mjs";
5
+
6
+ export const DEFAULT_SESSION_EVENT_LIMIT = 100;
7
+ export const MAX_SESSION_EVENT_LIMIT = 1_000;
8
+
9
+ const MAX_PENDING_SESSION_EVENTS = 2_048;
10
+ const MAX_UNMAPPED_SESSION_EVENT_TYPES = 128;
11
+ const SESSION_EVENT_READ_MODES = new Set(Object.values(SESSION_EVENT_READ_MODE));
12
+ const WRAPPED_CONTEXT_PATTERN = /^<([A-Za-z][\w:.-]*[_-][\w:.-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*/u;
13
+
14
+ export function isInjectedSessionAsk(value) {
15
+ if (typeof value !== "string") return false;
16
+ let remaining = value.trim();
17
+ if (!remaining) return false;
18
+ if (/^#{1,6}\s+[^\n]*\binstructions?\b/iu.test(remaining)) return true;
19
+ let matched = false;
20
+
21
+ while (remaining) {
22
+ const match = WRAPPED_CONTEXT_PATTERN.exec(remaining);
23
+ if (!match) return false;
24
+ matched = true;
25
+ remaining = remaining.slice(match[0].length).trimStart();
26
+ }
27
+
28
+ return matched;
29
+ }
30
+
31
+ export function createSessionEventReadState({
32
+ limit = DEFAULT_SESSION_EVENT_LIMIT,
33
+ mode = SESSION_EVENT_READ_MODE.RECENT,
34
+ } = {}) {
35
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_SESSION_EVENT_LIMIT) {
36
+ throw new TypeError(`limit must be between 1 and ${MAX_SESSION_EVENT_LIMIT}.`);
37
+ }
38
+
39
+ if (!SESSION_EVENT_READ_MODES.has(mode)) {
40
+ throw new TypeError(`Unsupported session event read mode: ${mode}`);
41
+ }
42
+
43
+ const events = new Array(limit);
44
+ const pending = new Map();
45
+ const pendingIds = new WeakMap();
46
+ let length = 0;
47
+ let start = 0;
48
+
49
+ function untrack(event) {
50
+ const pendingId = pendingIds.get(event);
51
+ if (pendingId && pending.get(pendingId) === event) pending.delete(pendingId);
52
+ }
53
+
54
+ return {
55
+ add(event, { pendingId = null } = {}) {
56
+ if (pendingId) {
57
+ pending.delete(pendingId);
58
+ pending.set(pendingId, event);
59
+ pendingIds.set(event, pendingId);
60
+
61
+ while (pending.size > MAX_PENDING_SESSION_EVENTS) {
62
+ pending.delete(pending.keys().next().value);
63
+ }
64
+ }
65
+
66
+ if (length < limit) {
67
+ events[(start + length) % limit] = event;
68
+ length += 1;
69
+ } else if (mode === SESSION_EVENT_READ_MODE.RECENT) {
70
+ untrack(events[start]);
71
+ events[start] = event;
72
+ start = (start + 1) % limit;
73
+ }
74
+
75
+ return mode === SESSION_EVENT_READ_MODE.PREVIEW && length === limit;
76
+ },
77
+ resolve(pendingId, update, { consume = true } = {}) {
78
+ const event = pending.get(pendingId);
79
+ if (!event) return false;
80
+ if (consume) pending.delete(pendingId);
81
+ update(event);
82
+ return true;
83
+ },
84
+ values() {
85
+ return Array.from({ length }, (_, index) => events[(start + index) % limit]);
86
+ },
87
+ window({ complete, stoppedEarly }) {
88
+ return {
89
+ complete,
90
+ end: stoppedEarly
91
+ ? SESSION_EVENT_WINDOW_END.OLDEST
92
+ : complete
93
+ ? SESSION_EVENT_WINDOW_END.NEWEST
94
+ : SESSION_EVENT_WINDOW_END.PARTIAL,
95
+ outcomesMayBeUnresolved: stoppedEarly || !complete,
96
+ };
97
+ },
98
+ };
99
+ }
100
+
101
+ export function createUnmappedSessionEventTracker() {
102
+ const counts = new Map();
103
+ let other = 0;
104
+
105
+ return {
106
+ add(type) {
107
+ const normalizedType = typeof type === "string" && type.trim()
108
+ ? type.trim()
109
+ : "unknown";
110
+ if (counts.has(normalizedType)) {
111
+ counts.set(normalizedType, counts.get(normalizedType) + 1);
112
+ } else if (counts.size < MAX_UNMAPPED_SESSION_EVENT_TYPES) {
113
+ counts.set(normalizedType, 1);
114
+ } else {
115
+ other += 1;
116
+ }
117
+ },
118
+ values(limit = 5) {
119
+ const entries = [...counts].map(([type, count]) => ({ count, type }));
120
+ if (other > 0) entries.push({ count: other, type: "other unmapped types" });
121
+ return entries
122
+ .sort((left, right) => right.count - left.count || left.type.localeCompare(right.type))
123
+ .slice(0, limit);
124
+ },
125
+ };
126
+ }
@@ -0,0 +1,307 @@
1
+ export const SESSION_EVENT_KIND = Object.freeze({
2
+ ASK: "ask",
3
+ DECIDED: "decided",
4
+ EDIT: "edit",
5
+ PLAN: "plan",
6
+ RAN: "ran",
7
+ SAID: "said",
8
+ SUMMARY: "summary",
9
+ });
10
+
11
+ export const SESSION_EVENT_COVERAGE_THRESHOLD = 90;
12
+
13
+ export const SESSION_EVENT_READ_MODE = Object.freeze({
14
+ PREVIEW: "preview",
15
+ RECENT: "recent",
16
+ });
17
+
18
+ export const SESSION_EVENT_REASON = Object.freeze({
19
+ NO_RECOGNIZED_EVENTS: "no-recognized-events",
20
+ NO_TRANSCRIPT_PATH: "no-transcript-path",
21
+ TRANSCRIPT_MISSING: "transcript-missing",
22
+ });
23
+
24
+ export const SESSION_EVENT_WINDOW_END = Object.freeze({
25
+ NEWEST: "newest",
26
+ OLDEST: "oldest",
27
+ PARTIAL: "partial",
28
+ });
29
+
30
+ const SESSION_EVENT_KINDS = new Set(Object.values(SESSION_EVENT_KIND));
31
+ const SESSION_EVENT_REASONS = new Set(Object.values(SESSION_EVENT_REASON));
32
+ const SESSION_EVENT_WINDOW_ENDS = new Set(Object.values(SESSION_EVENT_WINDOW_END));
33
+
34
+ function nullableBoolean(value, field) {
35
+ if (value === null || typeof value === "boolean") return value;
36
+ throw new TypeError(`${field} must be a boolean or null.`);
37
+ }
38
+
39
+ function nullableCount(value, field) {
40
+ if (value === null || (Number.isInteger(value) && value >= 0)) return value;
41
+ throw new TypeError(`${field} must be a non-negative integer or null.`);
42
+ }
43
+
44
+ function nullableString(value, field) {
45
+ if (value === null || typeof value === "string") return value;
46
+ throw new TypeError(`${field} must be a string or null.`);
47
+ }
48
+
49
+ function requiredCount(value, field) {
50
+ if (Number.isInteger(value) && value >= 0) return value;
51
+ throw new TypeError(`${field} must be a non-negative integer.`);
52
+ }
53
+
54
+ function requiredBoolean(value, field) {
55
+ if (typeof value === "boolean") return value;
56
+ throw new TypeError(`${field} must be a boolean.`);
57
+ }
58
+
59
+ function requiredString(value, field) {
60
+ if (typeof value === "string") return value;
61
+ throw new TypeError(`${field} must be a string.`);
62
+ }
63
+
64
+ function unmappedTypeEntries(value) {
65
+ if (!Array.isArray(value)) {
66
+ throw new TypeError("coverage.unmappedTypes must be an array.");
67
+ }
68
+
69
+ return value.map((entry) => ({
70
+ count: requiredCount(entry?.count, "coverage.unmappedTypes.count"),
71
+ type: requiredString(entry?.type, "coverage.unmappedTypes.type"),
72
+ }));
73
+ }
74
+
75
+ function sessionEventBase({ atMs, kind, sequence }) {
76
+ if (!SESSION_EVENT_KINDS.has(kind)) {
77
+ throw new TypeError(`Unsupported session event kind: ${kind}`);
78
+ }
79
+
80
+ if (atMs !== null && !Number.isFinite(atMs)) {
81
+ throw new TypeError("atMs must be a finite number or null.");
82
+ }
83
+
84
+ return {
85
+ atMs,
86
+ kind,
87
+ sequence: requiredCount(sequence, "sequence"),
88
+ };
89
+ }
90
+
91
+ export function createSessionEvent({
92
+ added = null,
93
+ answer = null,
94
+ applied = null,
95
+ atMs = null,
96
+ command = null,
97
+ error = null,
98
+ failed = null,
99
+ files = [],
100
+ injected = false,
101
+ kind,
102
+ question = null,
103
+ removed = null,
104
+ sequence,
105
+ steps = [],
106
+ text = null,
107
+ unclassified = false,
108
+ unextracted = false,
109
+ workdir = null,
110
+ } = {}) {
111
+ const base = sessionEventBase({ atMs, kind, sequence });
112
+
113
+ if (kind === SESSION_EVENT_KIND.ASK) {
114
+ return {
115
+ atMs: base.atMs,
116
+ injected: requiredBoolean(injected, "injected"),
117
+ kind: base.kind,
118
+ sequence: base.sequence,
119
+ text: requiredString(text, "text"),
120
+ };
121
+ }
122
+
123
+ if (kind === SESSION_EVENT_KIND.DECIDED) {
124
+ return {
125
+ answer: nullableString(answer, "answer"),
126
+ atMs: base.atMs,
127
+ kind: base.kind,
128
+ question: requiredString(question, "question"),
129
+ sequence: base.sequence,
130
+ };
131
+ }
132
+
133
+ if (kind === SESSION_EVENT_KIND.EDIT) {
134
+ if (!Array.isArray(files) || files.some((file) => typeof file !== "string")) {
135
+ throw new TypeError("files must be an array of strings.");
136
+ }
137
+
138
+ return {
139
+ added: nullableCount(added, "added"),
140
+ applied: nullableBoolean(applied, "applied"),
141
+ atMs: base.atMs,
142
+ files: [...files],
143
+ kind: base.kind,
144
+ removed: nullableCount(removed, "removed"),
145
+ sequence: base.sequence,
146
+ };
147
+ }
148
+
149
+ if (kind === SESSION_EVENT_KIND.PLAN) {
150
+ if (!Array.isArray(steps)) {
151
+ throw new TypeError("steps must be an array.");
152
+ }
153
+
154
+ return {
155
+ atMs: base.atMs,
156
+ kind: base.kind,
157
+ sequence: base.sequence,
158
+ steps: steps.map((step) => ({
159
+ status: requiredString(step?.status, "step.status"),
160
+ text: requiredString(step?.text, "step.text"),
161
+ })),
162
+ };
163
+ }
164
+
165
+ if (kind === SESSION_EVENT_KIND.RAN) {
166
+ return {
167
+ atMs: base.atMs,
168
+ command: nullableString(command, "command"),
169
+ error: nullableString(error, "error"),
170
+ failed: nullableBoolean(failed, "failed"),
171
+ kind: base.kind,
172
+ sequence: base.sequence,
173
+ unclassified: requiredBoolean(unclassified, "unclassified"),
174
+ unextracted: requiredBoolean(unextracted, "unextracted"),
175
+ workdir: nullableString(workdir, "workdir"),
176
+ };
177
+ }
178
+
179
+ return {
180
+ atMs: base.atMs,
181
+ kind: base.kind,
182
+ sequence: base.sequence,
183
+ text: requiredString(text, "text"),
184
+ };
185
+ }
186
+
187
+ export function createSessionEventCoverage({
188
+ duplicates = 0,
189
+ oversized = 0,
190
+ recognized = 0,
191
+ skipped = 0,
192
+ total = 0,
193
+ unmapped = 0,
194
+ unmappedTypes = [],
195
+ unparseable = 0,
196
+ } = {}) {
197
+ return {
198
+ duplicates: requiredCount(duplicates, "coverage.duplicates"),
199
+ oversized: requiredCount(oversized, "coverage.oversized"),
200
+ recognized: requiredCount(recognized, "coverage.recognized"),
201
+ skipped: requiredCount(skipped, "coverage.skipped"),
202
+ total: requiredCount(total, "coverage.total"),
203
+ unmapped: requiredCount(unmapped, "coverage.unmapped"),
204
+ unmappedTypes: unmappedTypeEntries(unmappedTypes),
205
+ unparseable: requiredCount(unparseable, "coverage.unparseable"),
206
+ };
207
+ }
208
+
209
+ export function sessionEventCoveragePercent(coverage) {
210
+ const normalizedCoverage = createSessionEventCoverage(coverage);
211
+ const considered = normalizedCoverage.total - normalizedCoverage.skipped;
212
+ if (considered === 0) return 100;
213
+ return Math.round((normalizedCoverage.recognized / considered) * 100);
214
+ }
215
+
216
+ export function createSessionEventHeader({
217
+ cwd = null,
218
+ git = null,
219
+ model = null,
220
+ origin = null,
221
+ provider,
222
+ version = null,
223
+ } = {}) {
224
+ let normalizedGit = null;
225
+
226
+ if (git !== null) {
227
+ if (typeof git !== "object" || Array.isArray(git)) {
228
+ throw new TypeError("git must be an object or null.");
229
+ }
230
+
231
+ normalizedGit = {
232
+ branch: nullableString(git.branch ?? null, "git.branch"),
233
+ commit: nullableString(git.commit ?? null, "git.commit"),
234
+ repository: nullableString(git.repository ?? null, "git.repository"),
235
+ };
236
+ }
237
+
238
+ return {
239
+ cwd: nullableString(cwd, "cwd"),
240
+ git: normalizedGit,
241
+ model: nullableString(model, "model"),
242
+ origin: nullableString(origin, "origin"),
243
+ provider: requiredString(provider, "provider"),
244
+ version: nullableString(version, "version"),
245
+ };
246
+ }
247
+
248
+ export function createSessionEventsResult({
249
+ coverage,
250
+ events = [],
251
+ header,
252
+ reason = null,
253
+ window = {},
254
+ } = {}) {
255
+ const normalizedCoverage = createSessionEventCoverage(coverage);
256
+
257
+ if (
258
+ normalizedCoverage.recognized
259
+ + normalizedCoverage.skipped
260
+ + normalizedCoverage.unmapped
261
+ + normalizedCoverage.unparseable
262
+ + normalizedCoverage.oversized
263
+ !== normalizedCoverage.total
264
+ ) {
265
+ throw new TypeError("Session event coverage counts must add up to the total.");
266
+ }
267
+
268
+ if (!Array.isArray(events)) {
269
+ throw new TypeError("events must be an array.");
270
+ }
271
+
272
+ if (reason !== null && !SESSION_EVENT_REASONS.has(reason)) {
273
+ throw new TypeError(`Unsupported session event reason: ${reason}`);
274
+ }
275
+
276
+ if (reason !== null && events.length > 0) {
277
+ throw new TypeError("A session event reason requires an empty event list.");
278
+ }
279
+
280
+ const complete = window.complete ?? true;
281
+ const end = window.end ?? SESSION_EVENT_WINDOW_END.NEWEST;
282
+ const outcomesMayBeUnresolved = window.outcomesMayBeUnresolved ?? false;
283
+
284
+ if (typeof complete !== "boolean") {
285
+ throw new TypeError("window.complete must be a boolean.");
286
+ }
287
+
288
+ if (end !== null && !SESSION_EVENT_WINDOW_ENDS.has(end)) {
289
+ throw new TypeError(`Unsupported session event window end: ${end}`);
290
+ }
291
+
292
+ if (typeof outcomesMayBeUnresolved !== "boolean") {
293
+ throw new TypeError("window.outcomesMayBeUnresolved must be a boolean.");
294
+ }
295
+
296
+ return {
297
+ coverage: normalizedCoverage,
298
+ events: [...events],
299
+ header: createSessionEventHeader(header),
300
+ reason,
301
+ window: {
302
+ complete,
303
+ end,
304
+ outcomesMayBeUnresolved,
305
+ },
306
+ };
307
+ }
@@ -4,6 +4,11 @@ import { promises as fs } from "node:fs";
4
4
  import readline from "node:readline";
5
5
  import { finished } from "node:stream/promises";
6
6
 
7
+ export const DEFAULT_JSONL_MAX_LINE_BYTES = 8 * 1024 * 1024;
8
+
9
+ const JSONL_READ_CHUNK_BYTES = 64 * 1024;
10
+ const JSONL_STARTING_LINE_BYTES = 4 * 1024;
11
+
7
12
  function parseLine(raw, index) {
8
13
  try {
9
14
  return { index, parsed: JSON.parse(raw), raw };
@@ -35,6 +40,149 @@ export async function* readJsonlEntries(filePath) {
35
40
  }
36
41
  }
37
42
 
43
+ function appendLineBytes(state, bytes, maxLineBytes) {
44
+ if (state.oversized || bytes.length === 0) return;
45
+
46
+ if (state.length + bytes.length > maxLineBytes) {
47
+ state.length = 0;
48
+ state.oversized = true;
49
+ return;
50
+ }
51
+
52
+ const requiredBytes = state.length + bytes.length;
53
+ if (state.buffer.length < requiredBytes) {
54
+ const capacity = Math.min(
55
+ maxLineBytes,
56
+ Math.max(requiredBytes, state.buffer.length * 2),
57
+ );
58
+ const buffer = Buffer.allocUnsafe(capacity);
59
+ state.buffer.copy(buffer, 0, 0, state.length);
60
+ state.buffer = buffer;
61
+ }
62
+
63
+ bytes.copy(state.buffer, state.length);
64
+ state.length += bytes.length;
65
+ }
66
+
67
+ function createLineState(maxLineBytes) {
68
+ return {
69
+ buffer: Buffer.allocUnsafe(Math.min(JSONL_STARTING_LINE_BYTES, maxLineBytes)),
70
+ length: 0,
71
+ oversized: false,
72
+ };
73
+ }
74
+
75
+ function resetLineState(state) {
76
+ state.length = 0;
77
+ state.oversized = false;
78
+ }
79
+
80
+ function snapshotEntry(state, index) {
81
+ if (state.oversized) {
82
+ return {
83
+ index,
84
+ oversized: true,
85
+ parsed: null,
86
+ raw: null,
87
+ };
88
+ }
89
+
90
+ let bytes = state.buffer.subarray(0, state.length);
91
+ if (bytes.at(-1) === 13) bytes = bytes.subarray(0, -1);
92
+ if (bytes.length === 0) return null;
93
+
94
+ const entry = parseLine(bytes.toString("utf8"), index);
95
+ return {
96
+ index: entry.index,
97
+ oversized: false,
98
+ parsed: entry.parsed,
99
+ raw: entry.raw,
100
+ };
101
+ }
102
+
103
+ export async function visitJsonlSnapshotEntries(
104
+ filePath,
105
+ visit,
106
+ { maxLineBytes = DEFAULT_JSONL_MAX_LINE_BYTES } = {},
107
+ ) {
108
+ if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes < 1) {
109
+ throw new TypeError("maxLineBytes must be a positive integer.");
110
+ }
111
+
112
+ const handle = await fs.open(filePath, "r");
113
+
114
+ try {
115
+ const { size: snapshotBytes } = await handle.stat();
116
+ let complete = true;
117
+ let index = 0;
118
+ let offset = 0;
119
+ const buffer = Buffer.allocUnsafe(Math.min(JSONL_READ_CHUNK_BYTES, Math.max(1, snapshotBytes)));
120
+ const state = createLineState(maxLineBytes);
121
+ let stoppedEarly = false;
122
+
123
+ async function flushLine() {
124
+ const entry = snapshotEntry(state, index);
125
+ resetLineState(state);
126
+ if (!entry) return true;
127
+ index += 1;
128
+ return (await visit(entry)) !== false;
129
+ }
130
+
131
+ while (offset < snapshotBytes) {
132
+ const requestedBytes = Math.min(JSONL_READ_CHUNK_BYTES, snapshotBytes - offset);
133
+ let bytesRead;
134
+
135
+ try {
136
+ ({ bytesRead } = await handle.read(buffer, 0, requestedBytes, offset));
137
+ } catch (error) {
138
+ if (error?.code === "ENOENT") {
139
+ complete = false;
140
+ break;
141
+ }
142
+
143
+ throw error;
144
+ }
145
+
146
+ if (bytesRead === 0) {
147
+ complete = false;
148
+ break;
149
+ }
150
+
151
+ offset += bytesRead;
152
+ let start = 0;
153
+
154
+ while (start < bytesRead) {
155
+ const newline = buffer.indexOf(10, start);
156
+ const end = newline === -1 || newline >= bytesRead ? bytesRead : newline;
157
+ appendLineBytes(state, buffer.subarray(start, end), maxLineBytes);
158
+
159
+ if (newline === -1 || newline >= bytesRead) break;
160
+
161
+ if (!(await flushLine())) {
162
+ stoppedEarly = true;
163
+ break;
164
+ }
165
+
166
+ start = newline + 1;
167
+ }
168
+
169
+ if (stoppedEarly) break;
170
+ }
171
+
172
+ if (!stoppedEarly && (state.oversized || state.length > 0)) {
173
+ if (!(await flushLine())) stoppedEarly = true;
174
+ }
175
+
176
+ return {
177
+ complete: complete && !stoppedEarly,
178
+ snapshotBytes,
179
+ stoppedEarly,
180
+ };
181
+ } finally {
182
+ await handle.close();
183
+ }
184
+ }
185
+
38
186
  export async function inspectJsonlMatches(filePath, matches, { sampleLimit = 100 } = {}) {
39
187
  let count = 0;
40
188
  const samples = [];