blob: 06caba5438b041b739838690ee717067ad64bfd1 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// Claude Code transcripts as Maka Sessions.
//
// Transcripts live at `~/.claude/projects/<encoded-cwd>/<uuid>.jsonl`, one
// JSON object per line, discriminated by `type`. This adapter owns the format
// parsing used by both catalog titles and imported messages, so the two paths
// agree about what the user actually said without exporting Claude internals.
//
// The directory name cannot answer which session belongs to which project —
// it encodes the cwd by replacing separators, so `-Users-a-b` is ambiguous
// between `/Users/a/b` and `/Users/a-b`. Every record carries its own `cwd`,
// and that is what a project-scoped query reads.
import { existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { open, readdir, stat, type FileHandle } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { sanitizeExternalSessionTitle } from '@maka/core/external-session';
import {
ExternalSessionLimitError,
ExternalSessionNotFoundError,
externalSessionMatchesQuery,
} from '@maka/core/external-session';
import type {
ExternalMakaSession,
ExternalSessionAdapter,
ExternalSessionCatalogPage,
ExternalSessionCatalogPageQuery,
ExternalSessionQuery,
ExternalSessionSummary,
} from '@maka/core/external-session';
import type { StoredMessage } from '@maka/core/session';
import {
TranscriptLineageIndexer,
type TranscriptRecord,
} from './claude-code-transcript-lineage.js';
import { listOffsetExternalSessionCatalogPage } from './offset-external-session-catalog.js';
export const CLAUDE_CODE_SESSION_ADAPTER_ID = 'claude-code';
/** Maximum source bytes scanned from one fixed transcript snapshot. */
export const CLAUDE_TRANSCRIPT_MAX_BYTES = 2 * 1024 * 1024 * 1024;
const CLAUDE_TRANSCRIPT_READ_BYTES = 64 * 1024;
const CLAUDE_TRANSCRIPT_MAX_RECORD_BYTES = 64 * 1024 * 1024;
const CLAUDE_TRANSCRIPT_MAX_CONVERTED_BYTES = 256 * 1024 * 1024;
const CLAUDE_TRANSCRIPT_MAX_MESSAGES = 250_000;
const CLAUDE_TRANSCRIPT_MAX_RECORDS = 1_000_000;
/**
* How much of one transcript a catalog summary may scan.
*
* A summary needs the head — cwd, the first prompt, when the session started —
* and the tail, where the source writes the titles it later gave the session.
* Everything between the two is conversation no catalog row shows. Reading to
* EOF instead made one page cost the whole corpus: a query that matches late,
* or nothing at all, paid for every transcript before it could answer.
*
* The two windows are disjoint and together bound one candidate at 512 KiB.
* Partial records at either edge are ignored rather than completed with the
* import reader's much larger per-record allowance. A page therefore costs at
* most its own candidates and never a transcript's length. A title or prompt
* hidden by a partial edge is the price: another visible title, or the Session
* id, stands in for it.
*
* The byte window is the whole budget. A record-count limit inside that window
* could only reject a transcript already fully covered by the byte limit,
* leaving a live session's `updatedAt` stale and changing its picker name.
*/
const CLAUDE_CATALOG_SUMMARY_HEAD_BYTES = 256 * 1024;
const CLAUDE_CATALOG_SUMMARY_TAIL_BYTES = 256 * 1024;
/** Session ids are the transcript's filename stem, and reach the filesystem.
* A uuid is what Claude Code writes; anything else is refused rather than
* joined onto a path. */
const SESSION_ID_PATTERN = /^[0-9a-fA-F-]{1,128}$/u;
export interface ClaudeCodeSessionAdapterOptions {
/** Overrides `~/.claude`. */
claudeHome?: string;
/** Maximum source bytes scanned from one fixed transcript snapshot. */
maxTranscriptBytes?: number;
/** Maximum bytes buffered for one JSONL record. */
maxRecordBytes?: number;
/** Maximum serialized bytes retained across converted messages. */
maxConvertedBytes?: number;
/** Maximum number of converted messages retained in memory. */
maxMessages?: number;
/** Maximum parsed JSONL records in one transcript snapshot. */
maxRecords?: number;
}
interface TranscriptSummary {
readonly cwd: string;
readonly title: string;
readonly createdAt?: number;
readonly updatedAt?: number;
readonly isSidechain: boolean;
}
export class ClaudeCodeSessionAdapter implements ExternalSessionAdapter {
readonly id = CLAUDE_CODE_SESSION_ADAPTER_ID;
readonly #home: string;
readonly #limits: ClaudeTranscriptLimits;
/**
* Summaries already derived from a transcript, keyed by path and invalidated
* by the file's own mtime and size.
*
* Listing reads and parses every transcript: 1128 of them take about a
* second here, and the catalog is listed once per search term. Without this,
* a pause in typing starts another full parse of files that have not changed
* since the last one — the cost is paid again for an answer already known.
*
* Keyed on what the filesystem reports rather than a timer: a transcript
* that Claude Code appended to must be re-read, and one that did not change
* cannot have a different summary.
*/
readonly #summaries = new Map<
string,
{ mtimeMs: number; size: number; summary?: ExternalSessionSummary }
>();
constructor(options: ClaudeCodeSessionAdapterOptions = {}) {
this.#home = options.claudeHome ?? join(homedir(), '.claude');
this.#limits = {
maxTranscriptBytes: options.maxTranscriptBytes ?? CLAUDE_TRANSCRIPT_MAX_BYTES,
maxRecordBytes: options.maxRecordBytes ?? CLAUDE_TRANSCRIPT_MAX_RECORD_BYTES,
maxConvertedBytes: options.maxConvertedBytes ?? CLAUDE_TRANSCRIPT_MAX_CONVERTED_BYTES,
maxMessages: options.maxMessages ?? CLAUDE_TRANSCRIPT_MAX_MESSAGES,
maxRecords: options.maxRecords ?? CLAUDE_TRANSCRIPT_MAX_RECORDS,
};
assertPositiveSafeInteger(this.#limits.maxTranscriptBytes, 'Claude transcript byte limit');
assertPositiveSafeInteger(this.#limits.maxRecordBytes, 'Claude transcript record byte limit');
assertPositiveSafeInteger(
this.#limits.maxConvertedBytes,
'Claude converted message byte limit',
);
assertPositiveSafeInteger(this.#limits.maxMessages, 'Claude converted message count limit');
assertPositiveSafeInteger(this.#limits.maxRecords, 'Claude transcript record count limit');
}
async detect(): Promise<boolean> {
return existsSync(this.#projectsRoot());
}
async listSessions(query?: ExternalSessionQuery): Promise<readonly ExternalSessionSummary[]> {
const summaries: ExternalSessionSummary[] = [];
const live = new Set<string>();
const offset = query?.offset ?? 0;
const limit = query?.limit ?? Number.MAX_SAFE_INTEGER;
let matched = 0;
const files = await this.#transcriptFiles();
for (const file of files) live.add(file.path);
for (const file of files) {
const summary = await this.#summaryOf(file.path, file.sessionId);
if (!summary) continue;
// The shared matcher, not a local cwd comparison: filtering happens here
// rather than after paging, and every source has to answer a query the
// same way or the catalog lies about which one dropped the term.
if (!externalSessionMatchesQuery(summary, query)) continue;
if (matched++ < offset) continue;
summaries.push(summary);
if (summaries.length === limit) break;
}
// A transcript the source no longer lists must not keep its entry alive,
// or a long-lived Host grows one per deleted session.
for (const path of this.#summaries.keys()) {
if (!live.has(path)) this.#summaries.delete(path);
}
return summaries;
}
async listSessionPage(
query: ExternalSessionCatalogPageQuery,
): Promise<ExternalSessionCatalogPage> {
return listOffsetExternalSessionCatalogPage(query, (pageQuery) => this.listSessions(pageQuery));
}
/**
* The summary for one transcript, parsed only when the file has changed.
*
* `undefined` is cached too: a sidechain transcript or an unreadable one is
* a stable answer, and re-deriving it every list would defeat the point.
*/
async #summaryOf(path: string, sessionId: string): Promise<ExternalSessionSummary | undefined> {
let mtimeMs: number;
let size: number;
try {
const info = await stat(path);
mtimeMs = info.mtimeMs;
size = info.size;
} catch {
this.#summaries.delete(path);
return undefined;
}
const cached = this.#summaries.get(path);
if (cached && cached.mtimeMs === mtimeMs && cached.size === size) return cached.summary;
const parsed = await readTranscriptSummary(path);
// Sub-agent transcripts are whole files, never records interleaved into a
// parent — so exclusion is per file. Importing one would present a
// fragment of a conversation as a conversation.
const summary =
parsed && !parsed.isSidechain
? {
id: sessionId,
name: parsed.title || sessionId,
cwd: parsed.cwd,
...(parsed.createdAt !== undefined ? { createdAt: parsed.createdAt } : {}),
...(parsed.updatedAt !== undefined ? { updatedAt: parsed.updatedAt } : {}),
}
: undefined;
this.#summaries.set(path, { mtimeMs, size, ...(summary ? { summary } : {}) });
return summary;
}
async readSession(sessionId: string): Promise<ExternalMakaSession> {
assertSafeSessionId(sessionId);
const file = (await this.#transcriptFiles()).find(
(candidate) => candidate.sessionId === sessionId,
);
if (!file) throw new ExternalSessionNotFoundError();
return convertClaudeTranscript(file.path, sessionId, this.#limits);
}
#projectsRoot(): string {
return join(this.#home, 'projects');
}
async #transcriptFiles(): Promise<
ReadonlyArray<{ path: string; sessionId: string; mtimeMs: number }>
> {
const root = this.#projectsRoot();
let projects: string[];
try {
projects = (await readdir(root, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
} catch {
return [];
}
// Keyed by session id: the same id can legitimately exist under more than
// one project directory after a workspace move or a resumed session. Two
// files with one id are two candidates for the same source session, and
// list and read must pick the same one or a user selects one summary and
// imports the other.
const bySessionId = new Map<string, { path: string; sessionId: string; mtimeMs: number }>();
for (const project of projects) {
let entries: string[];
try {
entries = (await readdir(join(root, project), { withFileTypes: true }))
.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'))
.map((entry) => entry.name);
} catch {
continue;
}
for (const name of entries) {
const sessionId = name.slice(0, -'.jsonl'.length);
if (!SESSION_ID_PATTERN.test(sessionId)) continue;
const path = join(root, project, name);
// The id reaches a path join, so the resolved file must still be under
// the projects root — a crafted id must not read outside it.
if (!resolve(path).startsWith(resolve(root))) continue;
let mtimeMs: number;
try {
mtimeMs = (await stat(path)).mtimeMs;
} catch {
continue;
}
const existing = bySessionId.get(sessionId);
// Newest wins, and the path breaks a tie so the choice does not depend
// on directory iteration order. A resumed session's continuation is
// the copy a user means when they pick that id.
if (
!existing ||
mtimeMs > existing.mtimeMs ||
(mtimeMs === existing.mtimeMs && path < existing.path)
) {
bySessionId.set(sessionId, { path, sessionId, mtimeMs });
}
}
}
return [...bySessionId.values()].sort(
(left, right) => right.mtimeMs - left.mtimeMs || left.path.localeCompare(right.path),
);
}
}
interface ClaudeTranscriptLimits {
readonly maxTranscriptBytes: number;
readonly maxRecordBytes: number;
readonly maxConvertedBytes: number;
readonly maxMessages: number;
readonly maxRecords: number;
}
interface ClaudeTitleCandidates {
customTitle?: string;
aiTitle?: string;
summary?: string;
lastPrompt?: string;
firstUserMessage?: string;
}
type ClaudeTranscriptReadLimits = Pick<ClaudeTranscriptLimits, 'maxRecordBytes' | 'maxRecords'>;
async function convertClaudeTranscript(
path: string,
sessionId: string,
limits: ClaudeTranscriptLimits,
): Promise<ExternalMakaSession> {
const snapshot = await ClaudeTranscriptSnapshot.open(path, sessionId, limits.maxTranscriptBytes);
try {
const indexer = new TranscriptLineageIndexer();
let cwd = '';
let isSidechain = false;
const titles: ClaudeTitleCandidates = {};
let records = 0;
for await (const record of snapshot.records(sessionId, limits)) {
records += 1;
indexer.accept(record);
if (record.isSidechain === true) isSidechain = true;
if (!cwd && typeof record.cwd === 'string' && record.cwd) cwd = record.cwd;
collectClaudeTitle(record, titles);
collectLegacyClaudeTitle(record, titles);
}
if (records === 0) throw new Error(`Claude Code transcript could not be read: ${sessionId}`);
if (isSidechain) {
throw new Error(`Claude Code transcript is a sub-agent sidechain: ${sessionId}`);
}
const lineage = indexer.finish();
const fragmentFilter = lineage.createFilter();
const responseCollector = new ClaudeResponseCollector(limits.maxConvertedBytes);
for await (const record of snapshot.records(sessionId, limits)) {
if (!fragmentFilter.keep(record) || record.type !== 'assistant') continue;
responseCollector.accept(record);
}
const converter = new ClaudeTranscriptConverter(sessionId, responseCollector.responses, limits);
const conversionFilter = lineage.createFilter();
for await (const record of snapshot.records(sessionId, limits)) {
if (conversionFilter.keep(record)) converter.accept(record);
}
return {
sourceSessionId: sessionId,
metadata: { name: pickClaudeTitle(titles) || sessionId, cwd },
messages: converter.finish(),
};
} finally {
await snapshot.close();
}
}
/**
* One open file descriptor and one byte length define the import snapshot.
* Appends are ignored, path replacement cannot redirect later passes, and a
* digest check rejects in-place rewrites that would otherwise mix records
* from different points in time.
*/
class ClaudeTranscriptSnapshot {
readonly #handle: FileHandle;
readonly #size: number;
#digest: string | undefined;
private constructor(handle: FileHandle, size: number) {
this.#handle = handle;
this.#size = size;
}
static async open(
path: string,
sessionId: string,
maxBytes: number,
): Promise<ClaudeTranscriptSnapshot> {
const handle = await open(path, 'r');
try {
const metadata = await handle.stat();
if (!metadata.isFile()) throw new Error('Claude Code transcript is not a regular file');
if (metadata.size > maxBytes) {
throw new ExternalSessionLimitError(
'transcript_bytes',
maxBytes,
`Claude Code transcript exceeds ${maxBytes} bytes: ${sessionId}`,
);
}
return new ClaudeTranscriptSnapshot(handle, metadata.size);
} catch (error) {
await handle.close();
throw error;
}
}
async *records(
sessionId: string,
limits: ClaudeTranscriptReadLimits,
): AsyncGenerator<TranscriptRecord> {
const digest = createHash('sha256');
yield* readClaudeTranscriptRecords(this.#handle, this.#size, sessionId, limits, (chunk) =>
digest.update(chunk),
);
this.#verifyDigest(digest.digest('hex'));
}
async close(): Promise<void> {
await this.#handle.close();
}
#verifyDigest(digest: string): void {
if (this.#digest === undefined) {
this.#digest = digest;
return;
}
if (this.#digest !== digest) {
throw new Error('Claude Code transcript changed while being read');
}
}
}
async function* readClaudeTranscriptRecords(
handle: FileHandle,
snapshotBytes: number,
sessionId: string,
limits: ClaudeTranscriptReadLimits,
observeChunk?: (chunk: Buffer) => void,
): AsyncGenerator<TranscriptRecord> {
const metadata = await handle.stat();
if (!metadata.isFile()) throw new Error('Claude Code transcript is not a regular file');
if (metadata.size < snapshotBytes) {
throw new Error('Claude Code transcript changed while being read');
}
const pending: Buffer[] = [];
let pendingBytes = 0;
let observedBytes = 0;
let parsedRecords = 0;
if (snapshotBytes > 0) {
for await (const value of handle.createReadStream({
autoClose: false,
emitClose: false,
start: 0,
end: snapshotBytes - 1,
highWaterMark: CLAUDE_TRANSCRIPT_READ_BYTES,
})) {
const chunk = Buffer.from(value);
observeChunk?.(chunk);
observedBytes += chunk.byteLength;
if (observedBytes > snapshotBytes) {
throw new Error('Claude Code transcript changed while being read');
}
let start = 0;
for (;;) {
const newline = chunk.indexOf(0x0a, start);
if (newline === -1) break;
const segment = chunk.subarray(start, newline);
assertClaudeRecordSize(pendingBytes + segment.byteLength, limits.maxRecordBytes, sessionId);
const bytes =
pending.length === 0
? segment
: Buffer.concat([...pending, segment], pendingBytes + segment.byteLength);
const record = parseClaudeTranscriptLine(bytes);
if (record) {
parsedRecords += 1;
assertClaudeRecordCount(parsedRecords, limits.maxRecords, sessionId);
yield record;
}
pending.length = 0;
pendingBytes = 0;
start = newline + 1;
}
if (start < chunk.byteLength) {
const segment = chunk.subarray(start);
assertClaudeRecordSize(pendingBytes + segment.byteLength, limits.maxRecordBytes, sessionId);
pending.push(segment);
pendingBytes += segment.byteLength;
}
}
}
if (observedBytes !== snapshotBytes) {
throw new Error('Claude Code transcript changed while being read');
}
if (pendingBytes > 0) {
const record = parseClaudeTranscriptLine(
pending.length === 1 ? pending[0]! : Buffer.concat(pending, pendingBytes),
);
if (record) {
parsedRecords += 1;
assertClaudeRecordCount(parsedRecords, limits.maxRecords, sessionId);
yield record;
}
}
}
function assertClaudeRecordSize(actualBytes: number, maxBytes: number, sessionId: string): void {
if (actualBytes > maxBytes) {
throw new ClaudeTranscriptReadLimitError(
'record_bytes',
maxBytes,
`Claude Code transcript record exceeds ${maxBytes} bytes: ${sessionId}`,
);
}
}
function assertClaudeRecordCount(actual: number, maxRecords: number, sessionId: string): void {
if (actual > maxRecords) {
throw new ClaudeTranscriptReadLimitError(
'records',
maxRecords,
`Claude Code transcript has more than ${maxRecords} records: ${sessionId}`,
);
}
}
class ClaudeTranscriptReadLimitError extends ExternalSessionLimitError {}
function parseClaudeTranscriptLine(bytes: Buffer): TranscriptRecord | undefined {
const text = bytes.toString('utf8').trim();
if (!text) return undefined;
try {
const value = JSON.parse(text) as unknown;
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as TranscriptRecord)
: undefined;
} catch {
// Interrupted writes leave a torn tail, while old transcripts can contain
// corrupt interior lines. Both are skipped so one bad line
// does not erase an otherwise readable conversation.
return undefined;
}
}
/**
* The catalog's view of one transcript, read within the summary budget above.
*
* Only the head and the tail are read, and `records === 0` still means "no
* transcript here". Everything the summary reports is derived the way a full
* read derived it — last title wins, first timestamp is `createdAt` — so a
* transcript inside the budget reads exactly as it did before.
*/
async function readTranscriptSummary(path: string): Promise<TranscriptSummary | undefined> {
const handle = await open(path, 'r').catch(() => undefined);
if (!handle) return undefined;
const scan: TranscriptSummaryScan = { titles: {}, cwd: '', isSidechain: false };
try {
const info = await handle.stat();
if (!info.isFile()) return undefined;
const head = await readSummaryHead(handle, info.size, scan);
let records = head.records;
if (head.end < info.size) {
records += await readSummaryTail(handle, info.size, head.end, head.endsOnBoundary, scan);
}
if (records === 0) {
// A large opening record can occupy the complete head window. Listing
// must not complete it with the import path's 64 MiB allowance, but it
// must not hide a real source Session either. File metadata is enough
// for a stable selectable row; import reports any record limit later.
if (info.size < CLAUDE_CATALOG_SUMMARY_HEAD_BYTES) return undefined;
return { cwd: scan.cwd, title: '', updatedAt: info.mtimeMs, isSidechain: false };
}
return {
cwd: scan.cwd,
title: pickClaudeTitle(scan.titles),
...(scan.createdAt !== undefined ? { createdAt: scan.createdAt } : {}),
...(scan.updatedAt !== undefined ? { updatedAt: scan.updatedAt } : {}),
isSidechain: scan.isSidechain,
};
} catch {
return undefined;
} finally {
await handle.close();
}
}
interface TranscriptSummaryScan {
readonly titles: ClaudeTitleCandidates;
cwd: string;
isSidechain: boolean;
createdAt?: number;
updatedAt?: number;
}
function observeSummaryRecord(record: TranscriptRecord, scan: TranscriptSummaryScan): void {
collectClaudeTitle(record, scan.titles);
collectLegacyClaudeTitle(record, scan.titles);
if (record.isSidechain === true) scan.isSidechain = true;
if (!scan.cwd && typeof record.cwd === 'string' && record.cwd) scan.cwd = record.cwd;
const ts = timestampMs(record);
if (ts !== undefined) {
scan.createdAt ??= ts;
if (scan.updatedAt === undefined || ts > scan.updatedAt) scan.updatedAt = ts;
}
}
/**
* The transcript's fixed opening window. A trailing partial record is ignored;
* listing never borrows the import path's per-record allowance to complete it.
*/
async function readSummaryHead(
handle: FileHandle,
size: number,
scan: TranscriptSummaryScan,
): Promise<{ records: number; end: number; endsOnBoundary: boolean }> {
const window = Math.min(size, CLAUDE_CATALOG_SUMMARY_HEAD_BYTES);
const buffer = Buffer.allocUnsafe(window);
const { bytesRead } = await handle.read(buffer, 0, window, 0);
const endsOnBoundary = bytesRead === 0 || buffer[bytesRead - 1] === 0x0a;
const completeEnd =
bytesRead === size || endsOnBoundary ? bytesRead : buffer.lastIndexOf(0x0a, bytesRead - 1) + 1;
const records = observeSummaryLines(transcriptLines(buffer.subarray(0, completeEnd)), scan);
if (!scan.cwd) scan.cwd = topLevelJsonStringField(buffer.subarray(0, bytesRead), 'cwd') ?? '';
return { records, end: bytesRead, endsOnBoundary };
}
function topLevelJsonStringField(buffer: Buffer, field: string): string | undefined {
const text = buffer.toString('utf8');
let depth = 0;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (char === '{' || char === '[') {
depth += 1;
continue;
}
if (char === '}' || char === ']') {
depth -= 1;
continue;
}
if (char !== '"') continue;
const end = jsonStringEnd(text, index);
if (end === undefined) return undefined;
if (depth === 1) {
let colon = end + 1;
while (/\s/u.test(text[colon] ?? '')) colon += 1;
if (text[colon] === ':') {
const key = parseJsonString(text, index, end);
if (key === field) {
let valueStart = colon + 1;
while (/\s/u.test(text[valueStart] ?? '')) valueStart += 1;
if (text[valueStart] !== '"') return undefined;
const valueEnd = jsonStringEnd(text, valueStart);
if (valueEnd === undefined) return undefined;
const value = parseJsonString(text, valueStart, valueEnd);
return value || undefined;
}
}
}
index = end;
}
return undefined;
}
function parseJsonString(text: string, start: number, end: number): string | undefined {
try {
const value = JSON.parse(text.slice(start, end + 1)) as unknown;
return typeof value === 'string' ? value : undefined;
} catch {
return undefined;
}
}
function jsonStringEnd(text: string, start: number): number | undefined {
let escaped = false;
for (let index = start + 1; index < text.length; index += 1) {
const char = text[index];
if (escaped) escaped = false;
else if (char === '\\') escaped = true;
else if (char === '"') return index;
}
return undefined;
}
/**
* The transcript's closing window, which is where the source writes the titles
* it gave the session after the fact.
*
* The window opens wherever the byte budget put it, which is as likely to be
* inside a record as on its boundary. The byte before it says which: the head
* always ends on a boundary, so a window that starts where the head stopped
* opens on one, and anything else is a fragment the head already read past.
*/
async function readSummaryTail(
handle: FileHandle,
size: number,
after: number,
headEndsOnBoundary: boolean,
scan: TranscriptSummaryScan,
): Promise<number> {
const start = Math.min(size, Math.max(after, size - CLAUDE_CATALOG_SUMMARY_TAIL_BYTES));
if (start >= size) return 0;
const buffer = Buffer.allocUnsafe(size - start);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
const tail = buffer.subarray(0, bytesRead);
const startsOnBoundary = start === after && headEndsOnBoundary;
const firstCompleteRecord = startsOnBoundary ? 0 : tail.indexOf(0x0a) + 1;
if (startsOnBoundary || firstCompleteRecord > 0) {
return observeSummaryLines(transcriptLines(tail.subarray(firstCompleteRecord)), scan);
}
return 0;
}
/**
* Splits a byte range into records.
*
* The split is on `0x0a`, which never appears inside a multi-byte UTF-8
* sequence, so a record boundary is a record boundary whatever it holds.
*/
function transcriptLines(buffer: Buffer): Buffer[] {
const lines: Buffer[] = [];
let start = 0;
for (;;) {
const newline = buffer.indexOf(0x0a, start);
if (newline === -1) break;
lines.push(buffer.subarray(start, newline));
start = newline + 1;
}
if (start < buffer.length) lines.push(buffer.subarray(start));
return lines;
}
function observeSummaryLines(lines: readonly Buffer[], scan: TranscriptSummaryScan): number {
let records = 0;
for (const line of lines) {
const record = parseClaudeTranscriptLine(line);
if (!record) continue;
records += 1;
observeSummaryRecord(record, scan);
}
return records;
}
function collectLegacyClaudeTitle(record: TranscriptRecord, titles: ClaudeTitleCandidates): void {
const take = (value: unknown): string | undefined =>
typeof value === 'string' && value.trim() ? sanitizeExternalSessionTitle(value) : undefined;
if (record.type === 'ai-title') {
titles.aiTitle = take(record.aiTitle ?? record.title) ?? titles.aiTitle;
} else if (record.type === 'last-prompt') {
titles.lastPrompt = take(record.lastPrompt ?? record.prompt) ?? titles.lastPrompt;
}
}
function collectClaudeTitle(record: TranscriptRecord, titles: ClaudeTitleCandidates): void {
if (typeof record.customTitle === 'string' && record.customTitle.length > 0) {
titles.customTitle = record.customTitle;
}
if (typeof record.aiTitle === 'string' && record.aiTitle.length > 0)
titles.aiTitle = record.aiTitle;
if (typeof record.summary === 'string' && record.summary.length > 0)
titles.summary = record.summary;
if (typeof record.lastPrompt === 'string' && record.lastPrompt.length > 0) {
titles.lastPrompt = record.lastPrompt;
}
if (titles.firstUserMessage === undefined) {
const candidate = claudeFirstPromptCandidate(record);
if (candidate !== undefined) titles.firstUserMessage = candidate;
}
}
function claudeFirstPromptCandidate(record: TranscriptRecord): string | undefined {
if (record.type !== 'user' || record.isMeta === true || record.isCompactSummary === true) {
return undefined;
}
const raw = claudeMessageText(record);
if (raw === undefined) return undefined;
const commandName = raw.match(/<command-name>([^<]+)<\/command-name>/);
if (commandName) return commandName[1]!.trim();
const bashInput = raw.match(/<bash-input>([^<]+)<\/bash-input>/);
if (bashInput) return `! ${bashInput[1]!.trim()}`;
const text = raw.trim();
if (isSyntheticClaudeUserText(text)) return undefined;
return text.length > 0 ? text : undefined;
}
function isSyntheticClaudeUserText(text: string): boolean {
const value = text.trimStart();
return (
value.startsWith('[Request interrupted by user') ||
/^<\/?(command-(name|message|args|contents)|local-command-(stdout|stderr)|bash-(input|stdout|stderr))[\s>]/.test(
value,
)
);
}
function pickClaudeTitle(titles: ClaudeTitleCandidates): string {
return sanitizeExternalSessionTitle(
titles.customTitle ??
titles.aiTitle ??
titles.lastPrompt ??
titles.summary ??
titles.firstUserMessage,
);
}
function claudeUserAuthoredText(record: TranscriptRecord): string | undefined {
if (record.isMeta === true || record.isCompactSummary === true) return undefined;
const text = claudeMessageText(record);
return text === undefined || isSyntheticClaudeUserText(text) ? undefined : text;
}
function claudeMessageText(record: TranscriptRecord): string | undefined {
const message = asMessageRecord(record);
if (!message) return undefined;
const content = message.content;
if (typeof content === 'string') return content.length > 0 ? content : undefined;
if (!Array.isArray(content)) return undefined;
const text = contentBlocks(message)
.filter((block) => block.type === 'text')
.map((block) => (typeof block.text === 'string' ? block.text : ''))
.join('\n')
.trim();
return text.length > 0 ? text : undefined;
}
function assertSafeSessionId(sessionId: string): void {
if (!SESSION_ID_PATTERN.test(sessionId)) {
throw new Error('Claude Code session id is not a transcript name');
}
}
function assertPositiveSafeInteger(value: number, label: string): void {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${label} must be a positive safe integer`);
}
}
function timestampMs(record: TranscriptRecord): number | undefined {
const value = record.timestamp;
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
/* ------------------------------------------------------------------ *
* Transcript -> StoredMessage[]
* ------------------------------------------------------------------ */
/** `stop_reason` values that mean the model finished what it was saying. This
* is the recorded evidence a terminal `turn_state` needs: the Ledger refuses a
* reconstructed terminal that no record corroborates
* (`runtime-ledger-repair.ts`), and rightly so — a transcript killed
* mid-answer must not import as one that completed.
*
* `max_tokens` is deliberately absent. It does report that generation stopped,
* but it stopped because the answer hit the output limit — the turn was cut
* off mid-sentence, which is the opposite of completed. It is rare (1
* occurrence across 1130 local transcripts) and imports with no terminal
* state, the same as any other turn whose end nothing vouches for. */
const TERMINAL_STOP_REASONS = new Set(['end_turn', 'stop_sequence']);
/** Names the cutoff for a turn the transcript simply stops inside, so a reader
* can tell an imported snapshot's edge from a user's Stop or a provider abort. */
const EXTERNAL_SNAPSHOT_ABORT_SOURCE = 'external_session_snapshot';
interface TurnAccumulator {
turnId: string;
lastTs: number;
/** Set when a terminal `stop_reason` is seen; the turn ends `completed`. */
terminalStop?: string;
/** Set by `isApiErrorMessage`; the turn ends `failed`. */
failed?: boolean;
/** Set by an interrupt notice; the turn ends `aborted`. */
aborted?: boolean;
}
class ClaudeResponseCollector {
readonly responses = new Map<string, TranscriptRecord[]>();
readonly #maxBytes: number;
#retainedBytes = 0;
constructor(maxBytes: number) {
this.#maxBytes = maxBytes;
}
accept(record: TranscriptRecord): void {
const responseId = stringOf(asMessageRecord(record)?.id);
if (responseId === undefined) return;
const retainedBytes = Buffer.byteLength(JSON.stringify(record), 'utf8');
if (retainedBytes > this.#maxBytes - this.#retainedBytes) {
throw new ExternalSessionLimitError(
'converted_bytes',
this.#maxBytes,
`Claude Code transcript converts to more than ${this.#maxBytes} bytes`,
);
}
this.#retainedBytes += retainedBytes;
const existing = this.responses.get(responseId);
if (existing) existing.push(record);
else this.responses.set(responseId, [record]);
}
}
class ClaudeTranscriptConverter {
readonly #emittedResponses = new Set<string>();
readonly #messages: StoredMessage[] = [];
readonly #sessionId: string;
readonly #responseFragments: Map<string, TranscriptRecord[]>;
readonly #limits: Pick<ClaudeTranscriptLimits, 'maxConvertedBytes' | 'maxMessages'>;
#pendingCompactBoundaryTs: number | undefined;
#turn: TurnAccumulator | undefined;
#sequence = 0;
#turnSequence = 0;
#convertedBytes = 0;
constructor(
sessionId: string,
responseFragments: Map<string, TranscriptRecord[]>,
limits: Pick<ClaudeTranscriptLimits, 'maxConvertedBytes' | 'maxMessages'>,
) {
this.#sessionId = sessionId;
this.#responseFragments = responseFragments;
this.#limits = limits;
}
accept(record: TranscriptRecord): void {
const ts = timestampMs(record) ?? this.#turn?.lastTs ?? 0;
if (this.#turn) this.#turn.lastTs = ts;
const type = record.type;
if (type === 'user') {
const message = asMessageRecord(record);
const toolResults = toolResultBlocks(message);
if (toolResults.length > 0) {
// Tool results arrive as `user` records — the harness replying to the
// model, not the human. Importing them as user Turns would put the
// model's own tool output in the user's mouth.
for (const block of toolResults) {
if (!this.#turn) continue;
const toolUseId = stringOf(block.tool_use_id);
// A result with no `tool_use_id` cannot be matched to its call.
// Minting one produces a result that is guaranteed not to pair with
// anything — a detached row in the transcript view, which is worse
// than the row being absent.
if (!toolUseId) continue;
this.#append({
type: 'tool_result',
id: this.#id('tool-result'),
turnId: this.#turn.turnId,
ts,
toolUseId,
isError: block.is_error === true,
content: { kind: 'text', text: toolResultText(block.content) },
});
}
return;
}
const text = claudeUserAuthoredText(record);
if (text === undefined) {
// Synthetic user text: interrupt notices and command wrappers. The
// interrupt notice is one of the few terminal facts a transcript
// carries, so it is read for status even though it is not a message.
const raw = rawUserText(message);
if (
raw &&
isSyntheticClaudeUserText(raw) &&
raw.trimStart().startsWith('[Request interrupted')
) {
if (this.#turn) this.#turn.aborted = true;
}
return;
}
// A human-authored user record opens a new turn.
this.#closeTurn();
this.#turn = { turnId: this.#nextTurnId(), lastTs: ts };
if (this.#pendingCompactBoundaryTs !== undefined) {
this.#append({
type: 'system_note',
id: this.#id('compact'),
turnId: this.#turn.turnId,
ts: this.#pendingCompactBoundaryTs,
kind: 'context_compacted',
});
this.#pendingCompactBoundaryTs = undefined;
}
this.#append({
type: 'user',
id: this.#id('user'),
turnId: this.#turn.turnId,
ts,
text,
});
return;
}
if (type === 'assistant') {
if (!this.#turn) {
// A transcript can open with an assistant record when the session was
// resumed. Give it a turn rather than dropping the content.
this.#turn = { turnId: this.#nextTurnId(), lastTs: ts };
if (this.#pendingCompactBoundaryTs !== undefined) {
this.#append({
type: 'system_note',
id: this.#id('compact'),
turnId: this.#turn.turnId,
ts: this.#pendingCompactBoundaryTs,
kind: 'context_compacted',
});
this.#pendingCompactBoundaryTs = undefined;
}
}
if (record.isApiErrorMessage === true) this.#turn.failed = true;
const message = asMessageRecord(record);
const responseId = stringOf(message?.id);
// A response is emitted once, at its first fragment, assembled from all
// of them. A later fragment reached here is that same response still
// being written — its content is already in what was emitted, and
// emitting again would repeat the reply.
if (responseId !== undefined) {
if (this.#emittedResponses.has(responseId)) return;
this.#emittedResponses.add(responseId);
}
// A fragment with no id stands alone; it is the only fragment of itself.
const fragments = (responseId === undefined
? undefined
: this.#responseFragments.get(responseId)) ?? [record];
// Status evidence is read from every fragment, not just the first: the
// `stop_reason` lands on whichever fragment the response finished on.
for (const fragment of fragments) {
if (fragment.isApiErrorMessage === true) this.#turn.failed = true;
const stop = stringOf(asMessageRecord(fragment)?.stop_reason);
if (stop && TERMINAL_STOP_REASONS.has(stop)) this.#turn.terminalStop = stop;
}
// The transcript names the model that produced each step. Carrying the
// real value keeps an imported turn attributable; a placeholder would
// put a model the user never ran onto their history.
const modelId = stringOf(message?.model) ?? 'claude-code';
// Concatenated in fragment order, which is the order the response was
// streamed. Joining rather than picking one: every delta is content the
// model produced, and choosing between them would be choosing which
// half of a reply to keep.
const thinking = fragments
.map((fragment) => thinkingText(asMessageRecord(fragment)))
.filter((part) => part.length > 0)
.join('\n\n');
if (thinking) {
this.#append({
type: 'assistant',
id: this.#id('thinking'),
turnId: this.#turn.turnId,
ts,
text: '',
thinking: { text: thinking },
contentOrder: ['thinking'],
modelId,
});
}
const text = fragments
.map((fragment) => claudeMessageText(fragment))
.filter((part): part is string => part !== undefined && part.length > 0)
.join('\n\n');
if (text) {
this.#append({
type: 'assistant',
id: this.#id('assistant'),
turnId: this.#turn.turnId,
ts,
text,
contentOrder: ['text'],
modelId,
});
}
// Every call the response made, before any of their results. Calls
// sharing a `message.id` came from one API response, so they were
// issued together however the log interleaved them with the results
// arriving; a call written after its sibling's result did not follow it.
for (const fragment of fragments) {
for (const block of toolUseBlocks(asMessageRecord(fragment))) {
this.#append({
type: 'tool_call',
// The id must equal the tool_use id so the result can match it.
id: stringOf(block.id) ?? this.#id('tool-call'),
turnId: this.#turn.turnId,
ts,
toolName: stringOf(block.name) ?? 'unknown',
args: block.input ?? {},
});
}
}
if (responseId !== undefined) this.#responseFragments.delete(responseId);
return;
}
// Preserve pre-boundary records and emit the boundary from the record that
// states it, so readers can see where the model context restarted.
if (record.subtype === 'compact_boundary') {
if (!this.#turn) {
this.#pendingCompactBoundaryTs = ts;
return;
}
this.#append({
type: 'system_note',
id: this.#id('compact'),
turnId: this.#turn.turnId,
ts,
kind: 'context_compacted',
});
}
}
finish(): readonly StoredMessage[] {
this.#closeTurn();
return this.#messages;
}
#id(kind: string): string {
return `claude-code:${this.#sessionId}:${kind}:${this.#sequence++}`;
}
#nextTurnId(): string {
return `claude-code:${this.#sessionId}:turn:${this.#turnSequence++}`;
}
#append(message: StoredMessage): void {
if (this.#messages.length >= this.#limits.maxMessages) {
throw new ExternalSessionLimitError(
'messages',
this.#limits.maxMessages,
`Claude Code transcript converts to more than ${this.#limits.maxMessages} messages`,
);
}
const encodedBytes = Buffer.byteLength(JSON.stringify(message), 'utf8');
if (encodedBytes > this.#limits.maxConvertedBytes - this.#convertedBytes) {
throw new ExternalSessionLimitError(
'converted_bytes',
this.#limits.maxConvertedBytes,
`Claude Code transcript converts to more than ${this.#limits.maxConvertedBytes} bytes`,
);
}
this.#convertedBytes += encodedBytes;
this.#messages.push(message);
}
#closeTurn(): void {
if (!this.#turn) return;
// Every turn gets a terminal state, and which one depends on what the
// transcript actually says.
//
// Leaving one out is not the same as preserving "unfinished". Without a
// `turn_state`, `deriveTurnRecords` falls back to `inferLegacyTurnStatus`,
// which answers `completed` for any turn holding an assistant message
// (`session.ts:1250`) and marks it `inferred`. The Ledger then refuses
// that uncorroborated terminal and the repair path persists
// `failed / missing_terminal_event` — an internal-corruption verdict on a
// transcript that was merely cut short. Measured: 13.9% of turns across
// 1130 local transcripts end with no assistant reply or at a `tool_use`
// whose result never arrived.
//
// So an unfinished turn is recorded as what it is: a snapshot that ended
// mid-turn, with an `abortSource` naming the import rather than a user or
// a provider. `end_turn`, interrupt notices and API errors keep their own
// evidence and are unaffected.
if (this.#turn.aborted) {
this.#append({
type: 'turn_state',
id: this.#id('turn-state'),
turnId: this.#turn.turnId,
ts: this.#turn.lastTs,
status: 'aborted',
abortedAt: this.#turn.lastTs,
abortSource: 'claude-code.interrupt',
});
} else if (this.#turn.failed) {
this.#append({
type: 'turn_state',
id: this.#id('turn-state'),
turnId: this.#turn.turnId,
ts: this.#turn.lastTs,
status: 'failed',
errorClass: 'claude_code_api_error',
});
} else if (this.#turn.terminalStop) {
this.#append({
type: 'turn_state',
id: this.#id('turn-state'),
turnId: this.#turn.turnId,
ts: this.#turn.lastTs,
status: 'completed',
});
} else {
this.#append({
type: 'turn_state',
id: this.#id('turn-state'),
turnId: this.#turn.turnId,
ts: this.#turn.lastTs,
status: 'aborted',
abortedAt: this.#turn.lastTs,
abortSource: EXTERNAL_SNAPSHOT_ABORT_SOURCE,
});
}
this.#turn = undefined;
}
}
function asMessageRecord(record: TranscriptRecord): Record<string, unknown> | undefined {
const message = record.message;
return typeof message === 'object' && message !== null && !Array.isArray(message)
? (message as Record<string, unknown>)
: undefined;
}
function contentBlocks(message: Record<string, unknown> | undefined): Record<string, unknown>[] {
const content = message?.content;
if (!Array.isArray(content)) return [];
return content.filter(
(block): block is Record<string, unknown> =>
typeof block === 'object' && block !== null && !Array.isArray(block),
);
}
function toolUseBlocks(message: Record<string, unknown> | undefined): Record<string, unknown>[] {
return contentBlocks(message).filter((block) => block.type === 'tool_use');
}
function toolResultBlocks(message: Record<string, unknown> | undefined): Record<string, unknown>[] {
return contentBlocks(message).filter((block) => block.type === 'tool_result');
}
function thinkingText(message: Record<string, unknown> | undefined): string {
return contentBlocks(message)
.filter((block) => block.type === 'thinking')
.map((block) => stringOf(block.thinking) ?? '')
.filter(Boolean)
.join('\n\n');
}
function rawUserText(message: Record<string, unknown> | undefined): string | undefined {
const content = message?.content;
if (typeof content === 'string') return content;
const texts = contentBlocks(message)
.filter((block) => block.type === 'text')
.map((block) => stringOf(block.text) ?? '');
return texts.join('\n').trim() || undefined;
}
function toolResultText(content: unknown): string {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.map((block) =>
typeof block === 'object' && block !== null && !Array.isArray(block)
? (stringOf((block as Record<string, unknown>).text) ?? '')
: '',
)
.filter(Boolean)
.join('\n');
}
return '';
}
function stringOf(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}