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.
- package/CHANGELOG.md +36 -0
- package/README.md +17 -8
- package/dist/index.d.ts +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/promptHistory.d.ts +118 -5
- package/dist/promptHistory.js +734 -160
- package/package.json +1 -1
package/dist/promptHistory.js
CHANGED
|
@@ -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
|
|
77
|
-
const cursor = { version:
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
175
|
+
stopAt(candidate, 'maxSessions');
|
|
140
176
|
break;
|
|
141
177
|
}
|
|
142
|
-
|
|
143
|
-
|
|
178
|
+
const remainingTotal = bounds.maxTotalBytes - totalBytes;
|
|
179
|
+
if (scannedAny && remainingTotal <= 0) {
|
|
180
|
+
stopAt(candidate, 'maxTotalBytes');
|
|
144
181
|
break;
|
|
145
182
|
}
|
|
146
|
-
|
|
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
|
-
|
|
194
|
+
outcome = await scanFile(candidate, stat, prior, budget, context);
|
|
149
195
|
}
|
|
150
196
|
catch {
|
|
151
197
|
continue;
|
|
152
198
|
}
|
|
153
|
-
totalBytes +=
|
|
154
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
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
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
787
|
+
resumeState() {
|
|
367
788
|
return undefined;
|
|
368
789
|
}
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
449
|
-
|
|
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
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
525
|
-
constructor(candidate,
|
|
526
|
-
super(candidate,
|
|
527
|
-
|
|
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
|
-
|
|
530
|
-
return { ...this.
|
|
1082
|
+
resumeState() {
|
|
1083
|
+
return { ...this.state };
|
|
531
1084
|
}
|
|
532
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1156
|
+
open.entry.usage = mapped;
|
|
590
1157
|
if (state.model)
|
|
591
|
-
|
|
592
|
-
this.
|
|
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 [];
|