session-steward 0.4.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.
@@ -1,3 +1,4 @@
1
+ import { readSessionEvents } from "./events.mjs";
1
2
  import {
2
3
  assertDeepCleanupSupported,
3
4
  diagnoseStorageCompatibility,
@@ -38,6 +39,7 @@ export const codexProvider = Object.freeze({
38
39
  loadSessionStore,
39
40
  planSessionDeletion,
40
41
  preflightSessionDeletion,
42
+ readSessionEvents,
41
43
  restoreSessionDeletionBackup,
42
44
  verifySessionDeletion,
43
45
  });
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;
@@ -811,10 +844,38 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
811
844
  return;
812
845
  }
813
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
+
814
875
  if (request.method === "GET" && requestUrl.pathname.startsWith("/api/sessions/")) {
815
876
  const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
816
877
  const provider = getProvider(providerId);
817
- const id = decodeURIComponent(requestUrl.pathname.slice("/api/sessions/".length));
878
+ const id = getSessionId(decodeURIComponent(requestUrl.pathname.slice("/api/sessions/".length)));
818
879
  const record = await provider.getSessionRecord({
819
880
  ...providerOptions(providerId, settings.getHome(providerId)),
820
881
  id,
@@ -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 = [];