sidekick-shared 0.27.1 → 0.27.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +35 -8
  3. package/dist/aggregation/EventAggregator.d.ts +12 -2
  4. package/dist/aggregation/EventAggregator.js +49 -19
  5. package/dist/browser.d.ts +2 -1
  6. package/dist/browser.js +9 -2
  7. package/dist/extractors/tokenUsage.js +1 -0
  8. package/dist/formatters/sessionDump.d.ts +10 -2
  9. package/dist/formatters/sessionDump.js +45 -23
  10. package/dist/index.d.ts +8 -4
  11. package/dist/index.js +31 -10
  12. package/dist/node.d.ts +3 -1
  13. package/dist/node.js +8 -2
  14. package/dist/parsers/codexParser.d.ts +22 -2
  15. package/dist/parsers/codexParser.js +64 -5
  16. package/dist/parsers/subagentScanner.d.ts +9 -0
  17. package/dist/parsers/subagentScanner.js +45 -12
  18. package/dist/parsers/subagentStatsCache.d.ts +17 -0
  19. package/dist/parsers/subagentStatsCache.js +82 -0
  20. package/dist/parsers/subagentTraceParser.js +19 -10
  21. package/dist/promptHistory.d.ts +82 -10
  22. package/dist/promptHistory.js +654 -183
  23. package/dist/providers/claudeCode.js +8 -41
  24. package/dist/providers/codex.js +28 -14
  25. package/dist/providers/codexDatabase.d.ts +16 -1
  26. package/dist/providers/codexDatabase.js +49 -1
  27. package/dist/providers/openCode.js +30 -4
  28. package/dist/report/htmlReportGenerator.js +33 -11
  29. package/dist/report/sessionReportInputs.d.ts +3 -1
  30. package/dist/report/sessionReportInputs.js +2 -0
  31. package/dist/report/types.d.ts +5 -0
  32. package/dist/schemas/sessionEvent.d.ts +30 -6
  33. package/dist/schemas/sessionEvent.js +7 -1
  34. package/dist/schemas/stateFile.d.ts +2 -0
  35. package/dist/schemas/stateFile.js +1 -0
  36. package/dist/sessionTokenTotals.d.ts +46 -0
  37. package/dist/sessionTokenTotals.js +138 -0
  38. package/dist/stateFile.d.ts +7 -0
  39. package/dist/tokenSummary.d.ts +27 -1
  40. package/dist/tokenSummary.js +50 -3
  41. package/dist/types/codex.d.ts +4 -0
  42. package/dist/types/sessionEvent.d.ts +22 -0
  43. package/dist/usage/claudeUsageDedupe.d.ts +53 -0
  44. package/dist/usage/claudeUsageDedupe.js +195 -0
  45. package/dist/usage/subagentUsage.d.ts +13 -0
  46. package/dist/usage/subagentUsage.js +21 -0
  47. package/dist/usage/usageEvents.d.ts +11 -2
  48. package/dist/usage/usageEvents.js +79 -8
  49. package/dist/watchers/eventBridge.js +5 -0
  50. package/dist/watchers/jsonlWatcher.d.ts +5 -0
  51. package/dist/watchers/jsonlWatcher.js +40 -5
  52. package/dist/watchers/types.d.ts +5 -0
  53. package/package.json +1 -1
@@ -9,9 +9,15 @@
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
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
+ *
12
18
  * A prompt can be written before its answer. The cursor then keeps the byte
13
- * offset of that prompt's line (not its text), the next call replays from it,
14
- * and the prompt is returned again under the same identity once its first
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
15
21
  * answering call brings model or usage (`metadataStatus` says how settled).
16
22
  */
17
23
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
@@ -48,6 +54,7 @@ var __importStar = (this && this.__importStar) || (function () {
48
54
  };
49
55
  })();
50
56
  Object.defineProperty(exports, "__esModule", { value: true });
57
+ exports.__promptHistoryTesting = void 0;
51
58
  exports.collectPromptHistory = collectPromptHistory;
52
59
  const crypto_1 = require("crypto");
53
60
  const fs = __importStar(require("fs"));
@@ -60,10 +67,22 @@ const DEFAULT_BOUNDS = {
60
67
  maxSessions: 1000,
61
68
  maxFileBytes: 16 * 1024 * 1024,
62
69
  maxTotalBytes: 256 * 1024 * 1024,
70
+ maxRecordBytes: 16 * 1024 * 1024,
71
+ maxEntries: 10_000,
63
72
  deadlineMs: 30_000,
64
73
  };
74
+ const DEFAULT_READ_CHUNK_BYTES = 1024 * 1024;
75
+ const HEAD_BYTES = 4096;
76
+ const MAX_EXCLUSIONS = 1000;
65
77
  const CHECK_EVERY_LINES = 500;
66
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
+ };
67
86
  /** Collect human-typed prompts from Claude Code and Codex logs under the given roots. */
68
87
  async function collectPromptHistory(options) {
69
88
  const bounds = { ...DEFAULT_BOUNDS, ...definedBounds(options.bounds) };
@@ -74,13 +93,19 @@ async function collectPromptHistory(options) {
74
93
  sessionsSkippedUnchanged: 0,
75
94
  sessionsOutOfScope: 0,
76
95
  filesOverSizeLimit: 0,
96
+ sessionsWithBacklog: 0,
97
+ sessionsRewritten: 0,
77
98
  sessionsNotInteractive: 0,
99
+ recordsOverSizeLimit: 0,
100
+ recordsMalformed: 0,
78
101
  promptsMissingTimestamp: 0,
79
102
  promptsOutOfScope: 0,
80
103
  };
81
- const previous = acceptedCursorSessions(options.cursor);
82
- const cursor = { version: 2, sessions: { ...previous } };
104
+ const { sessions: previous, next: previousNext } = acceptedCursor(options.cursor);
105
+ const cursor = { version: 3, sessions: { ...previous } };
83
106
  const entries = [];
107
+ const exclusions = [];
108
+ let hasMore = false;
84
109
  const shouldStop = () => {
85
110
  if (options.signal?.aborted)
86
111
  return 'aborted';
@@ -93,6 +118,8 @@ async function collectPromptHistory(options) {
93
118
  cursor,
94
119
  stats,
95
120
  boundsHit: [...boundsHit],
121
+ exclusions,
122
+ hasMore,
96
123
  });
97
124
  const realpaths = new Map();
98
125
  const { roots, listingRoots } = resolveRoots(options.workspacePaths, options.includeWorktrees !== false, realpaths);
@@ -100,13 +127,36 @@ async function collectPromptHistory(options) {
100
127
  return finish();
101
128
  const sinceMs = options.since ? options.since.getTime() : null;
102
129
  const providers = options.providers ?? ALL_PROVIDERS;
103
- const candidates = await listCandidates(providers, listingRoots, sinceMs);
104
- 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
+ };
105
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
+ };
106
152
  for (const candidate of candidates) {
107
153
  const stop = shouldStop();
108
154
  if (stop) {
109
- boundsHit.add(stop);
155
+ stopAt(candidate, stop);
156
+ break;
157
+ }
158
+ if (scannedAny && entries.length >= bounds.maxEntries) {
159
+ stopAt(candidate, 'maxEntries');
110
160
  break;
111
161
  }
112
162
  let stat;
@@ -116,81 +166,67 @@ async function collectPromptHistory(options) {
116
166
  catch {
117
167
  continue;
118
168
  }
119
- const prior = previous[candidate.key];
120
- 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) {
121
171
  stats.sessionsSkippedUnchanged++;
122
172
  continue;
123
173
  }
124
- // A file that shrank was rewritten; its old offsets and ordinals are meaningless.
125
- const resume = prior && prior.offset <= stat.size ? prior : undefined;
126
- // Replay from the open prompt's line so late answer metadata can reach it.
127
- let replay = validPending(resume);
128
- let offset = replay?.offset ?? resume?.offset ?? 0;
129
- if (resume?.state?.excluded) {
130
- cursor.sessions[candidate.key] = {
131
- ...resume,
132
- size: stat.size,
133
- mtimeMs: stat.mtimeMs,
134
- offset: stat.size,
135
- };
136
- stats.sessionsNotInteractive++;
137
- continue;
138
- }
139
- if (replay && resume && stat.size - offset > bounds.maxFileBytes) {
140
- // Too far to replay: stop waiting on that prompt rather than stall the file.
141
- replay = undefined;
142
- offset = resume.offset;
143
- }
144
- const bytesToRead = stat.size - offset;
145
- if (bytesToRead > bounds.maxFileBytes) {
146
- stats.filesOverSizeLimit++;
147
- boundsHit.add('maxFileBytes');
148
- continue;
149
- }
150
174
  if (stats.sessionsScanned >= bounds.maxSessions) {
151
- boundsHit.add('maxSessions');
175
+ stopAt(candidate, 'maxSessions');
152
176
  break;
153
177
  }
154
- if (totalBytes + bytesToRead > bounds.maxTotalBytes) {
155
- boundsHit.add('maxTotalBytes');
178
+ const remainingTotal = bounds.maxTotalBytes - totalBytes;
179
+ if (scannedAny && remainingTotal <= 0) {
180
+ stopAt(candidate, 'maxTotalBytes');
156
181
  break;
157
182
  }
158
- 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;
159
193
  try {
160
- buffer = await readRange(candidate.filePath, offset, bytesToRead);
194
+ outcome = await scanFile(candidate, stat, prior, budget, context);
161
195
  }
162
196
  catch {
163
197
  continue;
164
198
  }
165
- totalBytes += buffer.length;
166
- stats.sessionsScanned++;
167
- const outcome = await scanBuffer(candidate, buffer, offset, { resume, replay }, context);
168
- if (outcome.stoppedBy) {
169
- // Partially processed file: drop its entries and leave its cursor untouched.
170
- boundsHit.add(outcome.stoppedBy);
171
- break;
172
- }
199
+ totalBytes += outcome.bytesRead;
200
+ entries.push(...outcome.entries);
173
201
  if (outcome.notInteractive)
174
202
  stats.sessionsNotInteractive++;
175
203
  else if (outcome.inScope === 0 && outcome.outOfScope > 0)
176
204
  stats.sessionsOutOfScope++;
177
- for (const entry of outcome.entries) {
178
- // A replayed prompt is returned again only when its metadata changed.
179
- if (replay && entry.ordinal === replay.ordinal && metadataDigest(entry) === replay.emitted) {
180
- continue;
181
- }
182
- entries.push(entry);
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;
183
229
  }
184
- cursor.sessions[candidate.key] = {
185
- provider: candidate.provider,
186
- sessionId: candidate.sessionId,
187
- size: stat.size,
188
- mtimeMs: stat.mtimeMs,
189
- lastOrdinal: outcome.lastOrdinal,
190
- offset: outcome.offset,
191
- ...(outcome.state ? { state: outcome.state } : {}),
192
- ...(outcome.pending ? { pending: outcome.pending } : {}),
193
- };
194
230
  await yieldToEventLoop();
195
231
  }
196
232
  return finish();
@@ -206,25 +242,64 @@ function definedBounds(bounds) {
206
242
  }
207
243
  return result;
208
244
  }
209
- function acceptedCursorSessions(cursor) {
210
- if (!cursor || (cursor.version !== 1 && cursor.version !== 2))
211
- return {};
245
+ function acceptedCursor(cursor) {
246
+ if (!cursor || ![1, 2, 3].includes(cursor.version))
247
+ return { sessions: {} };
212
248
  const sessions = cursor.sessions;
213
- return typeof sessions === 'object' && sessions !== null
214
- ? sessions
215
- : {};
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;
216
258
  }
217
- function validPending(resume) {
218
- const pending = resume?.pending;
219
- if (!resume || !pending)
259
+ function validSession(session) {
260
+ if (!session || typeof session !== 'object')
220
261
  return undefined;
221
- const valid = Number.isInteger(pending.ordinal) &&
222
- pending.ordinal === resume.lastOrdinal &&
223
- Number.isInteger(pending.offset) &&
224
- pending.offset >= 0 &&
225
- pending.offset <= resume.offset &&
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 &&
226
289
  typeof pending.emitted === 'string';
227
- return valid ? pending : undefined;
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 };
228
303
  }
229
304
  /** Digest of what a consumer would store for an entry besides its identity and text. */
230
305
  function metadataDigest(entry) {
@@ -249,6 +324,10 @@ function compareEntries(left, right) {
249
324
  function compareStrings(left, right) {
250
325
  return left < right ? -1 : left > right ? 1 : 0;
251
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
+ }
252
331
  function realpathOrNull(input, cache) {
253
332
  const cached = cache.get(input);
254
333
  if (cached !== undefined)
@@ -343,69 +422,313 @@ async function listCandidates(providers, listingRoots, sinceMs) {
343
422
  function hashPath(filePath) {
344
423
  return (0, crypto_1.createHash)('sha256').update(filePath).digest('hex').slice(0, 12);
345
424
  }
346
- 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) {
347
429
  const buffer = Buffer.alloc(length);
348
- if (length === 0)
349
- return buffer;
350
- const handle = await fs.promises.open(filePath, 'r');
351
- try {
352
- let read = 0;
353
- while (read < length) {
354
- const { bytesRead } = await handle.read(buffer, read, length - read, offset + read);
355
- if (bytesRead === 0)
356
- break;
357
- read += bytesRead;
358
- }
359
- return read === length ? buffer : buffer.subarray(0, read);
360
- }
361
- finally {
362
- 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;
363
436
  }
437
+ return read === length ? buffer : buffer.subarray(0, read);
364
438
  }
365
439
  function yieldToEventLoop() {
366
440
  return new Promise((resolve) => setImmediate(resolve));
367
441
  }
368
- async function scanBuffer(candidate, buffer, startOffset, start, context) {
369
- const lastNewline = buffer.lastIndexOf(0x0a);
370
- const completeLength = lastNewline + 1;
371
- const scanner = candidate.provider === 'claude-code'
372
- ? new ClaudeLineScanner(candidate, start, context)
373
- : new CodexLineScanner(candidate, start, context);
374
- let lineStart = 0;
375
- for (let index = 0; lineStart < completeLength; index++) {
376
- if (index > 0 && index % CHECK_EVERY_LINES === 0) {
377
- await yieldToEventLoop();
378
- const stop = context.shouldStop();
379
- if (stop)
380
- return { ...scanner.outcome(startOffset), stoppedBy: stop };
381
- }
382
- const lineEnd = buffer.indexOf(0x0a, lineStart);
383
- const line = buffer.toString('utf8', lineStart, lineEnd);
384
- const lineOffset = startOffset + lineStart;
385
- lineStart = lineEnd + 1;
386
- if (!line.trim())
387
- continue;
388
- let record;
389
- try {
390
- 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;
391
461
  }
392
- catch {
393
- 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;
394
467
  }
395
- if (typeof record !== 'object' || record === null)
396
- continue;
397
- if (scanner.accept(record, lineOffset) === 'excluded') {
398
- // 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) {
494
+ return {
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,
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) {
399
525
  return {
400
- ...scanner.outcome(startOffset + buffer.length),
401
526
  entries: [],
402
- pending: undefined,
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,
403
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,
404
553
  stoppedBy: null,
554
+ notInteractive: false,
555
+ inScope: 0,
556
+ outOfScope: 0,
405
557
  };
406
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;
407
709
  }
408
- return { ...scanner.outcome(startOffset + completeLength), 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);
409
732
  }
410
733
  /** Shared per-file prompt bookkeeping for both providers. */
411
734
  class LineScanner {
@@ -417,45 +740,155 @@ class LineScanner {
417
740
  outOfScope = 0;
418
741
  /** Latest emitted prompt still collecting answer metadata. */
419
742
  open = null;
420
- constructor(candidate, start, context) {
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) {
421
748
  this.candidate = candidate;
422
749
  this.context = context;
423
- this.nextOrdinal = start.replay ? start.replay.ordinal : (start.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
+ }
424
786
  }
425
- state() {
787
+ resumeState() {
426
788
  return undefined;
427
789
  }
428
- outcome(offset) {
429
- 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() {
430
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
+ };
431
821
  return {
432
- entries: this.entries,
433
- lastOrdinal: this.nextOrdinal - 1,
434
- offset,
435
- ...(state ? { state } : {}),
436
- ...(open
437
- ? {
438
- pending: {
439
- ordinal: open.entry.ordinal,
440
- offset: open.lineOffset,
441
- emitted: metadataDigest(open.entry),
442
- ...(open.stateBefore ? { state: open.stateBefore } : {}),
443
- },
444
- }
445
- : {}),
446
- inScope: this.inScope,
447
- outOfScope: this.outOfScope,
448
- 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,
829
+ };
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,
449
878
  };
450
879
  }
451
880
  /** The open prompt's answer is complete, or will never arrive. */
452
881
  closeOpen() {
453
- if (this.open)
454
- this.open.entry.metadataStatus = 'final';
882
+ const open = this.open;
883
+ if (!open)
884
+ return;
885
+ open.entry.metadataStatus = 'final';
886
+ if (open.carried)
887
+ this.closedCarried = open;
455
888
  this.open = null;
456
889
  }
457
890
  /** Assign an ordinal to a human prompt and emit it when in scope. */
458
- recordPrompt(text, timestamp, cwd, gitBranch, lineOffset, stateBefore) {
891
+ recordPrompt(text, timestamp, cwd, gitBranch, lineOffset, lineBytes, stateBefore) {
459
892
  const ordinal = this.nextOrdinal++;
460
893
  this.closeOpen();
461
894
  if (typeof timestamp !== 'string' || Number.isNaN(Date.parse(timestamp))) {
@@ -482,7 +915,13 @@ class LineScanner {
482
915
  metadataStatus: 'pending',
483
916
  };
484
917
  this.entries.push(entry);
485
- this.open = { entry, lineOffset, ...(stateBefore ? { stateBefore } : {}) };
918
+ this.open = {
919
+ entry,
920
+ lineOffset,
921
+ lineBytes,
922
+ answerId: null,
923
+ ...(stateBefore ? { stateBefore } : {}),
924
+ };
486
925
  }
487
926
  }
488
927
  function transcriptMessage(candidate, record, role, content, extra, eventIndex) {
@@ -520,37 +959,48 @@ function compactUsage(usage) {
520
959
  }
521
960
  return Object.keys(result).length > 0 ? result : undefined;
522
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
+ }
523
974
  class ClaudeLineScanner extends LineScanner {
524
975
  lineIndex = 0;
525
- answerId = null;
526
- accept(record, lineOffset) {
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) {
527
986
  const index = this.lineIndex++;
528
987
  const message = record.message;
529
988
  if (typeof message !== 'object' || message === null)
530
989
  return 'ok';
531
990
  if (record.type === 'user') {
532
- const origin = record.origin;
533
991
  const blocks = claudeBlocks(message.content);
534
- const canonical = transcriptMessage(this.candidate, record, typeof message.role === 'string' ? message.role : 'user', blocks, {
535
- entrypoint: stringField(record.entrypoint),
536
- isMeta: record.isMeta === true ? true : undefined,
537
- isSidechain: record.isSidechain === true ? true : undefined,
538
- isCompactSummary: record.isCompactSummary === true ? true : undefined,
539
- originKind: origin && typeof origin.kind === 'string' ? origin.kind : undefined,
540
- promptSource: stringField(record.promptSource),
541
- }, index);
542
- const text = (0, humanPrompt_1.humanPromptText)(canonical, 'claude-code');
992
+ const text = claudeUserText(this.candidate, record, message, blocks, index);
543
993
  if (text === null) {
544
994
  // A tool result means the first answering call already finished.
545
- if (this.answerId !== null &&
995
+ if (this.open !== null &&
996
+ this.open.answerId !== null &&
546
997
  record.isSidechain !== true &&
547
998
  blocks.some((block) => block.type === 'tool_result')) {
548
999
  this.closeOpen();
549
1000
  }
550
1001
  return 'ok';
551
1002
  }
552
- this.answerId = null;
553
- this.recordPrompt(text, record.timestamp, record.cwd, stringField(record.gitBranch), lineOffset);
1003
+ this.recordPrompt(text, record.timestamp, record.cwd, stringField(record.gitBranch), lineOffset, lineBytes);
554
1004
  return 'ok';
555
1005
  }
556
1006
  const open = this.open;
@@ -559,10 +1009,10 @@ class ClaudeLineScanner extends LineScanner {
559
1009
  // Synthetic records (API errors, interrupts) are not model calls.
560
1010
  if (message.model === '<synthetic>')
561
1011
  return 'ok';
562
- const id = stringField(message.id) ?? `line:${index}`;
563
- if (this.answerId === null)
564
- this.answerId = id;
565
- if (id !== this.answerId) {
1012
+ const id = stringField(message.id) ?? `line:${lineOffset}`;
1013
+ if (open.answerId === null)
1014
+ open.answerId = id;
1015
+ if (id !== open.answerId) {
566
1016
  // A later tool-loop call: the first answering call is over and keeps its attribution.
567
1017
  this.closeOpen();
568
1018
  return 'ok';
@@ -618,22 +1068,36 @@ function claudeBlocks(content) {
618
1068
  }
619
1069
  class CodexLineScanner extends LineScanner {
620
1070
  lineIndex = 0;
621
- resumeState;
622
- constructor(candidate, start, context) {
623
- super(candidate, start, context);
1071
+ state;
1072
+ constructor(candidate, plan, context) {
1073
+ super(candidate, plan, context);
624
1074
  // A replay restarts at the open prompt's line, so it needs the state from just before it.
625
- const initial = start.replay ? start.replay.state : start.resume?.state;
626
- this.resumeState = { ...(initial ?? {}) };
1075
+ const initial = plan.kind === 'replay'
1076
+ ? plan.pending.state
1077
+ : plan.kind === 'continue'
1078
+ ? plan.session.state
1079
+ : undefined;
1080
+ this.state = { ...(initial ?? {}) };
627
1081
  }
628
- state() {
629
- return { ...this.resumeState };
1082
+ resumeState() {
1083
+ return { ...this.state };
630
1084
  }
631
- accept(record, lineOffset) {
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) {
632
1096
  const index = this.lineIndex++;
633
1097
  const payload = record.payload;
634
1098
  if (typeof payload !== 'object' || payload === null)
635
1099
  return 'ok';
636
- const state = this.resumeState;
1100
+ const state = this.state;
637
1101
  if (record.type === 'session_meta') {
638
1102
  // Forked sessions embed their parent's session_meta after their own; only the first counts.
639
1103
  if (state.meta)
@@ -658,7 +1122,7 @@ class CodexLineScanner extends LineScanner {
658
1122
  state.model = model;
659
1123
  return 'ok';
660
1124
  }
661
- if (record.type === 'response_item' && payload.type === 'message' && payload.role === 'user') {
1125
+ if (isCodexUserMessage(record, payload)) {
662
1126
  // Without session metadata the source (and thus interactivity) is unknown: fail closed.
663
1127
  if (!state.meta) {
664
1128
  state.excluded = true;
@@ -668,7 +1132,7 @@ class CodexLineScanner extends LineScanner {
668
1132
  const text = (0, humanPrompt_1.humanPromptText)(canonical, 'codex');
669
1133
  if (text === null)
670
1134
  return 'ok';
671
- this.recordPrompt(text, record.timestamp, state.cwd, state.gitBranch, lineOffset, {
1135
+ this.recordPrompt(text, record.timestamp, state.cwd, state.gitBranch, lineOffset, lineBytes, {
672
1136
  ...state,
673
1137
  });
674
1138
  return 'ok';
@@ -697,6 +1161,13 @@ class CodexLineScanner extends LineScanner {
697
1161
  return 'ok';
698
1162
  }
699
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
+ }
700
1171
  function codexBlocks(content) {
701
1172
  if (!Array.isArray(content))
702
1173
  return [];