sidekick-shared 0.26.6 → 0.27.1

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.
@@ -0,0 +1,719 @@
1
+ "use strict";
2
+ /**
3
+ * Node-only collection of human-typed prompts from Claude Code and Codex
4
+ * session logs, scoped to a set of workspace roots.
5
+ *
6
+ * Parse-only: no redaction and no network I/O. Scope fails closed — a prompt
7
+ * is returned only when the working directory recorded on its log line (or,
8
+ * for Codex, its session/turn context) realpath-resolves to an allowed root
9
+ * or somewhere inside one. Every bound stops cleanly and is reported in
10
+ * `boundsHit`; nothing is silently truncated and no bound throws.
11
+ *
12
+ * 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
15
+ * answering call brings model or usage (`metadataStatus` says how settled).
16
+ */
17
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ var desc = Object.getOwnPropertyDescriptor(m, k);
20
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
21
+ desc = { enumerable: true, get: function() { return m[k]; } };
22
+ }
23
+ Object.defineProperty(o, k2, desc);
24
+ }) : (function(o, m, k, k2) {
25
+ if (k2 === undefined) k2 = k;
26
+ o[k2] = m[k];
27
+ }));
28
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
29
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
30
+ }) : function(o, v) {
31
+ o["default"] = v;
32
+ });
33
+ var __importStar = (this && this.__importStar) || (function () {
34
+ var ownKeys = function(o) {
35
+ ownKeys = Object.getOwnPropertyNames || function (o) {
36
+ var ar = [];
37
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
38
+ return ar;
39
+ };
40
+ return ownKeys(o);
41
+ };
42
+ return function (mod) {
43
+ if (mod && mod.__esModule) return mod;
44
+ var result = {};
45
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
46
+ __setModuleDefault(result, mod);
47
+ return result;
48
+ };
49
+ })();
50
+ Object.defineProperty(exports, "__esModule", { value: true });
51
+ exports.collectPromptHistory = collectPromptHistory;
52
+ const crypto_1 = require("crypto");
53
+ const fs = __importStar(require("fs"));
54
+ const path = __importStar(require("path"));
55
+ const humanPrompt_1 = require("./humanPrompt");
56
+ const sessionPathResolver_1 = require("./parsers/sessionPathResolver");
57
+ const claudeCode_1 = require("./providers/claudeCode");
58
+ const codex_1 = require("./providers/codex");
59
+ const DEFAULT_BOUNDS = {
60
+ maxSessions: 1000,
61
+ maxFileBytes: 16 * 1024 * 1024,
62
+ maxTotalBytes: 256 * 1024 * 1024,
63
+ deadlineMs: 30_000,
64
+ };
65
+ const CHECK_EVERY_LINES = 500;
66
+ const ALL_PROVIDERS = ['claude-code', 'codex'];
67
+ /** Collect human-typed prompts from Claude Code and Codex logs under the given roots. */
68
+ async function collectPromptHistory(options) {
69
+ const bounds = { ...DEFAULT_BOUNDS, ...definedBounds(options.bounds) };
70
+ const startedAt = Date.now();
71
+ const boundsHit = new Set();
72
+ const stats = {
73
+ sessionsScanned: 0,
74
+ sessionsSkippedUnchanged: 0,
75
+ sessionsOutOfScope: 0,
76
+ filesOverSizeLimit: 0,
77
+ sessionsNotInteractive: 0,
78
+ promptsMissingTimestamp: 0,
79
+ promptsOutOfScope: 0,
80
+ };
81
+ const previous = acceptedCursorSessions(options.cursor);
82
+ const cursor = { version: 2, sessions: { ...previous } };
83
+ const entries = [];
84
+ const shouldStop = () => {
85
+ if (options.signal?.aborted)
86
+ return 'aborted';
87
+ if (Date.now() - startedAt >= bounds.deadlineMs)
88
+ return 'deadline';
89
+ return null;
90
+ };
91
+ const finish = () => ({
92
+ entries: entries.sort(compareEntries),
93
+ cursor,
94
+ stats,
95
+ boundsHit: [...boundsHit],
96
+ });
97
+ const realpaths = new Map();
98
+ const { roots, listingRoots } = resolveRoots(options.workspacePaths, options.includeWorktrees !== false, realpaths);
99
+ if (roots.length === 0)
100
+ return finish();
101
+ const sinceMs = options.since ? options.since.getTime() : null;
102
+ const providers = options.providers ?? ALL_PROVIDERS;
103
+ const candidates = await listCandidates(providers, listingRoots, sinceMs);
104
+ const context = { roots, sinceMs, stats, realpaths, shouldStop };
105
+ let totalBytes = 0;
106
+ for (const candidate of candidates) {
107
+ const stop = shouldStop();
108
+ if (stop) {
109
+ boundsHit.add(stop);
110
+ break;
111
+ }
112
+ let stat;
113
+ try {
114
+ stat = await fs.promises.stat(candidate.filePath);
115
+ }
116
+ catch {
117
+ continue;
118
+ }
119
+ const prior = previous[candidate.key];
120
+ if (prior && prior.size === stat.size && prior.mtimeMs === stat.mtimeMs) {
121
+ stats.sessionsSkippedUnchanged++;
122
+ continue;
123
+ }
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
+ if (stats.sessionsScanned >= bounds.maxSessions) {
151
+ boundsHit.add('maxSessions');
152
+ break;
153
+ }
154
+ if (totalBytes + bytesToRead > bounds.maxTotalBytes) {
155
+ boundsHit.add('maxTotalBytes');
156
+ break;
157
+ }
158
+ let buffer;
159
+ try {
160
+ buffer = await readRange(candidate.filePath, offset, bytesToRead);
161
+ }
162
+ catch {
163
+ continue;
164
+ }
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
+ }
173
+ if (outcome.notInteractive)
174
+ stats.sessionsNotInteractive++;
175
+ else if (outcome.inScope === 0 && outcome.outOfScope > 0)
176
+ 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);
183
+ }
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
+ await yieldToEventLoop();
195
+ }
196
+ return finish();
197
+ }
198
+ function definedBounds(bounds) {
199
+ const result = {};
200
+ if (!bounds)
201
+ return result;
202
+ for (const key of Object.keys(DEFAULT_BOUNDS)) {
203
+ const value = bounds[key];
204
+ if (typeof value === 'number' && !Number.isNaN(value))
205
+ result[key] = Math.max(0, value);
206
+ }
207
+ return result;
208
+ }
209
+ function acceptedCursorSessions(cursor) {
210
+ if (!cursor || (cursor.version !== 1 && cursor.version !== 2))
211
+ return {};
212
+ const sessions = cursor.sessions;
213
+ return typeof sessions === 'object' && sessions !== null
214
+ ? sessions
215
+ : {};
216
+ }
217
+ function validPending(resume) {
218
+ const pending = resume?.pending;
219
+ if (!resume || !pending)
220
+ 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 &&
226
+ typeof pending.emitted === 'string';
227
+ return valid ? pending : undefined;
228
+ }
229
+ /** Digest of what a consumer would store for an entry besides its identity and text. */
230
+ function metadataDigest(entry) {
231
+ const usage = entry.usage
232
+ ? [
233
+ entry.usage.inputTokens ?? null,
234
+ entry.usage.outputTokens ?? null,
235
+ entry.usage.cacheReadTokens ?? null,
236
+ entry.usage.cacheWriteTokens ?? null,
237
+ ]
238
+ : null;
239
+ return (0, crypto_1.createHash)('sha256')
240
+ .update(JSON.stringify([entry.model ?? null, usage, entry.metadataStatus]))
241
+ .digest('hex')
242
+ .slice(0, 16);
243
+ }
244
+ function compareEntries(left, right) {
245
+ return (compareStrings(left.provider, right.provider) ||
246
+ compareStrings(left.sessionId, right.sessionId) ||
247
+ left.ordinal - right.ordinal);
248
+ }
249
+ function compareStrings(left, right) {
250
+ return left < right ? -1 : left > right ? 1 : 0;
251
+ }
252
+ function realpathOrNull(input, cache) {
253
+ const cached = cache.get(input);
254
+ if (cached !== undefined)
255
+ return cached;
256
+ let resolved = null;
257
+ try {
258
+ resolved = path.isAbsolute(input) ? fs.realpathSync.native(input) : null;
259
+ }
260
+ catch {
261
+ resolved = null;
262
+ }
263
+ cache.set(input, resolved);
264
+ return resolved;
265
+ }
266
+ function resolveRoots(workspacePaths, includeWorktrees, realpaths) {
267
+ const roots = new Set();
268
+ const listingRoots = new Set();
269
+ const add = (candidate) => {
270
+ const real = realpathOrNull(candidate, realpaths);
271
+ if (!real)
272
+ return;
273
+ roots.add(real);
274
+ listingRoots.add(real);
275
+ listingRoots.add(path.resolve(candidate));
276
+ };
277
+ for (const workspacePath of workspacePaths) {
278
+ if (typeof workspacePath !== 'string' || !path.isAbsolute(workspacePath))
279
+ continue;
280
+ add(workspacePath);
281
+ if (!includeWorktrees)
282
+ continue;
283
+ const real = realpathOrNull(workspacePath, realpaths);
284
+ if (!real)
285
+ continue;
286
+ const mainRepo = (0, sessionPathResolver_1.resolveWorktreeMainRepo)(real) ?? real;
287
+ add(mainRepo);
288
+ for (const sibling of (0, sessionPathResolver_1.discoverWorktreeSiblings)(mainRepo))
289
+ add(sibling);
290
+ }
291
+ return { roots: [...roots], listingRoots: [...listingRoots] };
292
+ }
293
+ /** Fail-closed scope check on a recorded working directory. */
294
+ function scopedCwd(cwd, roots, realpaths) {
295
+ if (typeof cwd !== 'string' || cwd.length === 0)
296
+ return null;
297
+ const real = realpathOrNull(cwd, realpaths);
298
+ if (!real)
299
+ return null;
300
+ for (const root of roots) {
301
+ if (real === root)
302
+ return real;
303
+ const prefix = root.endsWith(path.sep) ? root : root + path.sep;
304
+ if (real.startsWith(prefix))
305
+ return real;
306
+ }
307
+ return null;
308
+ }
309
+ async function listCandidates(providers, listingRoots, sinceMs) {
310
+ const byPath = new Map();
311
+ const listOptions = sinceMs !== null ? { since: sinceMs } : {};
312
+ for (const provider of [...new Set(providers)]) {
313
+ if (!ALL_PROVIDERS.includes(provider))
314
+ continue;
315
+ // Listings are only candidate supersets; scope is re-checked per prompt.
316
+ const source = provider === 'claude-code' ? new claudeCode_1.ClaudeCodeProvider() : new codex_1.CodexProvider();
317
+ for (const root of listingRoots) {
318
+ let files;
319
+ try {
320
+ files = (await source.listSessionFilesAsync?.(root, listOptions)) ?? [];
321
+ }
322
+ catch {
323
+ files = [];
324
+ }
325
+ for (const file of files) {
326
+ if (byPath.has(file.path))
327
+ continue;
328
+ const sessionId = file.sessionId ?? path.basename(file.path, '.jsonl');
329
+ byPath.set(file.path, {
330
+ provider,
331
+ sessionId,
332
+ filePath: file.path,
333
+ key: `${provider}:${sessionId}:${hashPath(file.path)}`,
334
+ });
335
+ }
336
+ }
337
+ source.dispose();
338
+ }
339
+ return [...byPath.values()].sort((left, right) => compareStrings(left.provider, right.provider) ||
340
+ compareStrings(left.sessionId, right.sessionId) ||
341
+ compareStrings(left.key, right.key));
342
+ }
343
+ function hashPath(filePath) {
344
+ return (0, crypto_1.createHash)('sha256').update(filePath).digest('hex').slice(0, 12);
345
+ }
346
+ async function readRange(filePath, offset, length) {
347
+ 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();
363
+ }
364
+ }
365
+ function yieldToEventLoop() {
366
+ return new Promise((resolve) => setImmediate(resolve));
367
+ }
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);
391
+ }
392
+ catch {
393
+ continue;
394
+ }
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.
399
+ return {
400
+ ...scanner.outcome(startOffset + buffer.length),
401
+ entries: [],
402
+ pending: undefined,
403
+ notInteractive: true,
404
+ stoppedBy: null,
405
+ };
406
+ }
407
+ }
408
+ return { ...scanner.outcome(startOffset + completeLength), stoppedBy: null };
409
+ }
410
+ /** Shared per-file prompt bookkeeping for both providers. */
411
+ class LineScanner {
412
+ candidate;
413
+ context;
414
+ entries = [];
415
+ nextOrdinal;
416
+ inScope = 0;
417
+ outOfScope = 0;
418
+ /** Latest emitted prompt still collecting answer metadata. */
419
+ open = null;
420
+ constructor(candidate, start, context) {
421
+ this.candidate = candidate;
422
+ this.context = context;
423
+ this.nextOrdinal = start.replay ? start.replay.ordinal : (start.resume?.lastOrdinal ?? -1) + 1;
424
+ }
425
+ state() {
426
+ return undefined;
427
+ }
428
+ outcome(offset) {
429
+ const state = this.state();
430
+ const open = this.open;
431
+ 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,
449
+ };
450
+ }
451
+ /** The open prompt's answer is complete, or will never arrive. */
452
+ closeOpen() {
453
+ if (this.open)
454
+ this.open.entry.metadataStatus = 'final';
455
+ this.open = null;
456
+ }
457
+ /** Assign an ordinal to a human prompt and emit it when in scope. */
458
+ recordPrompt(text, timestamp, cwd, gitBranch, lineOffset, stateBefore) {
459
+ const ordinal = this.nextOrdinal++;
460
+ this.closeOpen();
461
+ if (typeof timestamp !== 'string' || Number.isNaN(Date.parse(timestamp))) {
462
+ this.context.stats.promptsMissingTimestamp++;
463
+ return;
464
+ }
465
+ const scoped = scopedCwd(cwd, this.context.roots, this.context.realpaths);
466
+ if (!scoped) {
467
+ this.outOfScope++;
468
+ this.context.stats.promptsOutOfScope++;
469
+ return;
470
+ }
471
+ this.inScope++;
472
+ if (this.context.sinceMs !== null && Date.parse(timestamp) < this.context.sinceMs)
473
+ return;
474
+ const entry = {
475
+ provider: this.candidate.provider,
476
+ sessionId: this.candidate.sessionId,
477
+ ordinal,
478
+ timestamp,
479
+ text,
480
+ cwd: scoped,
481
+ ...(gitBranch ? { gitBranch } : {}),
482
+ metadataStatus: 'pending',
483
+ };
484
+ this.entries.push(entry);
485
+ this.open = { entry, lineOffset, ...(stateBefore ? { stateBefore } : {}) };
486
+ }
487
+ }
488
+ function transcriptMessage(candidate, record, role, content, extra, eventIndex) {
489
+ return {
490
+ role: 'user',
491
+ timestamp: typeof record.timestamp === 'string' ? record.timestamp : '',
492
+ content,
493
+ text: content
494
+ .filter((block) => block.type === 'text')
495
+ .map((block) => block.text ?? '')
496
+ .join('\n'),
497
+ tools: [],
498
+ commands: [],
499
+ source: {
500
+ provider: candidate.provider,
501
+ sessionId: candidate.sessionId,
502
+ originalRole: role,
503
+ eventIndex,
504
+ eventType: 'user',
505
+ ...extra,
506
+ },
507
+ };
508
+ }
509
+ function stringField(value) {
510
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
511
+ }
512
+ function tokenCount(value) {
513
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
514
+ }
515
+ function compactUsage(usage) {
516
+ const result = {};
517
+ for (const [key, value] of Object.entries(usage)) {
518
+ if (typeof value === 'number')
519
+ result[key] = value;
520
+ }
521
+ return Object.keys(result).length > 0 ? result : undefined;
522
+ }
523
+ class ClaudeLineScanner extends LineScanner {
524
+ lineIndex = 0;
525
+ answerId = null;
526
+ accept(record, lineOffset) {
527
+ const index = this.lineIndex++;
528
+ const message = record.message;
529
+ if (typeof message !== 'object' || message === null)
530
+ return 'ok';
531
+ if (record.type === 'user') {
532
+ const origin = record.origin;
533
+ 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');
543
+ if (text === null) {
544
+ // A tool result means the first answering call already finished.
545
+ if (this.answerId !== null &&
546
+ record.isSidechain !== true &&
547
+ blocks.some((block) => block.type === 'tool_result')) {
548
+ this.closeOpen();
549
+ }
550
+ return 'ok';
551
+ }
552
+ this.answerId = null;
553
+ this.recordPrompt(text, record.timestamp, record.cwd, stringField(record.gitBranch), lineOffset);
554
+ return 'ok';
555
+ }
556
+ const open = this.open;
557
+ if (record.type !== 'assistant' || !open || record.isSidechain === true)
558
+ return 'ok';
559
+ // Synthetic records (API errors, interrupts) are not model calls.
560
+ if (message.model === '<synthetic>')
561
+ return 'ok';
562
+ const id = stringField(message.id) ?? `line:${index}`;
563
+ if (this.answerId === null)
564
+ this.answerId = id;
565
+ if (id !== this.answerId) {
566
+ // A later tool-loop call: the first answering call is over and keeps its attribution.
567
+ this.closeOpen();
568
+ return 'ok';
569
+ }
570
+ const entry = open.entry;
571
+ const model = stringField(message.model);
572
+ if (model)
573
+ entry.model = model;
574
+ const usage = message.usage;
575
+ if (usage && typeof usage === 'object') {
576
+ // Split records of one call repeat its usage; the latest replaces, never adds.
577
+ const mapped = compactUsage({
578
+ inputTokens: tokenCount(usage.input_tokens),
579
+ outputTokens: tokenCount(usage.output_tokens),
580
+ cacheReadTokens: tokenCount(usage.cache_read_input_tokens),
581
+ cacheWriteTokens: tokenCount(usage.cache_creation_input_tokens),
582
+ });
583
+ if (mapped)
584
+ entry.usage = mapped;
585
+ }
586
+ // Claude Code writes the call's final stop_reason and usage on every split record.
587
+ if (stringField(message.stop_reason))
588
+ this.closeOpen();
589
+ else
590
+ entry.metadataStatus = 'provisional';
591
+ return 'ok';
592
+ }
593
+ }
594
+ function claudeBlocks(content) {
595
+ if (typeof content === 'string')
596
+ return [{ type: 'text', text: content }];
597
+ if (!Array.isArray(content))
598
+ return [];
599
+ const blocks = [];
600
+ for (const item of content) {
601
+ if (typeof item !== 'object' || item === null)
602
+ continue;
603
+ const block = item;
604
+ if (block.type === 'text' && typeof block.text === 'string') {
605
+ blocks.push({ type: 'text', text: block.text });
606
+ }
607
+ else if (block.type === 'tool_result') {
608
+ blocks.push({ type: 'tool_result' });
609
+ }
610
+ else if (block.type === 'image') {
611
+ blocks.push({ type: 'image' });
612
+ }
613
+ else {
614
+ blocks.push({ type: 'unknown' });
615
+ }
616
+ }
617
+ return blocks;
618
+ }
619
+ class CodexLineScanner extends LineScanner {
620
+ lineIndex = 0;
621
+ resumeState;
622
+ constructor(candidate, start, context) {
623
+ super(candidate, start, context);
624
+ // 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 ?? {}) };
627
+ }
628
+ state() {
629
+ return { ...this.resumeState };
630
+ }
631
+ accept(record, lineOffset) {
632
+ const index = this.lineIndex++;
633
+ const payload = record.payload;
634
+ if (typeof payload !== 'object' || payload === null)
635
+ return 'ok';
636
+ const state = this.resumeState;
637
+ if (record.type === 'session_meta') {
638
+ // Forked sessions embed their parent's session_meta after their own; only the first counts.
639
+ if (state.meta)
640
+ return 'ok';
641
+ state.meta = true;
642
+ const source = typeof payload.source === 'string' ? payload.source : null;
643
+ if (!source || !humanPrompt_1.HUMAN_CODEX_SOURCES.includes(source) || stringField(payload.forked_from_id)) {
644
+ state.excluded = true;
645
+ return 'excluded';
646
+ }
647
+ state.cwd = stringField(payload.cwd);
648
+ const git = payload.git;
649
+ state.gitBranch = git && typeof git === 'object' ? stringField(git.branch) : undefined;
650
+ return 'ok';
651
+ }
652
+ if (record.type === 'turn_context') {
653
+ const cwd = stringField(payload.cwd);
654
+ if (cwd)
655
+ state.cwd = cwd;
656
+ const model = stringField(payload.model);
657
+ if (model)
658
+ state.model = model;
659
+ return 'ok';
660
+ }
661
+ if (record.type === 'response_item' && payload.type === 'message' && payload.role === 'user') {
662
+ // Without session metadata the source (and thus interactivity) is unknown: fail closed.
663
+ if (!state.meta) {
664
+ state.excluded = true;
665
+ return 'excluded';
666
+ }
667
+ const canonical = transcriptMessage(this.candidate, record, 'user', codexBlocks(payload.content), {}, index);
668
+ const text = (0, humanPrompt_1.humanPromptText)(canonical, 'codex');
669
+ if (text === null)
670
+ return 'ok';
671
+ this.recordPrompt(text, record.timestamp, state.cwd, state.gitBranch, lineOffset, {
672
+ ...state,
673
+ });
674
+ return 'ok';
675
+ }
676
+ const open = this.open;
677
+ if (record.type === 'event_msg' && payload.type === 'token_count' && open) {
678
+ // The first token_count after the prompt is the first answering call, complete.
679
+ const info = payload.info;
680
+ const last = info && typeof info === 'object' ? info.last_token_usage : undefined;
681
+ if (!last || typeof last !== 'object')
682
+ return 'ok';
683
+ const input = tokenCount(last.input_tokens);
684
+ const cached = tokenCount(last.cached_input_tokens);
685
+ const mapped = compactUsage({
686
+ inputTokens: input !== undefined ? Math.max(0, input - (cached ?? 0)) : undefined,
687
+ outputTokens: tokenCount(last.output_tokens),
688
+ cacheReadTokens: cached,
689
+ cacheWriteTokens: tokenCount(last.cache_write_input_tokens),
690
+ });
691
+ if (mapped)
692
+ open.entry.usage = mapped;
693
+ if (state.model)
694
+ open.entry.model = state.model;
695
+ this.closeOpen();
696
+ }
697
+ return 'ok';
698
+ }
699
+ }
700
+ function codexBlocks(content) {
701
+ if (!Array.isArray(content))
702
+ return [];
703
+ const blocks = [];
704
+ for (const item of content) {
705
+ if (typeof item !== 'object' || item === null)
706
+ continue;
707
+ const block = item;
708
+ if ((block.type === 'input_text' || block.type === 'text') && typeof block.text === 'string') {
709
+ blocks.push({ type: 'text', text: block.text });
710
+ }
711
+ else if (block.type === 'input_image') {
712
+ blocks.push({ type: 'image' });
713
+ }
714
+ else {
715
+ blocks.push({ type: 'unknown' });
716
+ }
717
+ }
718
+ return blocks;
719
+ }