sidekick-shared 0.27.0 → 0.27.2

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.
@@ -8,6 +8,17 @@
8
8
  * for Codex, its session/turn context) realpath-resolves to an allowed root
9
9
  * or somewhere inside one. Every bound stops cleanly and is reported in
10
10
  * `boundsHit`; nothing is silently truncated and no bound throws.
11
+ *
12
+ * Files are streamed in fixed-size chunks and every pass stops at a complete
13
+ * line, so a large or busy log is read across several calls: the cursor holds
14
+ * where each file stopped and the next call continues from there. A single
15
+ * record over `maxRecordBytes` is skipped without being buffered and reported
16
+ * in `exclusions`, the only way a record is permanently left out.
17
+ *
18
+ * A prompt can be written before its answer. The cursor then keeps the byte
19
+ * offset of that prompt's line and its answer-tracking state (never text), and
20
+ * the prompt is returned again under the same identity once its first
21
+ * answering call brings model or usage (`metadataStatus` says how settled).
11
22
  */
12
23
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
13
24
  if (k2 === undefined) k2 = k;
@@ -43,6 +54,7 @@ var __importStar = (this && this.__importStar) || (function () {
43
54
  };
44
55
  })();
45
56
  Object.defineProperty(exports, "__esModule", { value: true });
57
+ exports.__promptHistoryTesting = void 0;
46
58
  exports.collectPromptHistory = collectPromptHistory;
47
59
  const crypto_1 = require("crypto");
48
60
  const fs = __importStar(require("fs"));
@@ -55,10 +67,22 @@ const DEFAULT_BOUNDS = {
55
67
  maxSessions: 1000,
56
68
  maxFileBytes: 16 * 1024 * 1024,
57
69
  maxTotalBytes: 256 * 1024 * 1024,
70
+ maxRecordBytes: 16 * 1024 * 1024,
71
+ maxEntries: 10_000,
58
72
  deadlineMs: 30_000,
59
73
  };
74
+ const DEFAULT_READ_CHUNK_BYTES = 1024 * 1024;
75
+ const HEAD_BYTES = 4096;
76
+ const MAX_EXCLUSIONS = 1000;
60
77
  const CHECK_EVERY_LINES = 500;
61
78
  const ALL_PROVIDERS = ['claude-code', 'codex'];
79
+ let readChunkBytes = DEFAULT_READ_CHUNK_BYTES;
80
+ /** Test hooks; not part of the public entry points. */
81
+ exports.__promptHistoryTesting = {
82
+ setReadChunkBytes(bytes) {
83
+ readChunkBytes = bytes === null ? DEFAULT_READ_CHUNK_BYTES : Math.max(1, Math.floor(bytes));
84
+ },
85
+ };
62
86
  /** Collect human-typed prompts from Claude Code and Codex logs under the given roots. */
63
87
  async function collectPromptHistory(options) {
64
88
  const bounds = { ...DEFAULT_BOUNDS, ...definedBounds(options.bounds) };
@@ -69,13 +93,19 @@ async function collectPromptHistory(options) {
69
93
  sessionsSkippedUnchanged: 0,
70
94
  sessionsOutOfScope: 0,
71
95
  filesOverSizeLimit: 0,
96
+ sessionsWithBacklog: 0,
97
+ sessionsRewritten: 0,
72
98
  sessionsNotInteractive: 0,
99
+ recordsOverSizeLimit: 0,
100
+ recordsMalformed: 0,
73
101
  promptsMissingTimestamp: 0,
74
102
  promptsOutOfScope: 0,
75
103
  };
76
- const previous = options.cursor?.version === 1 ? options.cursor.sessions : {};
77
- const cursor = { version: 1, sessions: { ...previous } };
104
+ const { sessions: previous, next: previousNext } = acceptedCursor(options.cursor);
105
+ const cursor = { version: 3, sessions: { ...previous } };
78
106
  const entries = [];
107
+ const exclusions = [];
108
+ let hasMore = false;
79
109
  const shouldStop = () => {
80
110
  if (options.signal?.aborted)
81
111
  return 'aborted';
@@ -88,6 +118,8 @@ async function collectPromptHistory(options) {
88
118
  cursor,
89
119
  stats,
90
120
  boundsHit: [...boundsHit],
121
+ exclusions,
122
+ hasMore,
91
123
  });
92
124
  const realpaths = new Map();
93
125
  const { roots, listingRoots } = resolveRoots(options.workspacePaths, options.includeWorktrees !== false, realpaths);
@@ -95,13 +127,36 @@ async function collectPromptHistory(options) {
95
127
  return finish();
96
128
  const sinceMs = options.since ? options.since.getTime() : null;
97
129
  const providers = options.providers ?? ALL_PROVIDERS;
98
- const candidates = await listCandidates(providers, listingRoots, sinceMs);
99
- const context = { roots, sinceMs, stats, realpaths, shouldStop };
130
+ const candidates = rotate(await listCandidates(providers, listingRoots, sinceMs), previousNext);
131
+ const context = {
132
+ roots,
133
+ sinceMs,
134
+ stats,
135
+ realpaths,
136
+ bounds,
137
+ shouldStop,
138
+ entriesSoFar: () => entries.length,
139
+ exclude: (exclusion) => {
140
+ if (exclusions.length < MAX_EXCLUSIONS)
141
+ exclusions.push(exclusion);
142
+ },
143
+ };
100
144
  let totalBytes = 0;
145
+ let scannedAny = false;
146
+ // A call-wide stop remembers where it stopped so the next call starts there.
147
+ const stopAt = (candidate, bound) => {
148
+ boundsHit.add(bound);
149
+ cursor.next = candidate.key;
150
+ hasMore = true;
151
+ };
101
152
  for (const candidate of candidates) {
102
153
  const stop = shouldStop();
103
154
  if (stop) {
104
- boundsHit.add(stop);
155
+ stopAt(candidate, stop);
156
+ break;
157
+ }
158
+ if (scannedAny && entries.length >= bounds.maxEntries) {
159
+ stopAt(candidate, 'maxEntries');
105
160
  break;
106
161
  }
107
162
  let stat;
@@ -111,67 +166,67 @@ async function collectPromptHistory(options) {
111
166
  catch {
112
167
  continue;
113
168
  }
114
- const prior = previous[candidate.key];
115
- if (prior && prior.size === stat.size && prior.mtimeMs === stat.mtimeMs) {
169
+ const prior = validSession(previous[candidate.key]);
170
+ if (prior && isSettled(prior) && prior.size === stat.size && prior.mtimeMs === stat.mtimeMs) {
116
171
  stats.sessionsSkippedUnchanged++;
117
172
  continue;
118
173
  }
119
- // A file that shrank was rewritten; its old offsets and ordinals are meaningless.
120
- const resume = prior && prior.offset <= stat.size ? prior : undefined;
121
- const offset = resume?.offset ?? 0;
122
- const bytesToRead = stat.size - offset;
123
- if (resume?.state?.excluded) {
124
- cursor.sessions[candidate.key] = {
125
- ...resume,
126
- size: stat.size,
127
- mtimeMs: stat.mtimeMs,
128
- offset: stat.size,
129
- };
130
- stats.sessionsNotInteractive++;
131
- continue;
132
- }
133
- if (bytesToRead > bounds.maxFileBytes) {
134
- stats.filesOverSizeLimit++;
135
- boundsHit.add('maxFileBytes');
136
- continue;
137
- }
138
174
  if (stats.sessionsScanned >= bounds.maxSessions) {
139
- boundsHit.add('maxSessions');
175
+ stopAt(candidate, 'maxSessions');
140
176
  break;
141
177
  }
142
- if (totalBytes + bytesToRead > bounds.maxTotalBytes) {
143
- boundsHit.add('maxTotalBytes');
178
+ const remainingTotal = bounds.maxTotalBytes - totalBytes;
179
+ if (scannedAny && remainingTotal <= 0) {
180
+ stopAt(candidate, 'maxTotalBytes');
144
181
  break;
145
182
  }
146
- let buffer;
183
+ const budget = bounds.maxFileBytes <= remainingTotal
184
+ ? { bytes: bounds.maxFileBytes, bound: 'maxFileBytes', forceProgress: !scannedAny }
185
+ : {
186
+ bytes: Math.max(0, remainingTotal),
187
+ bound: 'maxTotalBytes',
188
+ forceProgress: !scannedAny,
189
+ };
190
+ stats.sessionsScanned++;
191
+ scannedAny = true;
192
+ let outcome;
147
193
  try {
148
- buffer = await readRange(candidate.filePath, offset, bytesToRead);
194
+ outcome = await scanFile(candidate, stat, prior, budget, context);
149
195
  }
150
196
  catch {
151
197
  continue;
152
198
  }
153
- totalBytes += buffer.length;
154
- stats.sessionsScanned++;
155
- const outcome = await scanBuffer(candidate, buffer, offset, resume, context);
156
- if (outcome.stoppedBy) {
157
- // Partially processed file: drop its entries and leave its cursor untouched.
158
- boundsHit.add(outcome.stoppedBy);
159
- break;
160
- }
199
+ totalBytes += outcome.bytesRead;
200
+ entries.push(...outcome.entries);
161
201
  if (outcome.notInteractive)
162
202
  stats.sessionsNotInteractive++;
163
203
  else if (outcome.inScope === 0 && outcome.outOfScope > 0)
164
204
  stats.sessionsOutOfScope++;
165
- entries.push(...outcome.entries);
166
- cursor.sessions[candidate.key] = {
167
- provider: candidate.provider,
168
- sessionId: candidate.sessionId,
169
- size: stat.size,
170
- mtimeMs: stat.mtimeMs,
171
- lastOrdinal: outcome.lastOrdinal,
172
- offset: outcome.offset,
173
- ...(outcome.state ? { state: outcome.state } : {}),
174
- };
205
+ if (outcome.session) {
206
+ cursor.sessions[candidate.key] = outcome.session;
207
+ if (outcome.session.backlog)
208
+ stats.sessionsWithBacklog++;
209
+ }
210
+ else {
211
+ // Rewritten under us: forget the file so the next call reads it from the start.
212
+ delete cursor.sessions[candidate.key];
213
+ stats.sessionsRewritten++;
214
+ hasMore = true;
215
+ }
216
+ if (outcome.stoppedBy === 'maxFileBytes' && outcome.progressed) {
217
+ // Per-file budget: move on so one large file cannot starve the others.
218
+ boundsHit.add('maxFileBytes');
219
+ stats.filesOverSizeLimit++;
220
+ hasMore = true;
221
+ }
222
+ else if (outcome.stoppedBy) {
223
+ // A file that could not move within its budget starts the next call, where
224
+ // the first file may always finish its first record.
225
+ if (outcome.stoppedBy === 'maxFileBytes')
226
+ stats.filesOverSizeLimit++;
227
+ stopAt(candidate, outcome.stoppedBy);
228
+ break;
229
+ }
175
230
  await yieldToEventLoop();
176
231
  }
177
232
  return finish();
@@ -187,6 +242,80 @@ function definedBounds(bounds) {
187
242
  }
188
243
  return result;
189
244
  }
245
+ function acceptedCursor(cursor) {
246
+ if (!cursor || ![1, 2, 3].includes(cursor.version))
247
+ return { sessions: {} };
248
+ const sessions = cursor.sessions;
249
+ return {
250
+ sessions: typeof sessions === 'object' && sessions !== null
251
+ ? sessions
252
+ : {},
253
+ ...(typeof cursor.next === 'string' ? { next: cursor.next } : {}),
254
+ };
255
+ }
256
+ function isOffset(value) {
257
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0;
258
+ }
259
+ function validSession(session) {
260
+ if (!session || typeof session !== 'object')
261
+ return undefined;
262
+ const valid = isOffset(session.offset) &&
263
+ Number.isInteger(session.lastOrdinal) &&
264
+ session.lastOrdinal >= -1 &&
265
+ typeof session.size === 'number' &&
266
+ typeof session.mtimeMs === 'number';
267
+ return valid ? session : undefined;
268
+ }
269
+ /** A session whose last pass reached the end and needs no migration. */
270
+ function isSettled(session) {
271
+ if (session.backlog || session.skipping)
272
+ return false;
273
+ if (session.state?.excluded)
274
+ return true;
275
+ if (!session.head)
276
+ return false;
277
+ return !session.pending || session.pending.answer !== undefined;
278
+ }
279
+ function resumePlan(session) {
280
+ if (!session)
281
+ return { kind: 'fresh' };
282
+ const pending = session.pending;
283
+ if (!pending)
284
+ return { kind: 'continue', session };
285
+ const basic = Number.isInteger(pending.ordinal) &&
286
+ pending.ordinal === session.lastOrdinal &&
287
+ isOffset(pending.offset) &&
288
+ pending.offset <= session.offset &&
289
+ typeof pending.emitted === 'string';
290
+ if (!basic)
291
+ return { kind: 'continue', session };
292
+ const answer = pending.answer;
293
+ if (answer === undefined) {
294
+ return session.skipping ? { kind: 'continue', session } : { kind: 'replay', session, pending };
295
+ }
296
+ const carried = typeof answer === 'object' &&
297
+ answer !== null &&
298
+ (answer.status === 'pending' || answer.status === 'provisional') &&
299
+ isOffset(pending.lineBytes) &&
300
+ pending.offset + pending.lineBytes < session.offset &&
301
+ typeof pending.timestamp === 'string';
302
+ return carried ? { kind: 'continue', session, carried: pending } : { kind: 'continue', session };
303
+ }
304
+ /** Digest of what a consumer would store for an entry besides its identity and text. */
305
+ function metadataDigest(entry) {
306
+ const usage = entry.usage
307
+ ? [
308
+ entry.usage.inputTokens ?? null,
309
+ entry.usage.outputTokens ?? null,
310
+ entry.usage.cacheReadTokens ?? null,
311
+ entry.usage.cacheWriteTokens ?? null,
312
+ ]
313
+ : null;
314
+ return (0, crypto_1.createHash)('sha256')
315
+ .update(JSON.stringify([entry.model ?? null, usage, entry.metadataStatus]))
316
+ .digest('hex')
317
+ .slice(0, 16);
318
+ }
190
319
  function compareEntries(left, right) {
191
320
  return (compareStrings(left.provider, right.provider) ||
192
321
  compareStrings(left.sessionId, right.sessionId) ||
@@ -195,6 +324,10 @@ function compareEntries(left, right) {
195
324
  function compareStrings(left, right) {
196
325
  return left < right ? -1 : left > right ? 1 : 0;
197
326
  }
327
+ function rotate(candidates, nextKey) {
328
+ const index = nextKey ? candidates.findIndex((candidate) => candidate.key === nextKey) : -1;
329
+ return index <= 0 ? candidates : [...candidates.slice(index), ...candidates.slice(0, index)];
330
+ }
198
331
  function realpathOrNull(input, cache) {
199
332
  const cached = cache.get(input);
200
333
  if (cached !== undefined)
@@ -289,65 +422,313 @@ async function listCandidates(providers, listingRoots, sinceMs) {
289
422
  function hashPath(filePath) {
290
423
  return (0, crypto_1.createHash)('sha256').update(filePath).digest('hex').slice(0, 12);
291
424
  }
292
- async function readRange(filePath, offset, length) {
425
+ function hashBytes(buffer) {
426
+ return (0, crypto_1.createHash)('sha256').update(buffer).digest('hex').slice(0, 32);
427
+ }
428
+ async function readAt(handle, offset, length) {
293
429
  const buffer = Buffer.alloc(length);
294
- if (length === 0)
295
- return buffer;
296
- const handle = await fs.promises.open(filePath, 'r');
297
- try {
298
- let read = 0;
299
- while (read < length) {
300
- const { bytesRead } = await handle.read(buffer, read, length - read, offset + read);
301
- if (bytesRead === 0)
302
- break;
303
- read += bytesRead;
304
- }
305
- return read === length ? buffer : buffer.subarray(0, read);
306
- }
307
- finally {
308
- await handle.close();
430
+ let read = 0;
431
+ while (read < length) {
432
+ const { bytesRead } = await handle.read(buffer, read, length - read, offset + read);
433
+ if (bytesRead === 0)
434
+ break;
435
+ read += bytesRead;
309
436
  }
437
+ return read === length ? buffer : buffer.subarray(0, read);
310
438
  }
311
439
  function yieldToEventLoop() {
312
440
  return new Promise((resolve) => setImmediate(resolve));
313
441
  }
314
- async function scanBuffer(candidate, buffer, startOffset, resume, context) {
315
- const lastNewline = buffer.lastIndexOf(0x0a);
316
- const complete = lastNewline >= 0 ? buffer.subarray(0, lastNewline + 1) : Buffer.alloc(0);
317
- const lines = complete.toString('utf8').split('\n');
318
- const scanner = candidate.provider === 'claude-code'
319
- ? new ClaudeLineScanner(candidate, resume, context)
320
- : new CodexLineScanner(candidate, resume, context);
321
- for (let index = 0; index < lines.length; index++) {
322
- if (index > 0 && index % CHECK_EVERY_LINES === 0) {
323
- await yieldToEventLoop();
324
- const stop = context.shouldStop();
325
- if (stop)
326
- return { ...scanner.outcome(startOffset), stoppedBy: stop };
327
- }
328
- const line = lines[index];
329
- if (!line.trim())
330
- continue;
331
- let record;
332
- try {
333
- record = JSON.parse(line);
442
+ /**
443
+ * Checks that the prefix and resume point recorded for a file still hold.
444
+ * Returns the head to store, or null when the file was truncated or rewritten.
445
+ */
446
+ async function verifyFile(handle, size, session) {
447
+ const headBytes = Math.min(HEAD_BYTES, size);
448
+ const head = await readAt(handle, 0, headBytes);
449
+ let bytesRead = head.length;
450
+ if (head.length !== headBytes)
451
+ return null;
452
+ if (session) {
453
+ if (session.offset > size)
454
+ return null;
455
+ const known = session.head;
456
+ if (known) {
457
+ if (!isOffset(known.bytes) || known.bytes > head.length)
458
+ return null;
459
+ if (hashBytes(head.subarray(0, known.bytes)) !== known.hash)
460
+ return null;
334
461
  }
335
- catch {
336
- continue;
462
+ if (session.offset > 0 && !session.skipping) {
463
+ const before = await readAt(handle, session.offset - 1, 1);
464
+ bytesRead += before.length;
465
+ if (before.length !== 1 || before[0] !== 0x0a)
466
+ return null;
337
467
  }
338
- if (typeof record !== 'object' || record === null)
339
- continue;
340
- if (scanner.accept(record) === 'excluded') {
341
- // Non-interactive Codex session: nothing in it will ever count.
468
+ }
469
+ return { head: { bytes: head.length, hash: hashBytes(head) }, bytesRead };
470
+ }
471
+ async function scanFile(candidate, stat, prior, budget, context) {
472
+ const handle = await fs.promises.open(candidate.filePath, 'r');
473
+ try {
474
+ let bytesRead = 0;
475
+ const verified = await verifyFile(handle, stat.size, prior);
476
+ let plan = resumePlan(prior);
477
+ let head = verified?.head;
478
+ if (verified) {
479
+ bytesRead += verified.bytesRead;
480
+ }
481
+ else {
482
+ // Offsets and ordinals of a rewritten file are meaningless: start over.
483
+ if (prior)
484
+ context.stats.sessionsRewritten++;
485
+ plan = { kind: 'fresh' };
486
+ const fresh = await verifyFile(handle, stat.size, undefined);
487
+ if (!fresh)
488
+ throw new Error('file changed while reading');
489
+ bytesRead += fresh.bytesRead;
490
+ head = fresh.head;
491
+ }
492
+ const session = plan.kind === 'fresh' ? undefined : plan.session;
493
+ if (session?.state?.excluded) {
342
494
  return {
343
- ...scanner.outcome(startOffset + buffer.length),
344
495
  entries: [],
496
+ progressed: true,
497
+ session: {
498
+ provider: candidate.provider,
499
+ sessionId: candidate.sessionId,
500
+ size: stat.size,
501
+ mtimeMs: stat.mtimeMs,
502
+ lastOrdinal: session.lastOrdinal,
503
+ offset: stat.size,
504
+ state: session.state,
505
+ ...(head ? { head } : {}),
506
+ },
507
+ bytesRead,
508
+ stoppedBy: null,
345
509
  notInteractive: true,
510
+ inScope: 0,
511
+ outOfScope: 0,
512
+ };
513
+ }
514
+ const scanner = candidate.provider === 'claude-code'
515
+ ? new ClaudeLineScanner(candidate, plan, context)
516
+ : new CodexLineScanner(candidate, plan, context);
517
+ const startOffset = plan.kind === 'fresh'
518
+ ? 0
519
+ : plan.kind === 'replay'
520
+ ? plan.pending.offset
521
+ : plan.session.offset;
522
+ const stream = await streamLines(handle, startOffset, stat.size, plan.kind === 'continue' && plan.session.skipping === true, budget, context, candidate, scanner);
523
+ bytesRead += stream.bytesRead;
524
+ if (stream.excluded) {
525
+ return {
526
+ entries: [],
527
+ progressed: true,
528
+ session: {
529
+ provider: candidate.provider,
530
+ sessionId: candidate.sessionId,
531
+ size: stat.size,
532
+ mtimeMs: stat.mtimeMs,
533
+ lastOrdinal: scanner.lastOrdinal(),
534
+ offset: stat.size,
535
+ state: scanner.resumeState() ?? { excluded: true },
536
+ ...(head ? { head } : {}),
537
+ },
538
+ bytesRead,
539
+ stoppedBy: null,
540
+ notInteractive: true,
541
+ inScope: 0,
542
+ outOfScope: 0,
543
+ };
544
+ }
545
+ const update = await scanner.carriedUpdate(handle);
546
+ bytesRead += update.bytesRead;
547
+ if (update.rewritten) {
548
+ return {
549
+ entries: [],
550
+ progressed: false,
551
+ session: undefined,
552
+ bytesRead,
346
553
  stoppedBy: null,
554
+ notInteractive: false,
555
+ inScope: 0,
556
+ outOfScope: 0,
347
557
  };
348
558
  }
559
+ const entries = scanner.returnedEntries();
560
+ if (update.entry)
561
+ entries.push(update.entry);
562
+ const state = scanner.resumeState();
563
+ const pending = scanner.pendingPrompt();
564
+ // A truncated read also keeps a backlog, so the next call re-verifies the file.
565
+ const backlog = stream.offset < stat.size && (stream.stoppedBy !== null || stream.truncated);
566
+ return {
567
+ entries,
568
+ progressed: stream.offset > startOffset || stream.skipping,
569
+ session: {
570
+ provider: candidate.provider,
571
+ sessionId: candidate.sessionId,
572
+ size: stat.size,
573
+ mtimeMs: stat.mtimeMs,
574
+ lastOrdinal: scanner.lastOrdinal(),
575
+ offset: stream.offset,
576
+ ...(state ? { state } : {}),
577
+ ...(pending ? { pending } : {}),
578
+ ...(backlog ? { backlog: true } : {}),
579
+ ...(stream.skipping ? { skipping: true } : {}),
580
+ ...(head ? { head } : {}),
581
+ },
582
+ bytesRead,
583
+ stoppedBy: backlog ? stream.stoppedBy : null,
584
+ notInteractive: false,
585
+ inScope: scanner.inScopeCount(),
586
+ outOfScope: scanner.outOfScopeCount(),
587
+ };
588
+ }
589
+ finally {
590
+ await handle.close();
591
+ }
592
+ }
593
+ /**
594
+ * Feeds complete JSONL lines from `[start, end)` to the scanner in fixed-size
595
+ * chunks. Lines split only at `\n`, which never occurs inside a multi-byte
596
+ * UTF-8 sequence, so decoding whole lines is exact whatever the chunk edges.
597
+ * A line longer than `maxRecordBytes` is dropped as it streams past.
598
+ */
599
+ async function streamLines(handle, start, end, resumeSkipping, budget, context, candidate, scanner) {
600
+ const { maxRecordBytes, maxEntries } = context.bounds;
601
+ const chunk = Buffer.allocUnsafe(Math.max(1, Math.min(readChunkBytes, end - start || 1)));
602
+ let position = start;
603
+ let committed = start;
604
+ let lineStart = start;
605
+ let skipping = resumeSkipping;
606
+ let pieces = [];
607
+ let pieceBytes = 0;
608
+ let bytesRead = 0;
609
+ let lines = 0;
610
+ let stoppedBy = null;
611
+ let truncated = false;
612
+ // The first file of a call always finishes its first record, so no budget can stall it.
613
+ const mayStop = () => !(budget.forceProgress && committed === start && !skipping);
614
+ const result = (excluded = false) => ({
615
+ offset: skipping ? position : committed,
616
+ skipping,
617
+ bytesRead,
618
+ stoppedBy,
619
+ truncated,
620
+ excluded,
621
+ });
622
+ while (position < end) {
623
+ const stop = context.shouldStop();
624
+ if (stop) {
625
+ stoppedBy = stop;
626
+ break;
627
+ }
628
+ const allowance = budget.bytes - bytesRead;
629
+ if (allowance <= 0 && mayStop()) {
630
+ stoppedBy = budget.bound;
631
+ break;
632
+ }
633
+ const want = Math.min(chunk.length, end - position, allowance > 0 ? allowance : chunk.length);
634
+ const { bytesRead: got } = await handle.read(chunk, 0, want, position);
635
+ if (got === 0) {
636
+ // Truncated while reading; the next call's verification sees the rewrite.
637
+ truncated = true;
638
+ break;
639
+ }
640
+ bytesRead += got;
641
+ const view = chunk.subarray(0, got);
642
+ let index = 0;
643
+ while (index < got) {
644
+ const newline = view.indexOf(0x0a, index);
645
+ const segmentEnd = newline === -1 ? got : newline;
646
+ if (!skipping) {
647
+ const segmentBytes = segmentEnd - index;
648
+ if (pieceBytes + segmentBytes > maxRecordBytes) {
649
+ skipping = true;
650
+ pieces = [];
651
+ pieceBytes = 0;
652
+ context.stats.recordsOverSizeLimit++;
653
+ context.exclude({
654
+ provider: candidate.provider,
655
+ sessionId: candidate.sessionId,
656
+ reason: 'oversizedRecord',
657
+ offset: lineStart,
658
+ });
659
+ }
660
+ else if (newline === -1 && segmentBytes > 0) {
661
+ // The chunk buffer is reused, so a partial line keeps a copy.
662
+ pieces.push(Buffer.from(view.subarray(index, segmentEnd)));
663
+ pieceBytes += segmentBytes;
664
+ }
665
+ }
666
+ if (newline === -1)
667
+ break;
668
+ if (skipping) {
669
+ skipping = false;
670
+ }
671
+ else {
672
+ let line;
673
+ if (pieces.length === 0) {
674
+ line = view.toString('utf8', index, newline);
675
+ }
676
+ else {
677
+ pieces.push(view.subarray(index, newline));
678
+ line = Buffer.concat(pieces).toString('utf8');
679
+ }
680
+ const lineOffset = lineStart;
681
+ const lineBytes = position + newline - lineStart;
682
+ if (acceptLine(line, lineOffset, lineBytes, scanner, context, candidate) === 'excluded') {
683
+ committed = position + newline + 1;
684
+ return result(true);
685
+ }
686
+ }
687
+ pieces = [];
688
+ pieceBytes = 0;
689
+ index = newline + 1;
690
+ committed = position + index;
691
+ lineStart = committed;
692
+ lines++;
693
+ if (mayStop() && context.entriesSoFar() + scanner.entryCount() >= maxEntries) {
694
+ stoppedBy = 'maxEntries';
695
+ position += index;
696
+ return result();
697
+ }
698
+ if (lines % CHECK_EVERY_LINES === 0) {
699
+ await yieldToEventLoop();
700
+ const stop = context.shouldStop();
701
+ if (stop) {
702
+ stoppedBy = stop;
703
+ position += index;
704
+ return result();
705
+ }
706
+ }
707
+ }
708
+ position += got;
349
709
  }
350
- return { ...scanner.outcome(startOffset + complete.length), stoppedBy: null };
710
+ return result();
711
+ }
712
+ function acceptLine(line, lineOffset, lineBytes, scanner, context, candidate) {
713
+ if (!line.trim())
714
+ return 'ok';
715
+ let record;
716
+ try {
717
+ record = JSON.parse(line);
718
+ }
719
+ catch {
720
+ context.stats.recordsMalformed++;
721
+ context.exclude({
722
+ provider: candidate.provider,
723
+ sessionId: candidate.sessionId,
724
+ reason: 'malformedRecord',
725
+ offset: lineOffset,
726
+ });
727
+ return 'ok';
728
+ }
729
+ if (typeof record !== 'object' || record === null)
730
+ return 'ok';
731
+ return scanner.accept(record, lineOffset, lineBytes);
351
732
  }
352
733
  /** Shared per-file prompt bookkeeping for both providers. */
353
734
  class LineScanner {
@@ -357,31 +738,159 @@ class LineScanner {
357
738
  nextOrdinal;
358
739
  inScope = 0;
359
740
  outOfScope = 0;
360
- pending = null;
361
- constructor(candidate, resume, context) {
741
+ /** Latest emitted prompt still collecting answer metadata. */
742
+ open = null;
743
+ /** The carried prompt once it has been closed during this pass. */
744
+ closedCarried = null;
745
+ /** Version 2 replay: the prompt already returned with this digest is not returned again. */
746
+ suppress = null;
747
+ constructor(candidate, plan, context) {
362
748
  this.candidate = candidate;
363
749
  this.context = context;
364
- this.nextOrdinal = (resume?.lastOrdinal ?? -1) + 1;
750
+ if (plan.kind === 'fresh') {
751
+ this.nextOrdinal = 0;
752
+ }
753
+ else if (plan.kind === 'replay') {
754
+ this.nextOrdinal = plan.pending.ordinal;
755
+ this.suppress = { ordinal: plan.pending.ordinal, emitted: plan.pending.emitted };
756
+ }
757
+ else {
758
+ this.nextOrdinal = plan.session.lastOrdinal + 1;
759
+ const carried = plan.carried;
760
+ if (carried?.answer &&
761
+ carried.lineBytes !== undefined &&
762
+ carried.lineBytes <= context.bounds.maxRecordBytes &&
763
+ carried.timestamp !== undefined) {
764
+ const answer = carried.answer;
765
+ const usage = answer.usage ? compactUsage(answer.usage) : undefined;
766
+ this.open = {
767
+ entry: {
768
+ provider: candidate.provider,
769
+ sessionId: candidate.sessionId,
770
+ ordinal: carried.ordinal,
771
+ timestamp: carried.timestamp,
772
+ text: '',
773
+ cwd: '',
774
+ ...(typeof answer.model === 'string' ? { model: answer.model } : {}),
775
+ ...(usage ? { usage } : {}),
776
+ metadataStatus: answer.status,
777
+ },
778
+ lineOffset: carried.offset,
779
+ lineBytes: carried.lineBytes,
780
+ ...(carried.state ? { stateBefore: { ...carried.state } } : {}),
781
+ answerId: typeof answer.id === 'string' ? answer.id : null,
782
+ carried: { emitted: carried.emitted },
783
+ };
784
+ }
785
+ }
365
786
  }
366
- state() {
787
+ resumeState() {
367
788
  return undefined;
368
789
  }
369
- outcome(offset) {
370
- const state = this.state();
790
+ lastOrdinal() {
791
+ return this.nextOrdinal - 1;
792
+ }
793
+ entryCount() {
794
+ return this.entries.length;
795
+ }
796
+ inScopeCount() {
797
+ return this.inScope;
798
+ }
799
+ outOfScopeCount() {
800
+ return this.outOfScope;
801
+ }
802
+ returnedEntries() {
803
+ const suppress = this.suppress;
804
+ if (!suppress)
805
+ return [...this.entries];
806
+ // A replayed prompt is returned again only when its metadata changed.
807
+ return this.entries.filter((entry) => entry.ordinal !== suppress.ordinal || metadataDigest(entry) !== suppress.emitted);
808
+ }
809
+ pendingPrompt() {
810
+ const open = this.open;
811
+ if (!open)
812
+ return undefined;
813
+ const entry = open.entry;
814
+ const status = entry.metadataStatus === 'provisional' ? 'provisional' : 'pending';
815
+ const answer = {
816
+ status,
817
+ ...(open.answerId !== null ? { id: open.answerId } : {}),
818
+ ...(entry.model ? { model: entry.model } : {}),
819
+ ...(entry.usage ? { usage: { ...entry.usage } } : {}),
820
+ };
371
821
  return {
372
- entries: this.entries,
373
- lastOrdinal: this.nextOrdinal - 1,
374
- offset,
375
- ...(state ? { state } : {}),
376
- inScope: this.inScope,
377
- outOfScope: this.outOfScope,
378
- notInteractive: false,
822
+ ordinal: entry.ordinal,
823
+ offset: open.lineOffset,
824
+ lineBytes: open.lineBytes,
825
+ timestamp: entry.timestamp,
826
+ emitted: metadataDigest(entry),
827
+ ...(open.stateBefore ? { state: { ...open.stateBefore } } : {}),
828
+ answer,
379
829
  };
380
830
  }
831
+ /**
832
+ * The carried prompt returned again when its metadata changed in this pass.
833
+ * Its text is re-read from its own line, which must still match exactly.
834
+ */
835
+ async carriedUpdate(handle) {
836
+ const carried = this.closedCarried ?? (this.open?.carried ? this.open : null);
837
+ if (!carried?.carried)
838
+ return { bytesRead: 0, rewritten: false };
839
+ const entry = carried.entry;
840
+ if (metadataDigest(entry) === carried.carried.emitted)
841
+ return { bytesRead: 0, rewritten: false };
842
+ const raw = await readAt(handle, carried.lineOffset, carried.lineBytes + 1);
843
+ const rewritten = { bytesRead: raw.length, rewritten: true };
844
+ if (raw.length !== carried.lineBytes + 1 || raw[carried.lineBytes] !== 0x0a)
845
+ return rewritten;
846
+ let record;
847
+ try {
848
+ record = JSON.parse(raw.toString('utf8', 0, carried.lineBytes));
849
+ }
850
+ catch {
851
+ return rewritten;
852
+ }
853
+ if (typeof record !== 'object' || record === null)
854
+ return rewritten;
855
+ const row = record;
856
+ const text = this.promptTextOf(row);
857
+ if (text === null || row.timestamp !== entry.timestamp)
858
+ return rewritten;
859
+ const place = this.promptPlaceOf(row, carried.stateBefore);
860
+ const scoped = scopedCwd(place.cwd, this.context.roots, this.context.realpaths);
861
+ if (!scoped) {
862
+ // The directory no longer resolves inside the roots: fail closed.
863
+ this.context.stats.promptsOutOfScope++;
864
+ return { bytesRead: raw.length, rewritten: false };
865
+ }
866
+ if (carried === this.open)
867
+ carried.carried = { emitted: metadataDigest(entry) };
868
+ return {
869
+ entry: {
870
+ ...entry,
871
+ text,
872
+ cwd: scoped,
873
+ ...(place.gitBranch ? { gitBranch: place.gitBranch } : {}),
874
+ ...(entry.usage ? { usage: { ...entry.usage } } : {}),
875
+ },
876
+ bytesRead: raw.length,
877
+ rewritten: false,
878
+ };
879
+ }
880
+ /** The open prompt's answer is complete, or will never arrive. */
881
+ closeOpen() {
882
+ const open = this.open;
883
+ if (!open)
884
+ return;
885
+ open.entry.metadataStatus = 'final';
886
+ if (open.carried)
887
+ this.closedCarried = open;
888
+ this.open = null;
889
+ }
381
890
  /** Assign an ordinal to a human prompt and emit it when in scope. */
382
- recordPrompt(text, timestamp, cwd, gitBranch) {
891
+ recordPrompt(text, timestamp, cwd, gitBranch, lineOffset, lineBytes, stateBefore) {
383
892
  const ordinal = this.nextOrdinal++;
384
- this.pending = null;
893
+ this.closeOpen();
385
894
  if (typeof timestamp !== 'string' || Number.isNaN(Date.parse(timestamp))) {
386
895
  this.context.stats.promptsMissingTimestamp++;
387
896
  return;
@@ -403,9 +912,16 @@ class LineScanner {
403
912
  text,
404
913
  cwd: scoped,
405
914
  ...(gitBranch ? { gitBranch } : {}),
915
+ metadataStatus: 'pending',
406
916
  };
407
917
  this.entries.push(entry);
408
- this.pending = entry;
918
+ this.open = {
919
+ entry,
920
+ lineOffset,
921
+ lineBytes,
922
+ answerId: null,
923
+ ...(stateBefore ? { stateBefore } : {}),
924
+ };
409
925
  }
410
926
  }
411
927
  function transcriptMessage(candidate, record, role, content, extra, eventIndex) {
@@ -443,54 +959,85 @@ function compactUsage(usage) {
443
959
  }
444
960
  return Object.keys(result).length > 0 ? result : undefined;
445
961
  }
962
+ function claudeUserText(candidate, record, message, blocks, eventIndex) {
963
+ const origin = record.origin;
964
+ const canonical = transcriptMessage(candidate, record, typeof message.role === 'string' ? message.role : 'user', blocks, {
965
+ entrypoint: stringField(record.entrypoint),
966
+ isMeta: record.isMeta === true ? true : undefined,
967
+ isSidechain: record.isSidechain === true ? true : undefined,
968
+ isCompactSummary: record.isCompactSummary === true ? true : undefined,
969
+ originKind: origin && typeof origin.kind === 'string' ? origin.kind : undefined,
970
+ promptSource: stringField(record.promptSource),
971
+ }, eventIndex);
972
+ return (0, humanPrompt_1.humanPromptText)(canonical, 'claude-code');
973
+ }
446
974
  class ClaudeLineScanner extends LineScanner {
447
975
  lineIndex = 0;
448
- answerId = null;
449
- accept(record) {
976
+ promptTextOf(record) {
977
+ const message = record.message;
978
+ if (record.type !== 'user' || typeof message !== 'object' || message === null)
979
+ return null;
980
+ return claudeUserText(this.candidate, record, message, claudeBlocks(message.content), 0);
981
+ }
982
+ promptPlaceOf(record) {
983
+ return { cwd: record.cwd, gitBranch: stringField(record.gitBranch) };
984
+ }
985
+ accept(record, lineOffset, lineBytes) {
450
986
  const index = this.lineIndex++;
451
987
  const message = record.message;
452
988
  if (typeof message !== 'object' || message === null)
453
989
  return 'ok';
454
990
  if (record.type === 'user') {
455
- const origin = record.origin;
456
- const canonical = transcriptMessage(this.candidate, record, typeof message.role === 'string' ? message.role : 'user', claudeBlocks(message.content), {
457
- entrypoint: stringField(record.entrypoint),
458
- isMeta: record.isMeta === true ? true : undefined,
459
- isSidechain: record.isSidechain === true ? true : undefined,
460
- isCompactSummary: record.isCompactSummary === true ? true : undefined,
461
- originKind: origin && typeof origin.kind === 'string' ? origin.kind : undefined,
462
- promptSource: stringField(record.promptSource),
463
- }, index);
464
- const text = (0, humanPrompt_1.humanPromptText)(canonical, 'claude-code');
465
- if (text === null)
991
+ const blocks = claudeBlocks(message.content);
992
+ const text = claudeUserText(this.candidate, record, message, blocks, index);
993
+ if (text === null) {
994
+ // A tool result means the first answering call already finished.
995
+ if (this.open !== null &&
996
+ this.open.answerId !== null &&
997
+ record.isSidechain !== true &&
998
+ blocks.some((block) => block.type === 'tool_result')) {
999
+ this.closeOpen();
1000
+ }
466
1001
  return 'ok';
467
- this.answerId = null;
468
- this.recordPrompt(text, record.timestamp, record.cwd, stringField(record.gitBranch));
1002
+ }
1003
+ this.recordPrompt(text, record.timestamp, record.cwd, stringField(record.gitBranch), lineOffset, lineBytes);
469
1004
  return 'ok';
470
1005
  }
471
- if (record.type === 'assistant' && this.pending && record.isSidechain !== true) {
472
- const id = stringField(message.id) ?? `line:${index}`;
473
- if (this.answerId === null)
474
- this.answerId = id;
475
- if (id !== this.answerId) {
476
- this.pending = null;
477
- return 'ok';
478
- }
479
- const model = stringField(message.model);
480
- if (model && model !== '<synthetic>')
481
- this.pending.model = model;
482
- const usage = message.usage;
483
- if (usage && typeof usage === 'object') {
484
- const mapped = compactUsage({
485
- inputTokens: tokenCount(usage.input_tokens),
486
- outputTokens: tokenCount(usage.output_tokens),
487
- cacheReadTokens: tokenCount(usage.cache_read_input_tokens),
488
- cacheWriteTokens: tokenCount(usage.cache_creation_input_tokens),
489
- });
490
- if (mapped)
491
- this.pending.usage = mapped;
492
- }
1006
+ const open = this.open;
1007
+ if (record.type !== 'assistant' || !open || record.isSidechain === true)
1008
+ return 'ok';
1009
+ // Synthetic records (API errors, interrupts) are not model calls.
1010
+ if (message.model === '<synthetic>')
1011
+ return 'ok';
1012
+ const id = stringField(message.id) ?? `line:${lineOffset}`;
1013
+ if (open.answerId === null)
1014
+ open.answerId = id;
1015
+ if (id !== open.answerId) {
1016
+ // A later tool-loop call: the first answering call is over and keeps its attribution.
1017
+ this.closeOpen();
1018
+ return 'ok';
1019
+ }
1020
+ const entry = open.entry;
1021
+ const model = stringField(message.model);
1022
+ if (model)
1023
+ entry.model = model;
1024
+ const usage = message.usage;
1025
+ if (usage && typeof usage === 'object') {
1026
+ // Split records of one call repeat its usage; the latest replaces, never adds.
1027
+ const mapped = compactUsage({
1028
+ inputTokens: tokenCount(usage.input_tokens),
1029
+ outputTokens: tokenCount(usage.output_tokens),
1030
+ cacheReadTokens: tokenCount(usage.cache_read_input_tokens),
1031
+ cacheWriteTokens: tokenCount(usage.cache_creation_input_tokens),
1032
+ });
1033
+ if (mapped)
1034
+ entry.usage = mapped;
493
1035
  }
1036
+ // Claude Code writes the call's final stop_reason and usage on every split record.
1037
+ if (stringField(message.stop_reason))
1038
+ this.closeOpen();
1039
+ else
1040
+ entry.metadataStatus = 'provisional';
494
1041
  return 'ok';
495
1042
  }
496
1043
  }
@@ -521,20 +1068,36 @@ function claudeBlocks(content) {
521
1068
  }
522
1069
  class CodexLineScanner extends LineScanner {
523
1070
  lineIndex = 0;
524
- resumeState;
525
- constructor(candidate, resume, context) {
526
- super(candidate, resume, context);
527
- this.resumeState = { ...(resume?.state ?? {}) };
1071
+ state;
1072
+ constructor(candidate, plan, context) {
1073
+ super(candidate, plan, context);
1074
+ // A replay restarts at the open prompt's line, so it needs the state from just before it.
1075
+ const initial = plan.kind === 'replay'
1076
+ ? plan.pending.state
1077
+ : plan.kind === 'continue'
1078
+ ? plan.session.state
1079
+ : undefined;
1080
+ this.state = { ...(initial ?? {}) };
528
1081
  }
529
- state() {
530
- return { ...this.resumeState };
1082
+ resumeState() {
1083
+ return { ...this.state };
531
1084
  }
532
- accept(record) {
1085
+ promptTextOf(record) {
1086
+ const payload = record.payload;
1087
+ if (!isCodexUserMessage(record, payload))
1088
+ return null;
1089
+ const canonical = transcriptMessage(this.candidate, record, 'user', codexBlocks(payload.content), {}, 0);
1090
+ return (0, humanPrompt_1.humanPromptText)(canonical, 'codex');
1091
+ }
1092
+ promptPlaceOf(_record, stateBefore) {
1093
+ return { cwd: stateBefore?.cwd, gitBranch: stateBefore?.gitBranch };
1094
+ }
1095
+ accept(record, lineOffset, lineBytes) {
533
1096
  const index = this.lineIndex++;
534
1097
  const payload = record.payload;
535
1098
  if (typeof payload !== 'object' || payload === null)
536
1099
  return 'ok';
537
- const state = this.resumeState;
1100
+ const state = this.state;
538
1101
  if (record.type === 'session_meta') {
539
1102
  // Forked sessions embed their parent's session_meta after their own; only the first counts.
540
1103
  if (state.meta)
@@ -559,7 +1122,7 @@ class CodexLineScanner extends LineScanner {
559
1122
  state.model = model;
560
1123
  return 'ok';
561
1124
  }
562
- if (record.type === 'response_item' && payload.type === 'message' && payload.role === 'user') {
1125
+ if (isCodexUserMessage(record, payload)) {
563
1126
  // Without session metadata the source (and thus interactivity) is unknown: fail closed.
564
1127
  if (!state.meta) {
565
1128
  state.excluded = true;
@@ -569,10 +1132,14 @@ class CodexLineScanner extends LineScanner {
569
1132
  const text = (0, humanPrompt_1.humanPromptText)(canonical, 'codex');
570
1133
  if (text === null)
571
1134
  return 'ok';
572
- this.recordPrompt(text, record.timestamp, state.cwd, state.gitBranch);
1135
+ this.recordPrompt(text, record.timestamp, state.cwd, state.gitBranch, lineOffset, lineBytes, {
1136
+ ...state,
1137
+ });
573
1138
  return 'ok';
574
1139
  }
575
- if (record.type === 'event_msg' && payload.type === 'token_count' && this.pending) {
1140
+ const open = this.open;
1141
+ if (record.type === 'event_msg' && payload.type === 'token_count' && open) {
1142
+ // The first token_count after the prompt is the first answering call, complete.
576
1143
  const info = payload.info;
577
1144
  const last = info && typeof info === 'object' ? info.last_token_usage : undefined;
578
1145
  if (!last || typeof last !== 'object')
@@ -586,14 +1153,21 @@ class CodexLineScanner extends LineScanner {
586
1153
  cacheWriteTokens: tokenCount(last.cache_write_input_tokens),
587
1154
  });
588
1155
  if (mapped)
589
- this.pending.usage = mapped;
1156
+ open.entry.usage = mapped;
590
1157
  if (state.model)
591
- this.pending.model = state.model;
592
- this.pending = null;
1158
+ open.entry.model = state.model;
1159
+ this.closeOpen();
593
1160
  }
594
1161
  return 'ok';
595
1162
  }
596
1163
  }
1164
+ function isCodexUserMessage(record, payload) {
1165
+ return (record.type === 'response_item' &&
1166
+ typeof payload === 'object' &&
1167
+ payload !== null &&
1168
+ payload.type === 'message' &&
1169
+ payload.role === 'user');
1170
+ }
597
1171
  function codexBlocks(content) {
598
1172
  if (!Array.isArray(content))
599
1173
  return [];