maka-agent 0.2.0-dev.31.20260913 → 0.2.0-dev.32.20260914
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/dist/pi-transcript.js +3 -4
- package/native/runtime-host-windows-task-launcher/prebuilds/win32-x64/maka-runtime-host-task-launcher.exe +0 -0
- package/node_modules/@maka/core/dist/agent-graph-schedule.js +15 -4
- package/node_modules/@maka/core/dist/executor-id.js +22 -0
- package/node_modules/@maka/core/dist/external-session.js +18 -0
- package/node_modules/@maka/core/dist/model-call-usage-projection.js +0 -13
- package/node_modules/@maka/core/dist/runtime-event.js +19 -3
- package/node_modules/@maka/core/dist/session-send-projection.js +6 -3
- package/node_modules/@maka/core/dist/session.js +8 -2
- package/node_modules/@maka/core/dist/shell-run-result.js +1 -0
- package/node_modules/@maka/core/dist/shell-run.js +4 -0
- package/node_modules/@maka/core/dist/work-board.js +94 -1
- package/node_modules/@maka/core/package.json +1 -0
- package/node_modules/@maka/eval/dist/fleet-simulation.js +270 -0
- package/node_modules/@maka/eval/dist/fleet-store.js +243 -0
- package/node_modules/@maka/eval/dist/fleet-worker.js +125 -0
- package/node_modules/@maka/eval/dist/fleet.js +329 -0
- package/node_modules/@maka/eval/dist/index.js +3 -0
- package/node_modules/@maka/runtime/dist/agent-run.js +2 -17
- package/node_modules/@maka/runtime/dist/ai-sdk-compaction.js +6 -2
- package/node_modules/@maka/runtime/dist/ai-sdk-message-projection.js +36 -24
- package/node_modules/@maka/runtime/dist/ai-sdk-turn.js +257 -330
- package/node_modules/@maka/runtime/dist/background-task-health-tool.js +104 -0
- package/node_modules/@maka/runtime/dist/history-compaction.js +3 -1
- package/node_modules/@maka/runtime/dist/local-web-fetch.js +1 -1
- package/node_modules/@maka/runtime/dist/model-adapter.js +62 -53
- package/node_modules/@maka/runtime/dist/model-history.js +1 -5
- package/node_modules/@maka/runtime/dist/plugin-executor-backend.js +311 -0
- package/node_modules/@maka/runtime/dist/plugin-executor-service.js +344 -0
- package/node_modules/@maka/runtime/dist/provider-error-classification.js +7 -1
- package/node_modules/@maka/runtime/dist/provider-request-telemetry.js +35 -8
- package/node_modules/@maka/runtime/dist/runtime-event-backfill.js +7 -1
- package/node_modules/@maka/runtime/dist/runtime-event-read-model.js +1 -0
- package/node_modules/@maka/runtime/dist/runtime-invocation-route.js +49 -0
- package/node_modules/@maka/runtime/dist/runtime-kernel.js +72 -150
- package/node_modules/@maka/runtime/dist/runtime-read-model.js +0 -2
- package/node_modules/@maka/runtime/dist/session-event-runtime-mapper.js +3 -0
- package/node_modules/@maka/runtime/dist/session-manager.js +100 -58
- package/node_modules/@maka/runtime/dist/shell-run-manager.js +9 -3
- package/node_modules/@maka/runtime/dist/shell-run-tool-result.js +1 -0
- package/node_modules/@maka/runtime/dist/stream-graph-schedule-reconcile.js +1 -0
- package/node_modules/@maka/runtime/dist/stream-graph-supervisor-tools.js +26 -4
- package/node_modules/@maka/runtime/dist/subagent-tools.js +7 -0
- package/node_modules/@maka/runtime/dist/tool-runtime.js +45 -466
- package/node_modules/@maka/runtime/package.json +3 -0
- package/node_modules/@maka/runtime-host/dist/adapter/session-projector.js +7 -2
- package/node_modules/@maka/runtime-host/dist/client/session-catalog-summary.js +1 -0
- package/node_modules/@maka/runtime-host/dist/protocol/external-session.js +24 -3
- package/node_modules/@maka/runtime-host/dist/protocol/index.js +4 -1
- package/node_modules/@maka/runtime-host/dist/protocol/plugin-platform.js +42 -5
- package/node_modules/@maka/runtime-host/dist/protocol/session-catalog.js +31 -3
- package/node_modules/@maka/runtime-host/dist/protocol/session-continuity.js +6 -0
- package/node_modules/@maka/runtime-host/dist/server/child-agent-composition.js +1 -0
- package/node_modules/@maka/runtime-host/dist/server/execution-artifacts.js +1 -22
- package/node_modules/@maka/runtime-host/dist/server/execution-composition.js +54 -8
- package/node_modules/@maka/runtime-host/dist/server/external-session-coordinator.js +14 -1
- package/node_modules/@maka/runtime-host/dist/server/host-session-availability.js +10 -2
- package/node_modules/@maka/runtime-host/dist/server/plugin-platform-coordinator.js +6 -0
- package/node_modules/@maka/runtime-host/dist/server/plugin-platform.js +6 -0
- package/node_modules/@maka/runtime-host/dist/server/session-catalog-coordinator.js +56 -16
- package/node_modules/@maka/runtime-host/dist/server/session-continuity-coordinator.js +4 -3
- package/node_modules/@maka/runtime-host/dist/server/session-revision-coordinator.js +4 -0
- package/node_modules/@maka/runtime-host/dist/server/web-fetch-tool.js +37 -1
- package/node_modules/@maka/storage/dist/claude-code-session-adapter.js +450 -234
- package/node_modules/@maka/storage/dist/claude-code-transcript-lineage.js +106 -77
- package/node_modules/@maka/storage/dist/legacy-run-header.js +2 -2
- package/node_modules/@maka/storage/dist/model-call-usage-sql.js +1 -1
- package/node_modules/@maka/storage/dist/session-store.js +12 -2
- package/node_modules/@maka/storage/dist/sqlite-long-term-memory-store.js +86 -12
- package/node_modules/@maka/storage/dist/work-board-store.js +40 -1
- package/package.json +1 -1
|
@@ -29,17 +29,21 @@
|
|
|
29
29
|
// between `/Users/a/b` and `/Users/a-b`. Every record carries its own `cwd`,
|
|
30
30
|
// and that is what a project-scoped query reads.
|
|
31
31
|
import { existsSync } from 'node:fs';
|
|
32
|
-
import {
|
|
32
|
+
import { createHash } from 'node:crypto';
|
|
33
|
+
import { open, readdir, stat } from 'node:fs/promises';
|
|
33
34
|
import { homedir } from 'node:os';
|
|
34
35
|
import { join, resolve } from 'node:path';
|
|
35
|
-
import { claudeAssistantText, claudeUserAuthoredText, isSyntheticClaudeUserText, pickClaudeTitle, sanitizeForeignTitle, } from '@maka/core/foreign-session';
|
|
36
|
-
import { externalSessionMatchesQuery } from '@maka/core/external-session';
|
|
37
|
-
import {
|
|
36
|
+
import { claudeAssistantText, claudeUserAuthoredText, collectClaudeTitle, isSyntheticClaudeUserText, pickClaudeTitle, sanitizeForeignTitle, } from '@maka/core/foreign-session';
|
|
37
|
+
import { ExternalSessionLimitError, externalSessionMatchesQuery, } from '@maka/core/external-session';
|
|
38
|
+
import { TranscriptLineageIndexer, } from './claude-code-transcript-lineage.js';
|
|
38
39
|
export const CLAUDE_CODE_SESSION_ADAPTER_ID = 'claude-code';
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
/** Maximum source bytes scanned from one fixed transcript snapshot. */
|
|
41
|
+
export const CLAUDE_TRANSCRIPT_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
|
42
|
+
const CLAUDE_TRANSCRIPT_READ_BYTES = 64 * 1024;
|
|
43
|
+
const CLAUDE_TRANSCRIPT_MAX_RECORD_BYTES = 64 * 1024 * 1024;
|
|
44
|
+
const CLAUDE_TRANSCRIPT_MAX_CONVERTED_BYTES = 256 * 1024 * 1024;
|
|
45
|
+
const CLAUDE_TRANSCRIPT_MAX_MESSAGES = 250_000;
|
|
46
|
+
const CLAUDE_TRANSCRIPT_MAX_RECORDS = 1_000_000;
|
|
43
47
|
/** Session ids are the transcript's filename stem, and reach the filesystem.
|
|
44
48
|
* A uuid is what Claude Code writes; anything else is refused rather than
|
|
45
49
|
* joined onto a path. */
|
|
@@ -47,7 +51,7 @@ const SESSION_ID_PATTERN = /^[0-9a-fA-F-]{1,128}$/u;
|
|
|
47
51
|
export class ClaudeCodeSessionAdapter {
|
|
48
52
|
id = CLAUDE_CODE_SESSION_ADAPTER_ID;
|
|
49
53
|
#home;
|
|
50
|
-
#
|
|
54
|
+
#limits;
|
|
51
55
|
/**
|
|
52
56
|
* Summaries already derived from a transcript, keyed by path and invalidated
|
|
53
57
|
* by the file's own mtime and size.
|
|
@@ -64,7 +68,18 @@ export class ClaudeCodeSessionAdapter {
|
|
|
64
68
|
#summaries = new Map();
|
|
65
69
|
constructor(options = {}) {
|
|
66
70
|
this.#home = options.claudeHome ?? join(homedir(), '.claude');
|
|
67
|
-
this.#
|
|
71
|
+
this.#limits = {
|
|
72
|
+
maxTranscriptBytes: options.maxTranscriptBytes ?? CLAUDE_TRANSCRIPT_MAX_BYTES,
|
|
73
|
+
maxRecordBytes: options.maxRecordBytes ?? CLAUDE_TRANSCRIPT_MAX_RECORD_BYTES,
|
|
74
|
+
maxConvertedBytes: options.maxConvertedBytes ?? CLAUDE_TRANSCRIPT_MAX_CONVERTED_BYTES,
|
|
75
|
+
maxMessages: options.maxMessages ?? CLAUDE_TRANSCRIPT_MAX_MESSAGES,
|
|
76
|
+
maxRecords: options.maxRecords ?? CLAUDE_TRANSCRIPT_MAX_RECORDS,
|
|
77
|
+
};
|
|
78
|
+
assertPositiveSafeInteger(this.#limits.maxTranscriptBytes, 'Claude transcript byte limit');
|
|
79
|
+
assertPositiveSafeInteger(this.#limits.maxRecordBytes, 'Claude transcript record byte limit');
|
|
80
|
+
assertPositiveSafeInteger(this.#limits.maxConvertedBytes, 'Claude converted message byte limit');
|
|
81
|
+
assertPositiveSafeInteger(this.#limits.maxMessages, 'Claude converted message count limit');
|
|
82
|
+
assertPositiveSafeInteger(this.#limits.maxRecords, 'Claude transcript record count limit');
|
|
68
83
|
}
|
|
69
84
|
async detect() {
|
|
70
85
|
return existsSync(this.#projectsRoot());
|
|
@@ -114,7 +129,7 @@ export class ClaudeCodeSessionAdapter {
|
|
|
114
129
|
const cached = this.#summaries.get(path);
|
|
115
130
|
if (cached && cached.mtimeMs === mtimeMs && cached.size === size)
|
|
116
131
|
return cached.summary;
|
|
117
|
-
const parsed = await
|
|
132
|
+
const parsed = await readTranscriptSummary(path);
|
|
118
133
|
// Sub-agent transcripts are whole files, never records interleaved into a
|
|
119
134
|
// parent — so exclusion is per file. Importing one would present a
|
|
120
135
|
// fragment of a conversation as a conversation.
|
|
@@ -135,17 +150,7 @@ export class ClaudeCodeSessionAdapter {
|
|
|
135
150
|
const file = (await this.#transcriptFiles()).find((candidate) => candidate.sessionId === sessionId);
|
|
136
151
|
if (!file)
|
|
137
152
|
throw new Error(`Claude Code transcript not found: ${sessionId}`);
|
|
138
|
-
|
|
139
|
-
if (!parsed)
|
|
140
|
-
throw new Error(`Claude Code transcript could not be read: ${sessionId}`);
|
|
141
|
-
if (parsed.isSidechain) {
|
|
142
|
-
throw new Error(`Claude Code transcript is a sub-agent sidechain: ${sessionId}`);
|
|
143
|
-
}
|
|
144
|
-
return {
|
|
145
|
-
sourceSessionId: sessionId,
|
|
146
|
-
metadata: { name: parsed.title || sessionId, cwd: parsed.cwd },
|
|
147
|
-
messages: convertTranscript(sessionId, parsed.records),
|
|
148
|
-
};
|
|
153
|
+
return convertClaudeTranscript(file.path, sessionId, this.#limits);
|
|
149
154
|
}
|
|
150
155
|
#projectsRoot() {
|
|
151
156
|
return join(this.#home, 'projects');
|
|
@@ -206,66 +211,233 @@ export class ClaudeCodeSessionAdapter {
|
|
|
206
211
|
}
|
|
207
212
|
return [...bySessionId.values()].map(({ path, sessionId }) => ({ path, sessionId }));
|
|
208
213
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
+
}
|
|
215
|
+
async function convertClaudeTranscript(path, sessionId, limits) {
|
|
216
|
+
const snapshot = await ClaudeTranscriptSnapshot.open(path, sessionId, limits.maxTranscriptBytes);
|
|
217
|
+
try {
|
|
218
|
+
const indexer = new TranscriptLineageIndexer();
|
|
219
|
+
let cwd = '';
|
|
220
|
+
let isSidechain = false;
|
|
221
|
+
const titles = {};
|
|
222
|
+
let records = 0;
|
|
223
|
+
for await (const record of snapshot.records(sessionId, limits)) {
|
|
224
|
+
records += 1;
|
|
225
|
+
indexer.accept(record);
|
|
226
|
+
if (record.isSidechain === true)
|
|
227
|
+
isSidechain = true;
|
|
228
|
+
if (!cwd && typeof record.cwd === 'string' && record.cwd)
|
|
229
|
+
cwd = record.cwd;
|
|
230
|
+
collectClaudeTitle(record, titles);
|
|
231
|
+
collectLegacyClaudeTitle(record, titles);
|
|
214
232
|
}
|
|
215
|
-
|
|
216
|
-
|
|
233
|
+
if (records === 0)
|
|
234
|
+
throw new Error(`Claude Code transcript could not be read: ${sessionId}`);
|
|
235
|
+
if (isSidechain) {
|
|
236
|
+
throw new Error(`Claude Code transcript is a sub-agent sidechain: ${sessionId}`);
|
|
237
|
+
}
|
|
238
|
+
const lineage = indexer.finish();
|
|
239
|
+
const fragmentFilter = lineage.createFilter();
|
|
240
|
+
const responseCollector = new ClaudeResponseCollector(limits.maxConvertedBytes);
|
|
241
|
+
for await (const record of snapshot.records(sessionId, limits)) {
|
|
242
|
+
if (!fragmentFilter.keep(record) || record.type !== 'assistant')
|
|
243
|
+
continue;
|
|
244
|
+
responseCollector.accept(record);
|
|
217
245
|
}
|
|
218
|
-
|
|
246
|
+
const converter = new ClaudeTranscriptConverter(sessionId, responseCollector.responses, limits);
|
|
247
|
+
const conversionFilter = lineage.createFilter();
|
|
248
|
+
for await (const record of snapshot.records(sessionId, limits)) {
|
|
249
|
+
if (conversionFilter.keep(record))
|
|
250
|
+
converter.accept(record);
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
sourceSessionId: sessionId,
|
|
254
|
+
metadata: { name: pickClaudeTitle(titles) || sessionId, cwd },
|
|
255
|
+
messages: converter.finish(),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
await snapshot.close();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* One open file descriptor and one byte length define the import snapshot.
|
|
264
|
+
* Appends are ignored, path replacement cannot redirect later passes, and a
|
|
265
|
+
* digest check rejects in-place rewrites that would otherwise mix records
|
|
266
|
+
* from different points in time.
|
|
267
|
+
*/
|
|
268
|
+
class ClaudeTranscriptSnapshot {
|
|
269
|
+
#handle;
|
|
270
|
+
#size;
|
|
271
|
+
#digest;
|
|
272
|
+
constructor(handle, size) {
|
|
273
|
+
this.#handle = handle;
|
|
274
|
+
this.#size = size;
|
|
275
|
+
}
|
|
276
|
+
static async open(path, sessionId, maxBytes) {
|
|
277
|
+
const handle = await open(path, 'r');
|
|
219
278
|
try {
|
|
220
|
-
|
|
279
|
+
const metadata = await handle.stat();
|
|
280
|
+
if (!metadata.isFile())
|
|
281
|
+
throw new Error('Claude Code transcript is not a regular file');
|
|
282
|
+
if (metadata.size > maxBytes) {
|
|
283
|
+
throw new ExternalSessionLimitError('transcript_bytes', maxBytes, `Claude Code transcript exceeds ${maxBytes} bytes: ${sessionId}`);
|
|
284
|
+
}
|
|
285
|
+
return new ClaudeTranscriptSnapshot(handle, metadata.size);
|
|
221
286
|
}
|
|
222
|
-
catch {
|
|
223
|
-
|
|
287
|
+
catch (error) {
|
|
288
|
+
await handle.close();
|
|
289
|
+
throw error;
|
|
224
290
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
291
|
+
}
|
|
292
|
+
async *records(sessionId, limits) {
|
|
293
|
+
const digest = createHash('sha256');
|
|
294
|
+
yield* readClaudeTranscriptRecords(this.#handle, this.#size, sessionId, limits, (chunk) => digest.update(chunk));
|
|
295
|
+
this.#verifyDigest(digest.digest('hex'));
|
|
296
|
+
}
|
|
297
|
+
async close() {
|
|
298
|
+
await this.#handle.close();
|
|
299
|
+
}
|
|
300
|
+
#verifyDigest(digest) {
|
|
301
|
+
if (this.#digest === undefined) {
|
|
302
|
+
this.#digest = digest;
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (this.#digest !== digest) {
|
|
306
|
+
throw new Error('Claude Code transcript changed while being read');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async function* readClaudeTranscriptRecords(handle, snapshotBytes, sessionId, limits, observeChunk) {
|
|
311
|
+
const metadata = await handle.stat();
|
|
312
|
+
if (!metadata.isFile())
|
|
313
|
+
throw new Error('Claude Code transcript is not a regular file');
|
|
314
|
+
if (metadata.size < snapshotBytes) {
|
|
315
|
+
throw new Error('Claude Code transcript changed while being read');
|
|
316
|
+
}
|
|
317
|
+
const pending = [];
|
|
318
|
+
let pendingBytes = 0;
|
|
319
|
+
let observedBytes = 0;
|
|
320
|
+
let parsedRecords = 0;
|
|
321
|
+
if (snapshotBytes > 0) {
|
|
322
|
+
for await (const value of handle.createReadStream({
|
|
323
|
+
autoClose: false,
|
|
324
|
+
emitClose: false,
|
|
325
|
+
start: 0,
|
|
326
|
+
end: snapshotBytes - 1,
|
|
327
|
+
highWaterMark: CLAUDE_TRANSCRIPT_READ_BYTES,
|
|
328
|
+
})) {
|
|
329
|
+
const chunk = Buffer.from(value);
|
|
330
|
+
observeChunk?.(chunk);
|
|
331
|
+
observedBytes += chunk.byteLength;
|
|
332
|
+
if (observedBytes > snapshotBytes) {
|
|
333
|
+
throw new Error('Claude Code transcript changed while being read');
|
|
238
334
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
335
|
+
let start = 0;
|
|
336
|
+
for (;;) {
|
|
337
|
+
const newline = chunk.indexOf(0x0a, start);
|
|
338
|
+
if (newline === -1)
|
|
339
|
+
break;
|
|
340
|
+
const segment = chunk.subarray(start, newline);
|
|
341
|
+
assertClaudeRecordSize(pendingBytes + segment.byteLength, limits.maxRecordBytes, sessionId);
|
|
342
|
+
const bytes = pending.length === 0
|
|
343
|
+
? segment
|
|
344
|
+
: Buffer.concat([...pending, segment], pendingBytes + segment.byteLength);
|
|
345
|
+
const record = parseClaudeTranscriptLine(bytes);
|
|
346
|
+
if (record) {
|
|
347
|
+
parsedRecords += 1;
|
|
348
|
+
assertClaudeRecordCount(parsedRecords, limits.maxRecords, sessionId);
|
|
349
|
+
yield record;
|
|
350
|
+
}
|
|
351
|
+
pending.length = 0;
|
|
352
|
+
pendingBytes = 0;
|
|
353
|
+
start = newline + 1;
|
|
244
354
|
}
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
355
|
+
if (start < chunk.byteLength) {
|
|
356
|
+
const segment = chunk.subarray(start);
|
|
357
|
+
assertClaudeRecordSize(pendingBytes + segment.byteLength, limits.maxRecordBytes, sessionId);
|
|
358
|
+
pending.push(segment);
|
|
359
|
+
pendingBytes += segment.byteLength;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (observedBytes !== snapshotBytes) {
|
|
364
|
+
throw new Error('Claude Code transcript changed while being read');
|
|
365
|
+
}
|
|
366
|
+
if (pendingBytes > 0) {
|
|
367
|
+
const record = parseClaudeTranscriptLine(pending.length === 1 ? pending[0] : Buffer.concat(pending, pendingBytes));
|
|
368
|
+
if (record) {
|
|
369
|
+
parsedRecords += 1;
|
|
370
|
+
assertClaudeRecordCount(parsedRecords, limits.maxRecords, sessionId);
|
|
371
|
+
yield record;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function assertClaudeRecordSize(actualBytes, maxBytes, sessionId) {
|
|
376
|
+
if (actualBytes > maxBytes) {
|
|
377
|
+
throw new ClaudeTranscriptReadLimitError('record_bytes', maxBytes, `Claude Code transcript record exceeds ${maxBytes} bytes: ${sessionId}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function assertClaudeRecordCount(actual, maxRecords, sessionId) {
|
|
381
|
+
if (actual > maxRecords) {
|
|
382
|
+
throw new ClaudeTranscriptReadLimitError('records', maxRecords, `Claude Code transcript has more than ${maxRecords} records: ${sessionId}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
class ClaudeTranscriptReadLimitError extends ExternalSessionLimitError {
|
|
386
|
+
}
|
|
387
|
+
function parseClaudeTranscriptLine(bytes) {
|
|
388
|
+
const text = bytes.toString('utf8').trim();
|
|
389
|
+
if (!text)
|
|
390
|
+
return undefined;
|
|
391
|
+
try {
|
|
392
|
+
const value = JSON.parse(text);
|
|
393
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
394
|
+
? value
|
|
395
|
+
: undefined;
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
// Interrupted writes leave a torn tail, while old transcripts can contain
|
|
399
|
+
// corrupt interior lines. Both were historically skipped so one bad line
|
|
400
|
+
// does not erase an otherwise readable conversation.
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
async function readTranscriptSummary(path) {
|
|
405
|
+
const handle = await open(path, 'r').catch(() => undefined);
|
|
406
|
+
if (!handle)
|
|
407
|
+
return undefined;
|
|
408
|
+
const titles = {};
|
|
409
|
+
let cwd = '';
|
|
410
|
+
let isSidechain = false;
|
|
411
|
+
let createdAt;
|
|
412
|
+
let updatedAt;
|
|
413
|
+
let records = 0;
|
|
414
|
+
let mtimeMs;
|
|
415
|
+
try {
|
|
416
|
+
const info = await handle.stat();
|
|
417
|
+
if (!info.isFile())
|
|
418
|
+
return undefined;
|
|
419
|
+
mtimeMs = info.mtimeMs;
|
|
420
|
+
for await (const record of readClaudeTranscriptRecords(handle, info.size, path, {
|
|
421
|
+
maxRecordBytes: CLAUDE_TRANSCRIPT_MAX_RECORD_BYTES,
|
|
422
|
+
maxRecords: CLAUDE_TRANSCRIPT_MAX_RECORDS,
|
|
423
|
+
})) {
|
|
424
|
+
records += 1;
|
|
425
|
+
collectClaudeTitle(record, titles);
|
|
426
|
+
collectLegacyClaudeTitle(record, titles);
|
|
427
|
+
if (record.isSidechain === true)
|
|
250
428
|
isSidechain = true;
|
|
251
|
-
if (typeof
|
|
252
|
-
cwd =
|
|
253
|
-
const ts = timestampMs(
|
|
429
|
+
if (!cwd && typeof record.cwd === 'string' && record.cwd)
|
|
430
|
+
cwd = record.cwd;
|
|
431
|
+
const ts = timestampMs(record);
|
|
254
432
|
if (ts !== undefined) {
|
|
255
433
|
createdAt ??= ts;
|
|
256
|
-
updatedAt
|
|
257
|
-
|
|
258
|
-
collectTitle(typed, titles);
|
|
259
|
-
if (titles.firstUserMessage === undefined && typed.type === 'user') {
|
|
260
|
-
const text = claudeUserAuthoredText(typed);
|
|
261
|
-
if (text)
|
|
262
|
-
titles.firstUserMessage = text;
|
|
434
|
+
if (updatedAt === undefined || ts > updatedAt)
|
|
435
|
+
updatedAt = ts;
|
|
263
436
|
}
|
|
264
437
|
}
|
|
265
|
-
if (records
|
|
438
|
+
if (records === 0)
|
|
266
439
|
return undefined;
|
|
267
440
|
return {
|
|
268
|
-
records,
|
|
269
441
|
cwd,
|
|
270
442
|
title: pickClaudeTitle(titles),
|
|
271
443
|
...(createdAt !== undefined ? { createdAt } : {}),
|
|
@@ -273,26 +445,43 @@ export class ClaudeCodeSessionAdapter {
|
|
|
273
445
|
isSidechain,
|
|
274
446
|
};
|
|
275
447
|
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
if (error instanceof ClaudeTranscriptReadLimitError && mtimeMs !== undefined) {
|
|
450
|
+
// Import keeps strict per-record and record-count bounds, but reaching
|
|
451
|
+
// one of them must not erase a real transcript from the catalog. Retain
|
|
452
|
+
// any metadata already observed and use the file mtime for a stable,
|
|
453
|
+
// sortable fallback row; selecting it then surfaces the precise limit.
|
|
454
|
+
return {
|
|
455
|
+
cwd,
|
|
456
|
+
title: pickClaudeTitle(titles),
|
|
457
|
+
...(createdAt !== undefined ? { createdAt } : {}),
|
|
458
|
+
updatedAt: mtimeMs,
|
|
459
|
+
isSidechain,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
return undefined;
|
|
463
|
+
}
|
|
464
|
+
finally {
|
|
465
|
+
await handle.close();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
function collectLegacyClaudeTitle(record, titles) {
|
|
469
|
+
const take = (value) => typeof value === 'string' && value.trim() ? sanitizeForeignTitle(value) : undefined;
|
|
470
|
+
if (record.type === 'ai-title') {
|
|
471
|
+
titles.aiTitle = take(record.aiTitle ?? record.title) ?? titles.aiTitle;
|
|
472
|
+
}
|
|
473
|
+
else if (record.type === 'last-prompt') {
|
|
474
|
+
titles.lastPrompt = take(record.lastPrompt ?? record.prompt) ?? titles.lastPrompt;
|
|
475
|
+
}
|
|
276
476
|
}
|
|
277
477
|
function assertSafeSessionId(sessionId) {
|
|
278
478
|
if (!SESSION_ID_PATTERN.test(sessionId)) {
|
|
279
479
|
throw new Error('Claude Code session id is not a transcript name');
|
|
280
480
|
}
|
|
281
481
|
}
|
|
282
|
-
function
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
case 'ai-title':
|
|
286
|
-
titles.aiTitle = take(record.aiTitle ?? record.title) ?? titles.aiTitle;
|
|
287
|
-
return;
|
|
288
|
-
case 'last-prompt':
|
|
289
|
-
titles.lastPrompt = take(record.lastPrompt ?? record.prompt) ?? titles.lastPrompt;
|
|
290
|
-
return;
|
|
291
|
-
case 'summary':
|
|
292
|
-
titles.summary = take(record.summary) ?? titles.summary;
|
|
293
|
-
return;
|
|
294
|
-
default:
|
|
295
|
-
return;
|
|
482
|
+
function assertPositiveSafeInteger(value, label) {
|
|
483
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
484
|
+
throw new Error(`${label} must be a positive safe integer`);
|
|
296
485
|
}
|
|
297
486
|
}
|
|
298
487
|
function timestampMs(record) {
|
|
@@ -324,112 +513,49 @@ const TERMINAL_STOP_REASONS = new Set(['end_turn', 'stop_sequence']);
|
|
|
324
513
|
/** Names the cutoff for a turn the transcript simply stops inside, so a reader
|
|
325
514
|
* can tell an imported snapshot's edge from a user's Stop or a provider abort. */
|
|
326
515
|
const EXTERNAL_SNAPSHOT_ABORT_SOURCE = 'external_session_snapshot';
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
// Every fragment of one assistant response, keyed by `message.id`. A
|
|
336
|
-
// response is emitted once, from all of its fragments, at the position of
|
|
337
|
-
// the first — so a later fragment's text is part of the reply rather than
|
|
338
|
-
// something the first fragment's absence of text can suppress.
|
|
339
|
-
const responseFragments = new Map();
|
|
340
|
-
for (const record of records) {
|
|
341
|
-
if (record.type !== 'assistant')
|
|
342
|
-
continue;
|
|
516
|
+
class ClaudeResponseCollector {
|
|
517
|
+
responses = new Map();
|
|
518
|
+
#maxBytes;
|
|
519
|
+
#retainedBytes = 0;
|
|
520
|
+
constructor(maxBytes) {
|
|
521
|
+
this.#maxBytes = maxBytes;
|
|
522
|
+
}
|
|
523
|
+
accept(record) {
|
|
343
524
|
const responseId = stringOf(asMessageRecord(record)?.id);
|
|
344
525
|
if (responseId === undefined)
|
|
345
|
-
|
|
346
|
-
const
|
|
526
|
+
return;
|
|
527
|
+
const retainedBytes = Buffer.byteLength(JSON.stringify(record), 'utf8');
|
|
528
|
+
if (retainedBytes > this.#maxBytes - this.#retainedBytes) {
|
|
529
|
+
throw new ExternalSessionLimitError('converted_bytes', this.#maxBytes, `Claude Code transcript converts to more than ${this.#maxBytes} bytes`);
|
|
530
|
+
}
|
|
531
|
+
this.#retainedBytes += retainedBytes;
|
|
532
|
+
const existing = this.responses.get(responseId);
|
|
347
533
|
if (existing)
|
|
348
534
|
existing.push(record);
|
|
349
535
|
else
|
|
350
|
-
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
// `turn_state`, `deriveTurnRecords` falls back to `inferLegacyTurnStatus`,
|
|
374
|
-
// which answers `completed` for any turn holding an assistant message
|
|
375
|
-
// (`session.ts:1250`) and marks it `inferred`. The Ledger then refuses
|
|
376
|
-
// that uncorroborated terminal and the repair path persists
|
|
377
|
-
// `failed / missing_terminal_event` — an internal-corruption verdict on a
|
|
378
|
-
// transcript that was merely cut short. Measured: 13.9% of turns across
|
|
379
|
-
// 1130 local transcripts end with no assistant reply or at a `tool_use`
|
|
380
|
-
// whose result never arrived.
|
|
381
|
-
//
|
|
382
|
-
// So an unfinished turn is recorded as what it is: a snapshot that ended
|
|
383
|
-
// mid-turn, with an `abortSource` naming the import rather than a user or
|
|
384
|
-
// a provider. `end_turn`, interrupt notices and API errors keep their own
|
|
385
|
-
// evidence and are unaffected.
|
|
386
|
-
if (turn.aborted) {
|
|
387
|
-
messages.push({
|
|
388
|
-
type: 'turn_state',
|
|
389
|
-
id: id('turn-state'),
|
|
390
|
-
turnId: turn.turnId,
|
|
391
|
-
ts: turn.lastTs,
|
|
392
|
-
status: 'aborted',
|
|
393
|
-
abortedAt: turn.lastTs,
|
|
394
|
-
abortSource: 'claude-code.interrupt',
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
|
-
else if (turn.failed) {
|
|
398
|
-
messages.push({
|
|
399
|
-
type: 'turn_state',
|
|
400
|
-
id: id('turn-state'),
|
|
401
|
-
turnId: turn.turnId,
|
|
402
|
-
ts: turn.lastTs,
|
|
403
|
-
status: 'failed',
|
|
404
|
-
errorClass: 'claude_code_api_error',
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
else if (turn.terminalStop) {
|
|
408
|
-
messages.push({
|
|
409
|
-
type: 'turn_state',
|
|
410
|
-
id: id('turn-state'),
|
|
411
|
-
turnId: turn.turnId,
|
|
412
|
-
ts: turn.lastTs,
|
|
413
|
-
status: 'completed',
|
|
414
|
-
});
|
|
415
|
-
}
|
|
416
|
-
else {
|
|
417
|
-
messages.push({
|
|
418
|
-
type: 'turn_state',
|
|
419
|
-
id: id('turn-state'),
|
|
420
|
-
turnId: turn.turnId,
|
|
421
|
-
ts: turn.lastTs,
|
|
422
|
-
status: 'aborted',
|
|
423
|
-
abortedAt: turn.lastTs,
|
|
424
|
-
abortSource: EXTERNAL_SNAPSHOT_ABORT_SOURCE,
|
|
425
|
-
});
|
|
426
|
-
}
|
|
427
|
-
turn = undefined;
|
|
428
|
-
};
|
|
429
|
-
for (const record of records) {
|
|
430
|
-
const ts = timestampMs(record) ?? turn?.lastTs ?? 0;
|
|
431
|
-
if (turn)
|
|
432
|
-
turn.lastTs = ts;
|
|
536
|
+
this.responses.set(responseId, [record]);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
class ClaudeTranscriptConverter {
|
|
540
|
+
#emittedResponses = new Set();
|
|
541
|
+
#messages = [];
|
|
542
|
+
#sessionId;
|
|
543
|
+
#responseFragments;
|
|
544
|
+
#limits;
|
|
545
|
+
#pendingCompactBoundaryTs;
|
|
546
|
+
#turn;
|
|
547
|
+
#sequence = 0;
|
|
548
|
+
#turnSequence = 0;
|
|
549
|
+
#convertedBytes = 0;
|
|
550
|
+
constructor(sessionId, responseFragments, limits) {
|
|
551
|
+
this.#sessionId = sessionId;
|
|
552
|
+
this.#responseFragments = responseFragments;
|
|
553
|
+
this.#limits = limits;
|
|
554
|
+
}
|
|
555
|
+
accept(record) {
|
|
556
|
+
const ts = timestampMs(record) ?? this.#turn?.lastTs ?? 0;
|
|
557
|
+
if (this.#turn)
|
|
558
|
+
this.#turn.lastTs = ts;
|
|
433
559
|
const type = record.type;
|
|
434
560
|
if (type === 'user') {
|
|
435
561
|
const message = asMessageRecord(record);
|
|
@@ -439,7 +565,7 @@ export function convertTranscript(sessionId, rawRecords) {
|
|
|
439
565
|
// model, not the human. Importing them as user Turns would put the
|
|
440
566
|
// model's own tool output in the user's mouth.
|
|
441
567
|
for (const block of toolResults) {
|
|
442
|
-
if (!turn)
|
|
568
|
+
if (!this.#turn)
|
|
443
569
|
continue;
|
|
444
570
|
const toolUseId = stringOf(block.tool_use_id);
|
|
445
571
|
// A result with no `tool_use_id` cannot be matched to its call.
|
|
@@ -448,17 +574,17 @@ export function convertTranscript(sessionId, rawRecords) {
|
|
|
448
574
|
// than the row being absent.
|
|
449
575
|
if (!toolUseId)
|
|
450
576
|
continue;
|
|
451
|
-
|
|
577
|
+
this.#append({
|
|
452
578
|
type: 'tool_result',
|
|
453
|
-
id: id('tool-result'),
|
|
454
|
-
turnId: turn.turnId,
|
|
579
|
+
id: this.#id('tool-result'),
|
|
580
|
+
turnId: this.#turn.turnId,
|
|
455
581
|
ts,
|
|
456
582
|
toolUseId,
|
|
457
583
|
isError: block.is_error === true,
|
|
458
584
|
content: { kind: 'text', text: toolResultText(block.content) },
|
|
459
585
|
});
|
|
460
586
|
}
|
|
461
|
-
|
|
587
|
+
return;
|
|
462
588
|
}
|
|
463
589
|
const text = claudeUserAuthoredText(record);
|
|
464
590
|
if (text === undefined) {
|
|
@@ -469,45 +595,51 @@ export function convertTranscript(sessionId, rawRecords) {
|
|
|
469
595
|
if (raw &&
|
|
470
596
|
isSyntheticClaudeUserText(raw) &&
|
|
471
597
|
raw.trimStart().startsWith('[Request interrupted')) {
|
|
472
|
-
if (turn)
|
|
473
|
-
turn.aborted = true;
|
|
598
|
+
if (this.#turn)
|
|
599
|
+
this.#turn.aborted = true;
|
|
474
600
|
}
|
|
475
|
-
|
|
601
|
+
return;
|
|
476
602
|
}
|
|
477
603
|
// A human-authored user record opens a new turn.
|
|
478
|
-
closeTurn();
|
|
479
|
-
turn = { turnId: nextTurnId(), lastTs: ts };
|
|
480
|
-
if (pendingCompactBoundaryTs !== undefined) {
|
|
481
|
-
|
|
604
|
+
this.#closeTurn();
|
|
605
|
+
this.#turn = { turnId: this.#nextTurnId(), lastTs: ts };
|
|
606
|
+
if (this.#pendingCompactBoundaryTs !== undefined) {
|
|
607
|
+
this.#append({
|
|
482
608
|
type: 'system_note',
|
|
483
|
-
id: id('compact'),
|
|
484
|
-
turnId: turn.turnId,
|
|
485
|
-
ts: pendingCompactBoundaryTs,
|
|
609
|
+
id: this.#id('compact'),
|
|
610
|
+
turnId: this.#turn.turnId,
|
|
611
|
+
ts: this.#pendingCompactBoundaryTs,
|
|
486
612
|
kind: 'context_compacted',
|
|
487
613
|
});
|
|
488
|
-
pendingCompactBoundaryTs = undefined;
|
|
614
|
+
this.#pendingCompactBoundaryTs = undefined;
|
|
489
615
|
}
|
|
490
|
-
|
|
491
|
-
|
|
616
|
+
this.#append({
|
|
617
|
+
type: 'user',
|
|
618
|
+
id: this.#id('user'),
|
|
619
|
+
turnId: this.#turn.turnId,
|
|
620
|
+
ts,
|
|
621
|
+
text,
|
|
622
|
+
});
|
|
623
|
+
return;
|
|
492
624
|
}
|
|
493
625
|
if (type === 'assistant') {
|
|
494
|
-
if (!turn) {
|
|
626
|
+
if (!this.#turn) {
|
|
495
627
|
// A transcript can open with an assistant record when the session was
|
|
496
628
|
// resumed. Give it a turn rather than dropping the content.
|
|
497
|
-
turn = { turnId: nextTurnId(), lastTs: ts };
|
|
498
|
-
if (pendingCompactBoundaryTs !== undefined) {
|
|
499
|
-
|
|
629
|
+
this.#turn = { turnId: this.#nextTurnId(), lastTs: ts };
|
|
630
|
+
if (this.#pendingCompactBoundaryTs !== undefined) {
|
|
631
|
+
this.#append({
|
|
500
632
|
type: 'system_note',
|
|
501
|
-
id: id('compact'),
|
|
502
|
-
turnId: turn.turnId,
|
|
503
|
-
ts: pendingCompactBoundaryTs,
|
|
633
|
+
id: this.#id('compact'),
|
|
634
|
+
turnId: this.#turn.turnId,
|
|
635
|
+
ts: this.#pendingCompactBoundaryTs,
|
|
504
636
|
kind: 'context_compacted',
|
|
505
637
|
});
|
|
506
|
-
pendingCompactBoundaryTs = undefined;
|
|
638
|
+
this.#pendingCompactBoundaryTs = undefined;
|
|
507
639
|
}
|
|
508
640
|
}
|
|
509
641
|
if (record.isApiErrorMessage === true)
|
|
510
|
-
turn.failed = true;
|
|
642
|
+
this.#turn.failed = true;
|
|
511
643
|
const message = asMessageRecord(record);
|
|
512
644
|
const responseId = stringOf(message?.id);
|
|
513
645
|
// A response is emitted once, at its first fragment, assembled from all
|
|
@@ -515,22 +647,22 @@ export function convertTranscript(sessionId, rawRecords) {
|
|
|
515
647
|
// being written — its content is already in what was emitted, and
|
|
516
648
|
// emitting again would repeat the reply.
|
|
517
649
|
if (responseId !== undefined) {
|
|
518
|
-
if (emittedResponses.has(responseId))
|
|
519
|
-
|
|
520
|
-
emittedResponses.add(responseId);
|
|
650
|
+
if (this.#emittedResponses.has(responseId))
|
|
651
|
+
return;
|
|
652
|
+
this.#emittedResponses.add(responseId);
|
|
521
653
|
}
|
|
522
654
|
// A fragment with no id stands alone; it is the only fragment of itself.
|
|
523
655
|
const fragments = (responseId === undefined
|
|
524
656
|
? undefined
|
|
525
|
-
: responseFragments.get(responseId)) ?? [record];
|
|
657
|
+
: this.#responseFragments.get(responseId)) ?? [record];
|
|
526
658
|
// Status evidence is read from every fragment, not just the first: the
|
|
527
659
|
// `stop_reason` lands on whichever fragment the response finished on.
|
|
528
660
|
for (const fragment of fragments) {
|
|
529
661
|
if (fragment.isApiErrorMessage === true)
|
|
530
|
-
turn.failed = true;
|
|
662
|
+
this.#turn.failed = true;
|
|
531
663
|
const stop = stringOf(asMessageRecord(fragment)?.stop_reason);
|
|
532
664
|
if (stop && TERMINAL_STOP_REASONS.has(stop))
|
|
533
|
-
turn.terminalStop = stop;
|
|
665
|
+
this.#turn.terminalStop = stop;
|
|
534
666
|
}
|
|
535
667
|
// The transcript names the model that produced each step. Carrying the
|
|
536
668
|
// real value keeps an imported turn attributable; a placeholder would
|
|
@@ -545,10 +677,10 @@ export function convertTranscript(sessionId, rawRecords) {
|
|
|
545
677
|
.filter((part) => part.length > 0)
|
|
546
678
|
.join('\n\n');
|
|
547
679
|
if (thinking) {
|
|
548
|
-
|
|
680
|
+
this.#append({
|
|
549
681
|
type: 'assistant',
|
|
550
|
-
id: id('thinking'),
|
|
551
|
-
turnId: turn.turnId,
|
|
682
|
+
id: this.#id('thinking'),
|
|
683
|
+
turnId: this.#turn.turnId,
|
|
552
684
|
ts,
|
|
553
685
|
text: '',
|
|
554
686
|
thinking: { text: thinking },
|
|
@@ -561,10 +693,10 @@ export function convertTranscript(sessionId, rawRecords) {
|
|
|
561
693
|
.filter((part) => part !== undefined && part.length > 0)
|
|
562
694
|
.join('\n\n');
|
|
563
695
|
if (text) {
|
|
564
|
-
|
|
696
|
+
this.#append({
|
|
565
697
|
type: 'assistant',
|
|
566
|
-
id: id('assistant'),
|
|
567
|
-
turnId: turn.turnId,
|
|
698
|
+
id: this.#id('assistant'),
|
|
699
|
+
turnId: this.#turn.turnId,
|
|
568
700
|
ts,
|
|
569
701
|
text,
|
|
570
702
|
contentOrder: ['text'],
|
|
@@ -577,18 +709,20 @@ export function convertTranscript(sessionId, rawRecords) {
|
|
|
577
709
|
// arriving; a call written after its sibling's result did not follow it.
|
|
578
710
|
for (const fragment of fragments) {
|
|
579
711
|
for (const block of toolUseBlocks(asMessageRecord(fragment))) {
|
|
580
|
-
|
|
712
|
+
this.#append({
|
|
581
713
|
type: 'tool_call',
|
|
582
714
|
// The id must equal the tool_use id so the result can match it.
|
|
583
|
-
id: stringOf(block.id) ?? id('tool-call'),
|
|
584
|
-
turnId: turn.turnId,
|
|
715
|
+
id: stringOf(block.id) ?? this.#id('tool-call'),
|
|
716
|
+
turnId: this.#turn.turnId,
|
|
585
717
|
ts,
|
|
586
718
|
toolName: stringOf(block.name) ?? 'unknown',
|
|
587
719
|
args: block.input ?? {},
|
|
588
720
|
});
|
|
589
721
|
}
|
|
590
722
|
}
|
|
591
|
-
|
|
723
|
+
if (responseId !== undefined)
|
|
724
|
+
this.#responseFragments.delete(responseId);
|
|
725
|
+
return;
|
|
592
726
|
}
|
|
593
727
|
// The compaction boundary, keyed on the record that states it.
|
|
594
728
|
//
|
|
@@ -604,21 +738,103 @@ export function convertTranscript(sessionId, rawRecords) {
|
|
|
604
738
|
// the boundary marks where the model's context restarted, which is the
|
|
605
739
|
// part a reader cannot reconstruct from the messages themselves.
|
|
606
740
|
if (record.subtype === 'compact_boundary') {
|
|
607
|
-
if (!turn) {
|
|
608
|
-
pendingCompactBoundaryTs = ts;
|
|
609
|
-
|
|
741
|
+
if (!this.#turn) {
|
|
742
|
+
this.#pendingCompactBoundaryTs = ts;
|
|
743
|
+
return;
|
|
610
744
|
}
|
|
611
|
-
|
|
745
|
+
this.#append({
|
|
612
746
|
type: 'system_note',
|
|
613
|
-
id: id('compact'),
|
|
614
|
-
turnId: turn.turnId,
|
|
747
|
+
id: this.#id('compact'),
|
|
748
|
+
turnId: this.#turn.turnId,
|
|
615
749
|
ts,
|
|
616
750
|
kind: 'context_compacted',
|
|
617
751
|
});
|
|
618
752
|
}
|
|
619
753
|
}
|
|
620
|
-
|
|
621
|
-
|
|
754
|
+
finish() {
|
|
755
|
+
this.#closeTurn();
|
|
756
|
+
return this.#messages;
|
|
757
|
+
}
|
|
758
|
+
#id(kind) {
|
|
759
|
+
return `claude-code:${this.#sessionId}:${kind}:${this.#sequence++}`;
|
|
760
|
+
}
|
|
761
|
+
#nextTurnId() {
|
|
762
|
+
return `claude-code:${this.#sessionId}:turn:${this.#turnSequence++}`;
|
|
763
|
+
}
|
|
764
|
+
#append(message) {
|
|
765
|
+
if (this.#messages.length >= this.#limits.maxMessages) {
|
|
766
|
+
throw new ExternalSessionLimitError('messages', this.#limits.maxMessages, `Claude Code transcript converts to more than ${this.#limits.maxMessages} messages`);
|
|
767
|
+
}
|
|
768
|
+
const encodedBytes = Buffer.byteLength(JSON.stringify(message), 'utf8');
|
|
769
|
+
if (encodedBytes > this.#limits.maxConvertedBytes - this.#convertedBytes) {
|
|
770
|
+
throw new ExternalSessionLimitError('converted_bytes', this.#limits.maxConvertedBytes, `Claude Code transcript converts to more than ${this.#limits.maxConvertedBytes} bytes`);
|
|
771
|
+
}
|
|
772
|
+
this.#convertedBytes += encodedBytes;
|
|
773
|
+
this.#messages.push(message);
|
|
774
|
+
}
|
|
775
|
+
#closeTurn() {
|
|
776
|
+
if (!this.#turn)
|
|
777
|
+
return;
|
|
778
|
+
// Every turn gets a terminal state, and which one depends on what the
|
|
779
|
+
// transcript actually says.
|
|
780
|
+
//
|
|
781
|
+
// Leaving one out is not the same as preserving "unfinished". Without a
|
|
782
|
+
// `turn_state`, `deriveTurnRecords` falls back to `inferLegacyTurnStatus`,
|
|
783
|
+
// which answers `completed` for any turn holding an assistant message
|
|
784
|
+
// (`session.ts:1250`) and marks it `inferred`. The Ledger then refuses
|
|
785
|
+
// that uncorroborated terminal and the repair path persists
|
|
786
|
+
// `failed / missing_terminal_event` — an internal-corruption verdict on a
|
|
787
|
+
// transcript that was merely cut short. Measured: 13.9% of turns across
|
|
788
|
+
// 1130 local transcripts end with no assistant reply or at a `tool_use`
|
|
789
|
+
// whose result never arrived.
|
|
790
|
+
//
|
|
791
|
+
// So an unfinished turn is recorded as what it is: a snapshot that ended
|
|
792
|
+
// mid-turn, with an `abortSource` naming the import rather than a user or
|
|
793
|
+
// a provider. `end_turn`, interrupt notices and API errors keep their own
|
|
794
|
+
// evidence and are unaffected.
|
|
795
|
+
if (this.#turn.aborted) {
|
|
796
|
+
this.#append({
|
|
797
|
+
type: 'turn_state',
|
|
798
|
+
id: this.#id('turn-state'),
|
|
799
|
+
turnId: this.#turn.turnId,
|
|
800
|
+
ts: this.#turn.lastTs,
|
|
801
|
+
status: 'aborted',
|
|
802
|
+
abortedAt: this.#turn.lastTs,
|
|
803
|
+
abortSource: 'claude-code.interrupt',
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
else if (this.#turn.failed) {
|
|
807
|
+
this.#append({
|
|
808
|
+
type: 'turn_state',
|
|
809
|
+
id: this.#id('turn-state'),
|
|
810
|
+
turnId: this.#turn.turnId,
|
|
811
|
+
ts: this.#turn.lastTs,
|
|
812
|
+
status: 'failed',
|
|
813
|
+
errorClass: 'claude_code_api_error',
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
else if (this.#turn.terminalStop) {
|
|
817
|
+
this.#append({
|
|
818
|
+
type: 'turn_state',
|
|
819
|
+
id: this.#id('turn-state'),
|
|
820
|
+
turnId: this.#turn.turnId,
|
|
821
|
+
ts: this.#turn.lastTs,
|
|
822
|
+
status: 'completed',
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
else {
|
|
826
|
+
this.#append({
|
|
827
|
+
type: 'turn_state',
|
|
828
|
+
id: this.#id('turn-state'),
|
|
829
|
+
turnId: this.#turn.turnId,
|
|
830
|
+
ts: this.#turn.lastTs,
|
|
831
|
+
status: 'aborted',
|
|
832
|
+
abortedAt: this.#turn.lastTs,
|
|
833
|
+
abortSource: EXTERNAL_SNAPSHOT_ABORT_SOURCE,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
this.#turn = undefined;
|
|
837
|
+
}
|
|
622
838
|
}
|
|
623
839
|
function asMessageRecord(record) {
|
|
624
840
|
const message = record.message;
|