sidekick-shared 0.27.1 → 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 +20 -0
- package/README.md +11 -5
- package/dist/index.d.ts +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/promptHistory.d.ts +82 -10
- package/dist/promptHistory.js +654 -183
- package/package.json +1 -1
package/dist/promptHistory.js
CHANGED
|
@@ -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
|
|
14
|
-
*
|
|
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 =
|
|
82
|
-
const cursor = { version:
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
175
|
+
stopAt(candidate, 'maxSessions');
|
|
152
176
|
break;
|
|
153
177
|
}
|
|
154
|
-
|
|
155
|
-
|
|
178
|
+
const remainingTotal = bounds.maxTotalBytes - totalBytes;
|
|
179
|
+
if (scannedAny && remainingTotal <= 0) {
|
|
180
|
+
stopAt(candidate, 'maxTotalBytes');
|
|
156
181
|
break;
|
|
157
182
|
}
|
|
158
|
-
|
|
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
|
-
|
|
194
|
+
outcome = await scanFile(candidate, stat, prior, budget, context);
|
|
161
195
|
}
|
|
162
196
|
catch {
|
|
163
197
|
continue;
|
|
164
198
|
}
|
|
165
|
-
totalBytes +=
|
|
166
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
if (
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
210
|
-
if (!cursor ||
|
|
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
|
|
214
|
-
|
|
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
|
|
218
|
-
|
|
219
|
-
if (!resume || !pending)
|
|
259
|
+
function validSession(session) {
|
|
260
|
+
if (!session || typeof session !== 'object')
|
|
220
261
|
return undefined;
|
|
221
|
-
const valid =
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
787
|
+
resumeState() {
|
|
426
788
|
return undefined;
|
|
427
789
|
}
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
454
|
-
|
|
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 = {
|
|
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
|
-
|
|
526
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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:${
|
|
563
|
-
if (
|
|
564
|
-
|
|
565
|
-
if (id !==
|
|
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
|
-
|
|
622
|
-
constructor(candidate,
|
|
623
|
-
super(candidate,
|
|
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 =
|
|
626
|
-
|
|
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
|
-
|
|
629
|
-
return { ...this.
|
|
1082
|
+
resumeState() {
|
|
1083
|
+
return { ...this.state };
|
|
630
1084
|
}
|
|
631
|
-
|
|
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.
|
|
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
|
|
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 [];
|