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.
Files changed (71) hide show
  1. package/dist/pi-transcript.js +3 -4
  2. package/native/runtime-host-windows-task-launcher/prebuilds/win32-x64/maka-runtime-host-task-launcher.exe +0 -0
  3. package/node_modules/@maka/core/dist/agent-graph-schedule.js +15 -4
  4. package/node_modules/@maka/core/dist/executor-id.js +22 -0
  5. package/node_modules/@maka/core/dist/external-session.js +18 -0
  6. package/node_modules/@maka/core/dist/model-call-usage-projection.js +0 -13
  7. package/node_modules/@maka/core/dist/runtime-event.js +19 -3
  8. package/node_modules/@maka/core/dist/session-send-projection.js +6 -3
  9. package/node_modules/@maka/core/dist/session.js +8 -2
  10. package/node_modules/@maka/core/dist/shell-run-result.js +1 -0
  11. package/node_modules/@maka/core/dist/shell-run.js +4 -0
  12. package/node_modules/@maka/core/dist/work-board.js +94 -1
  13. package/node_modules/@maka/core/package.json +1 -0
  14. package/node_modules/@maka/eval/dist/fleet-simulation.js +270 -0
  15. package/node_modules/@maka/eval/dist/fleet-store.js +243 -0
  16. package/node_modules/@maka/eval/dist/fleet-worker.js +125 -0
  17. package/node_modules/@maka/eval/dist/fleet.js +329 -0
  18. package/node_modules/@maka/eval/dist/index.js +3 -0
  19. package/node_modules/@maka/runtime/dist/agent-run.js +2 -17
  20. package/node_modules/@maka/runtime/dist/ai-sdk-compaction.js +6 -2
  21. package/node_modules/@maka/runtime/dist/ai-sdk-message-projection.js +36 -24
  22. package/node_modules/@maka/runtime/dist/ai-sdk-turn.js +257 -330
  23. package/node_modules/@maka/runtime/dist/background-task-health-tool.js +104 -0
  24. package/node_modules/@maka/runtime/dist/history-compaction.js +3 -1
  25. package/node_modules/@maka/runtime/dist/local-web-fetch.js +1 -1
  26. package/node_modules/@maka/runtime/dist/model-adapter.js +62 -53
  27. package/node_modules/@maka/runtime/dist/model-history.js +1 -5
  28. package/node_modules/@maka/runtime/dist/plugin-executor-backend.js +311 -0
  29. package/node_modules/@maka/runtime/dist/plugin-executor-service.js +344 -0
  30. package/node_modules/@maka/runtime/dist/provider-error-classification.js +7 -1
  31. package/node_modules/@maka/runtime/dist/provider-request-telemetry.js +35 -8
  32. package/node_modules/@maka/runtime/dist/runtime-event-backfill.js +7 -1
  33. package/node_modules/@maka/runtime/dist/runtime-event-read-model.js +1 -0
  34. package/node_modules/@maka/runtime/dist/runtime-invocation-route.js +49 -0
  35. package/node_modules/@maka/runtime/dist/runtime-kernel.js +72 -150
  36. package/node_modules/@maka/runtime/dist/runtime-read-model.js +0 -2
  37. package/node_modules/@maka/runtime/dist/session-event-runtime-mapper.js +3 -0
  38. package/node_modules/@maka/runtime/dist/session-manager.js +100 -58
  39. package/node_modules/@maka/runtime/dist/shell-run-manager.js +9 -3
  40. package/node_modules/@maka/runtime/dist/shell-run-tool-result.js +1 -0
  41. package/node_modules/@maka/runtime/dist/stream-graph-schedule-reconcile.js +1 -0
  42. package/node_modules/@maka/runtime/dist/stream-graph-supervisor-tools.js +26 -4
  43. package/node_modules/@maka/runtime/dist/subagent-tools.js +7 -0
  44. package/node_modules/@maka/runtime/dist/tool-runtime.js +45 -466
  45. package/node_modules/@maka/runtime/package.json +3 -0
  46. package/node_modules/@maka/runtime-host/dist/adapter/session-projector.js +7 -2
  47. package/node_modules/@maka/runtime-host/dist/client/session-catalog-summary.js +1 -0
  48. package/node_modules/@maka/runtime-host/dist/protocol/external-session.js +24 -3
  49. package/node_modules/@maka/runtime-host/dist/protocol/index.js +4 -1
  50. package/node_modules/@maka/runtime-host/dist/protocol/plugin-platform.js +42 -5
  51. package/node_modules/@maka/runtime-host/dist/protocol/session-catalog.js +31 -3
  52. package/node_modules/@maka/runtime-host/dist/protocol/session-continuity.js +6 -0
  53. package/node_modules/@maka/runtime-host/dist/server/child-agent-composition.js +1 -0
  54. package/node_modules/@maka/runtime-host/dist/server/execution-artifacts.js +1 -22
  55. package/node_modules/@maka/runtime-host/dist/server/execution-composition.js +54 -8
  56. package/node_modules/@maka/runtime-host/dist/server/external-session-coordinator.js +14 -1
  57. package/node_modules/@maka/runtime-host/dist/server/host-session-availability.js +10 -2
  58. package/node_modules/@maka/runtime-host/dist/server/plugin-platform-coordinator.js +6 -0
  59. package/node_modules/@maka/runtime-host/dist/server/plugin-platform.js +6 -0
  60. package/node_modules/@maka/runtime-host/dist/server/session-catalog-coordinator.js +56 -16
  61. package/node_modules/@maka/runtime-host/dist/server/session-continuity-coordinator.js +4 -3
  62. package/node_modules/@maka/runtime-host/dist/server/session-revision-coordinator.js +4 -0
  63. package/node_modules/@maka/runtime-host/dist/server/web-fetch-tool.js +37 -1
  64. package/node_modules/@maka/storage/dist/claude-code-session-adapter.js +450 -234
  65. package/node_modules/@maka/storage/dist/claude-code-transcript-lineage.js +106 -77
  66. package/node_modules/@maka/storage/dist/legacy-run-header.js +2 -2
  67. package/node_modules/@maka/storage/dist/model-call-usage-sql.js +1 -1
  68. package/node_modules/@maka/storage/dist/session-store.js +12 -2
  69. package/node_modules/@maka/storage/dist/sqlite-long-term-memory-store.js +86 -12
  70. package/node_modules/@maka/storage/dist/work-board-store.js +40 -1
  71. 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 { readFile, readdir, stat } from 'node:fs/promises';
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 { resolveTranscriptLineage, } from './claude-code-transcript-lineage.js';
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
- /** A transcript larger than this is not read. Bounded for the same reason the
40
- * Codex rollout cap exists: a single hostile or runaway file must not be able
41
- * to exhaust the Host's memory during an import the user asked for. */
42
- export const CLAUDE_TRANSCRIPT_MAX_BYTES = 64 * 1024 * 1024;
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
- #maxBytes;
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.#maxBytes = options.maxTranscriptBytes ?? CLAUDE_TRANSCRIPT_MAX_BYTES;
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 this.#parse(path, sessionId);
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
- const parsed = await this.#parse(file.path, sessionId);
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
- async #parse(path, sessionId) {
210
- try {
211
- const info = await stat(path);
212
- if (info.size > this.#maxBytes)
213
- return undefined;
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
- catch {
216
- return undefined;
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
- let raw;
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
- raw = await readFile(path, 'utf8');
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
- return undefined;
287
+ catch (error) {
288
+ await handle.close();
289
+ throw error;
224
290
  }
225
- const records = [];
226
- let cwd = '';
227
- let isSidechain = false;
228
- let createdAt;
229
- let updatedAt;
230
- const titles = {};
231
- for (const line of raw.split('\n')) {
232
- const trimmed = line.trim();
233
- if (!trimmed)
234
- continue;
235
- let record;
236
- try {
237
- record = JSON.parse(trimmed);
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
- catch {
240
- // A torn final line is what an interrupted write leaves behind, and a
241
- // corrupt interior line is not worth failing an otherwise readable
242
- // transcript over. Skipping is what the scanner already does.
243
- continue;
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 (typeof record !== 'object' || record === null || Array.isArray(record))
246
- continue;
247
- const typed = record;
248
- records.push(typed);
249
- if (typed.isSidechain === true)
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 typed.cwd === 'string' && typed.cwd && !cwd)
252
- cwd = typed.cwd;
253
- const ts = timestampMs(typed);
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 = ts;
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.length === 0)
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 collectTitle(record, titles) {
283
- const take = (value) => typeof value === 'string' && value.trim() ? sanitizeForeignTitle(value) : undefined;
284
- switch (record.type) {
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
- * Which records are the conversation, decided before any of them becomes a
329
- * message. See `claude-code-transcript-lineage` for what the transcript's own
330
- * fields say about rewind branches, compaction boundaries, and fragmented
331
- * responses; this file only converts what that returns.
332
- */
333
- export function convertTranscript(sessionId, rawRecords) {
334
- const records = resolveTranscriptLineage(rawRecords).records;
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
- continue;
346
- const existing = responseFragments.get(responseId);
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
- responseFragments.set(responseId, [record]);
351
- }
352
- const emittedResponses = new Set();
353
- const messages = [];
354
- // A boundary can precede the first turn — a transcript that opens straight
355
- // after a compaction. A system note needs a turn to hang from, so the fact
356
- // waits for one rather than being dropped for arriving early.
357
- let pendingCompactBoundaryTs;
358
- let turn;
359
- let sequence = 0;
360
- const id = (kind) => `claude-code:${sessionId}:${kind}:${sequence++}`;
361
- // Turn ids count separately from message ids. Sharing one counter made turn
362
- // ids skip (`turn:0`, `turn:3`) for no reason, and left them one edit away
363
- // from colliding with a message id if the emission order ever changed.
364
- let turnSequence = 0;
365
- const nextTurnId = () => `claude-code:${sessionId}:turn:${turnSequence++}`;
366
- const closeTurn = () => {
367
- if (!turn)
368
- return;
369
- // Every turn gets a terminal state, and which one depends on what the
370
- // transcript actually says.
371
- //
372
- // Leaving one out is not the same as preserving "unfinished". Without a
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
- messages.push({
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
- continue;
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
- continue;
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
- messages.push({
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
- messages.push({ type: 'user', id: id('user'), turnId: turn.turnId, ts, text });
491
- continue;
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
- messages.push({
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
- continue;
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
- messages.push({
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
- messages.push({
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
- messages.push({
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
- continue;
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
- continue;
741
+ if (!this.#turn) {
742
+ this.#pendingCompactBoundaryTs = ts;
743
+ return;
610
744
  }
611
- messages.push({
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
- closeTurn();
621
- return messages;
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;