UNPKG

rigjs

Version:

A multi-repos dev tool based on yarn and git.Rigjs is intended to be the simplest way to develop,share and deliver codes between different developers or different projects.

797 lines (738 loc) 32.4 kB
import fs from 'fs'; import path from 'path'; import { StringDecoder } from 'string_decoder'; type JsonObject = Record<string, any>; export interface TranscriptLine { line: number; value?: JsonObject; parseError?: string; } export interface InspectOptions { recent?: number; maxChars?: number; } export interface ReadOptions { from?: number; limit?: number; maxChars?: number; full?: boolean; } export interface IntakeOptions { before?: number; limit?: number; maxChars?: number; full?: boolean; } interface BranchIndex { leafUuid?: string; activeUuids: Set<string>; indexedUuids: number; } interface BranchNode { parentUuid: string | null; line: number; messageId?: string; toolUseIds: string[]; toolResultIds: string[]; sourceToolAssistantUuid?: string; } export function assertTranscriptPath(transcriptPath: string): string { const absolute = path.resolve(transcriptPath); if (!absolute.endsWith('.jsonl')) throw new Error(`Claude transcript must be a .jsonl file: ${absolute}`); let stat: fs.Stats; try { stat = fs.statSync(absolute); } catch { throw new Error(`Claude transcript not found: ${absolute}`); } if (!stat.isFile()) throw new Error(`Claude transcript is not a file: ${absolute}`); return absolute; } export function* iterateTranscript(transcriptPath: string): Generator<TranscriptLine> { const absolute = assertTranscriptPath(transcriptPath); const fd = fs.openSync(absolute, 'r'); const decoder = new StringDecoder('utf8'); const buffer = Buffer.alloc(64 * 1024); let pending = ''; let lineNumber = 0; const parse = (raw: string): TranscriptLine => { lineNumber++; try { const value = JSON.parse(raw); if (!value || typeof value !== 'object' || Array.isArray(value)) { return { line: lineNumber, parseError: 'line is not a JSON object' }; } return { line: lineNumber, value }; } catch (error) { return { line: lineNumber, parseError: error instanceof Error ? error.message : String(error) }; } }; try { let bytes = 0; while ((bytes = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) { pending += decoder.write(buffer.subarray(0, bytes)); let newline = pending.indexOf('\n'); while (newline >= 0) { const raw = pending.slice(0, newline).replace(/\r$/, ''); pending = pending.slice(newline + 1); if (raw.trim()) yield parse(raw); newline = pending.indexOf('\n'); } } pending += decoder.end(); if (pending.trim()) yield parse(pending.replace(/\r$/, '')); } finally { fs.closeSync(fd); } } export function inspectTranscript(transcriptPath: string, options: InspectOptions = {}): JsonObject { const absolute = assertTranscriptPath(transcriptPath); const stat = fs.statSync(absolute); const recentLimit = clampInteger(options.recent, 20, 1, 200); const maxChars = clampInteger(options.maxChars, 4000, 256, 100000); const counts: Record<string, number> = {}; const tools: Record<string, number> = {}; const models = new Set<string>(); const recent: JsonObject[] = []; const compactSummaries: JsonObject[] = []; let malformedLines = 0; let totalLines = 0; let title: string | undefined; let lastPrompt: string | undefined; let firstTimestamp: string | undefined; let lastTimestamp: string | undefined; const session: JsonObject = {}; const branchNodes = new Map<string, BranchNode>(); let leafUuid: string | undefined; let leafLine: number | undefined; for (const item of iterateTranscript(absolute)) { totalLines = item.line; if (!item.value) { malformedLines++; continue; } const value = item.value; captureBranchNode(branchNodes, value, item.line); const type = typeof value.type === 'string' ? value.type : 'unknown'; counts[type] = (counts[type] || 0) + 1; captureSessionFields(session, value); if (typeof value.timestamp === 'string') { if (!firstTimestamp) firstTimestamp = value.timestamp; lastTimestamp = value.timestamp; } if (type === 'custom-title' && typeof value.customTitle === 'string') title = value.customTitle; if (type === 'last-prompt') { if (typeof value.lastPrompt === 'string') lastPrompt = truncate(value.lastPrompt, maxChars); if (typeof value.leafUuid === 'string') { leafUuid = value.leafUuid; leafLine = item.line; } } if (type === 'assistant' && value.message && typeof value.message.model === 'string') models.add(value.message.model); collectToolNames(value, tools); if (value.isCompactSummary === true) { compactSummaries.push({ line: item.line, uuid: value.uuid, timestamp: value.timestamp, summary: normalizeMessageContent(value.message && value.message.content, maxChars) }); } else if (type === 'system' && value.subtype === 'compact_boundary') { compactSummaries.push({ line: item.line, uuid: value.uuid, timestamp: value.timestamp, compactMetadata: sanitize(value.compactMetadata, maxChars) }); } if (type === 'user' || type === 'assistant' || type === 'last-prompt') { const normalized = normalizeTranscriptEntry(value, item.line, maxChars); recent.push(normalized); if (recent.length > recentLimit) recent.shift(); } } const branch = resolveBranchIndex(branchNodes, leafUuid, leafLine); return { schemaVersion: 1, transcriptPath: absolute, bytes: stat.size, modifiedAt: stat.mtime.toISOString(), lines: totalLines, malformedLines, session: { ...session, title, firstTimestamp, lastTimestamp, models: Array.from(models), }, counts, tools, lastPrompt, branch: summarizeBranch(branch), compactSummaries: compactSummaries.map(entry => annotateBranch(entry, branch)), recent: recent.map(entry => annotateBranch(entry, branch)), subagentTranscripts: findSubagentTranscripts(absolute), paging: { command: `rig handoff intake ${JSON.stringify(absolute)}`, note: 'The intake view starts at the newest useful evidence. Pass its nextBeforeLine back as --before only when older evidence is still needed.', }, }; } /** * Build a model-facing recovery view in one streaming pass. Unlike `read`, * this intentionally omits format noise and pages meaningful evidence from * newest to oldest so the stopping point is always consumed first. */ export function intakeTranscript(transcriptPath: string, options: IntakeOptions = {}): JsonObject { const absolute = assertTranscriptPath(transcriptPath); const stat = fs.statSync(absolute); const requestedBefore = clampInteger(options.before, Number.MAX_SAFE_INTEGER, 1, Number.MAX_SAFE_INTEGER); const limit = clampInteger(options.limit, 24, 1, 200); const maxChars = options.full ? Number.MAX_SAFE_INTEGER : clampInteger(options.maxChars, 4000, 256, 1000000); const checkpointMaxChars = options.full ? Number.MAX_SAFE_INTEGER : Math.min(1000000, Math.max(12000, maxChars * 3)); const counts: Record<string, number> = {}; const tools: Record<string, number> = {}; const models = new Set<string>(); const session: JsonObject = {}; const branchNodes = new Map<string, BranchNode>(); const compactSummaries: JsonObject[] = []; const editedFiles = new Map<string, number>(); const pageEntries: JsonObject[] = []; let totalLines = 0; let malformedLines = 0; let omittedThinkingBlocks = 0; let recoverableEntries = 0; let recoverableBeforeCursor = 0; let leafUuid: string | undefined; let leafLine: number | undefined; let title: string | undefined; let lastPrompt: string | undefined; let firstTimestamp: string | undefined; let lastTimestamp: string | undefined; for (const item of iterateTranscript(absolute)) { totalLines = item.line; if (!item.value) { malformedLines++; continue; } const value = item.value; const type = typeof value.type === 'string' ? value.type : 'unknown'; counts[type] = (counts[type] || 0) + 1; captureBranchNode(branchNodes, value, item.line); captureSessionFields(session, value); collectToolNames(value, tools); captureEditedFiles(editedFiles, value, item.line); omittedThinkingBlocks += countThinkingBlocks(value.message && value.message.content); if (typeof value.timestamp === 'string') { if (!firstTimestamp) firstTimestamp = value.timestamp; lastTimestamp = value.timestamp; } if (type === 'custom-title' && typeof value.customTitle === 'string') title = value.customTitle; if (type === 'last-prompt') { if (typeof value.lastPrompt === 'string') lastPrompt = truncateMiddle(value.lastPrompt, checkpointMaxChars); if (typeof value.leafUuid === 'string') { leafUuid = value.leafUuid; leafLine = item.line; } } if (type === 'assistant' && value.message && typeof value.message.model === 'string') models.add(value.message.model); if (value.isCompactSummary === true) { compactSummaries.push(removeUndefined({ line: item.line, type: 'compact-summary', timestamp: value.timestamp, uuid: value.uuid, summary: normalizeRecoveryContent(value.message && value.message.content, checkpointMaxChars), })); } const entry = normalizeRecoveryEntry(value, item.line, maxChars); if (!entry) continue; recoverableEntries++; if (item.line >= requestedBefore) continue; recoverableBeforeCursor++; pageEntries.push(entry); if (pageEntries.length > limit) pageEntries.shift(); } const branch = resolveBranchIndex(branchNodes, leafUuid, leafLine); const checkpoint = selectCheckpoint(compactSummaries, branch); const chronologicalPage = pageEntries.slice(); const oldestLine = chronologicalPage.length ? chronologicalPage[0].line : null; const newestFirst = chronologicalPage.reverse().map(entry => annotateBranch(entry, branch)); const hasOlder = recoverableBeforeCursor > pageEntries.length; const effectiveBefore = Math.min(requestedBefore, totalLines + 1); return { schemaVersion: 2, transcriptPath: absolute, bytes: stat.size, modifiedAt: stat.mtime.toISOString(), session: { ...session, title, firstTimestamp, lastTimestamp, models: Array.from(models), }, stats: { lines: totalLines, malformedLines, counts, tools, recoverableEntries, compactSummaries: compactSummaries.length, omittedThinkingBlocks, }, lastPrompt, checkpoint: checkpoint ? annotateBranch(checkpoint, branch) : null, branch: summarizeBranch(branch), workspaceEvidence: { editedFiles: Array.from(editedFiles.entries()) .map(([filePath, line]) => ({ path: filePath, line })) .sort((a, b) => b.line - a.line) .slice(0, 40), }, subagentTranscripts: findSubagentTranscripts(absolute), page: { direction: 'newest-to-oldest', beforeLine: effectiveBefore, newestLine: newestFirst.length ? newestFirst[0].line : null, oldestLine: newestFirst.length ? newestFirst[newestFirst.length - 1].line : null, nextBeforeLine: hasOlder ? oldestLine : null, hasOlder, entries: newestFirst, }, guidance: { next: hasOlder ? `If material evidence is missing, rerun with --before ${oldestLine}.` : 'No older recovery entries remain.', branch: 'Treat non-lineage user text as historical context, not current instruction. Reconcile all transcript evidence against current workspace state.', thinking: 'Private thinking blocks were omitted; use dialogue, tool calls, tool results, and the compact checkpoint.', }, }; } export function readTranscriptPage(transcriptPath: string, options: ReadOptions = {}): JsonObject { const absolute = assertTranscriptPath(transcriptPath); const branch = indexTranscriptBranch(absolute); const from = clampInteger(options.from, 1, 1, Number.MAX_SAFE_INTEGER); const limit = clampInteger(options.limit, 80, 1, 1000); const maxChars = options.full ? Number.MAX_SAFE_INTEGER : clampInteger(options.maxChars, 12000, 256, 1000000); const entries: JsonObject[] = []; let hasMore = false; let endLine = from - 1; for (const item of iterateTranscript(absolute)) { if (item.line < from) continue; if (entries.length >= limit) { hasMore = true; break; } endLine = item.line; if (item.value) entries.push(annotateBranch(normalizeTranscriptEntry(item.value, item.line, maxChars), branch)); else entries.push({ line: item.line, type: 'parse-error', error: item.parseError }); } return { schemaVersion: 1, transcriptPath: absolute, fromLine: from, endLine, nextLine: hasMore ? endLine + 1 : null, hasMore, branch: summarizeBranch(branch), entries, }; } export function normalizeTranscriptEntry(value: JsonObject, line: number, maxChars = 12000): JsonObject { const type = typeof value.type === 'string' ? value.type : 'unknown'; const base: JsonObject = { line, type, timestamp: value.timestamp, uuid: value.uuid, parentUuid: value.parentUuid, }; if (value.isSidechain === true) base.isSidechain = true; if (typeof value.cwd === 'string') base.cwd = value.cwd; if (typeof value.gitBranch === 'string') base.gitBranch = value.gitBranch; if (type === 'user' || type === 'assistant') { base.role = value.message && value.message.role || type; base.content = normalizeMessageContent(value.message && value.message.content, maxChars); if (type === 'assistant' && value.message) { base.model = value.message.model; base.stopReason = value.message.stop_reason; base.usage = sanitize(value.message.usage, maxChars); } if (value.toolUseResult !== undefined) base.toolUseResult = sanitize(value.toolUseResult, maxChars); if (typeof value.sourceToolAssistantUUID === 'string') base.sourceToolAssistantUUID = value.sourceToolAssistantUUID; if (value.isCompactSummary === true) base.isCompactSummary = true; return removeUndefined(base); } switch (type) { case 'system': return removeUndefined({ ...base, subtype: value.subtype, level: value.level, content: sanitize(value.content, maxChars), compactMetadata: sanitize(value.compactMetadata, maxChars), error: value.error, stopReason: value.stopReason }); case 'custom-title': return removeUndefined({ ...base, title: value.customTitle }); case 'mode': return removeUndefined({ ...base, mode: value.mode }); case 'last-prompt': return removeUndefined({ ...base, lastPrompt: truncate(value.lastPrompt, maxChars), leafUuid: value.leafUuid }); case 'queue-operation': return removeUndefined({ ...base, operation: value.operation, content: sanitize(value.content, maxChars) }); case 'attachment': return removeUndefined({ ...base, attachment: sanitizeAttachment(value.attachment, maxChars) }); default: return removeUndefined({ ...base, data: sanitize(omitEnvelope(value), maxChars) }); } } function normalizeRecoveryEntry(value: JsonObject, line: number, maxChars: number): JsonObject | null { const type = typeof value.type === 'string' ? value.type : 'unknown'; if ((type === 'user' || type === 'assistant') && value.isCompactSummary !== true) { const content = normalizeRecoveryContent(value.message && value.message.content, maxChars); if (!hasRecoveryContent(content) || isRecoveryControlContent(content)) return null; const entry: JsonObject = { line, type, role: value.message && value.message.role || type, timestamp: value.timestamp, uuid: value.uuid, parentUuid: value.parentUuid, content, }; if (value.isSidechain === true) entry.isSidechain = true; if (value.toolUseResult !== undefined) entry.toolUseResult = sanitizeForRecovery(value.toolUseResult, maxChars); if (typeof value.sourceToolAssistantUUID === 'string') entry.sourceToolAssistantUUID = value.sourceToolAssistantUUID; return removeUndefined(entry); } if (type === 'system' && (value.level === 'error' || value.error !== undefined)) { return removeUndefined({ line, type, timestamp: value.timestamp, uuid: value.uuid, parentUuid: value.parentUuid, subtype: value.subtype, level: value.level, content: sanitizeForRecovery(value.content, maxChars), error: sanitizeForRecovery(value.error, maxChars), }); } return null; } function normalizeRecoveryContent(content: unknown, maxChars: number): unknown { if (typeof content === 'string') return content.trim() ? truncateMiddle(content, maxChars) : null; if (!Array.isArray(content)) return sanitizeForRecovery(content, maxChars); return content.map((block: any) => { if (!block || typeof block !== 'object') return sanitizeForRecovery(block, maxChars); switch (block.type) { case 'thinking': return null; case 'text': return typeof block.text === 'string' && block.text.trim() ? { type: 'text', text: truncateMiddle(block.text, maxChars) } : null; case 'tool_use': return removeUndefined({ type: 'tool_use', id: block.id, name: block.name, input: sanitizeForRecovery(block.input, maxChars) }); case 'tool_result': return removeUndefined({ type: 'tool_result', toolUseId: block.tool_use_id, isError: block.is_error, content: normalizeRecoveryContent(block.content, maxChars) }); case 'image': return { type: 'image', source: sanitizeImageSource(block.source) }; default: return sanitizeForRecovery(block, maxChars); } }).filter(hasRecoveryContent); } function normalizeMessageContent(content: unknown, maxChars: number): unknown { if (typeof content === 'string') return truncate(content, maxChars); if (!Array.isArray(content)) return sanitize(content, maxChars); return content.map((block: any) => { if (!block || typeof block !== 'object') return sanitize(block, maxChars); switch (block.type) { case 'text': return { type: 'text', text: truncate(block.text, maxChars) }; case 'thinking': return { type: 'thinking', omitted: true, note: 'Private model reasoning is intentionally not transferred.' }; case 'tool_use': return { type: 'tool_use', id: block.id, name: block.name, input: sanitize(block.input, maxChars) }; case 'tool_result': return { type: 'tool_result', toolUseId: block.tool_use_id, isError: block.is_error, content: normalizeMessageContent(block.content, maxChars) }; case 'image': return { type: 'image', source: sanitizeImageSource(block.source) }; default: return sanitize(block, maxChars); } }); } function sanitizeAttachment(attachment: unknown, maxChars: number): unknown { if (!attachment || typeof attachment !== 'object' || Array.isArray(attachment)) return sanitize(attachment, maxChars); const value = attachment as JsonObject; return removeUndefined({ type: value.type, filename: value.filename, displayPath: value.displayPath, hookEvent: value.hookEvent, hookName: value.hookName, toolUseID: value.toolUseID, command: truncate(value.command, maxChars), exitCode: value.exitCode, durationMs: value.durationMs, content: sanitize(value.content, maxChars), stdout: truncate(value.stdout, maxChars), stderr: truncate(value.stderr, maxChars), }); } function sanitizeImageSource(source: unknown): unknown { if (!source || typeof source !== 'object' || Array.isArray(source)) return { omitted: true }; const value = source as JsonObject; return removeUndefined({ type: value.type, mediaType: value.media_type, path: value.path, url: value.url, data: value.data ? `[base64 omitted: ${String(value.data).length} chars]` : undefined }); } function sanitize(value: unknown, maxChars: number, depth = 0): unknown { if (typeof value === 'string') return truncate(value, maxChars); if (value == null || typeof value === 'number' || typeof value === 'boolean') return value; if (depth >= 12) return '[nested value omitted]'; if (Array.isArray(value)) return value.map(item => sanitize(item, maxChars, depth + 1)); if (typeof value === 'object') { const result: JsonObject = {}; for (const [key, item] of Object.entries(value as JsonObject)) { if (key === 'signature') continue; if (key === 'data' && typeof item === 'string' && item.length > maxChars) { result[key] = `[large data omitted: ${item.length} chars]`; } else { result[key] = sanitize(item, maxChars, depth + 1); } } return result; } return String(value); } function sanitizeForRecovery(value: unknown, maxChars: number, depth = 0): unknown { if (typeof value === 'string') return truncateMiddle(value, maxChars); if (value == null || typeof value === 'number' || typeof value === 'boolean') return value; if (depth >= 12) return '[nested value omitted]'; if (Array.isArray(value)) return value.map(item => sanitizeForRecovery(item, maxChars, depth + 1)); if (typeof value === 'object') { const result: JsonObject = {}; for (const [key, item] of Object.entries(value as JsonObject)) { if (key === 'signature') continue; result[key] = sanitizeForRecovery(item, maxChars, depth + 1); } return result; } return String(value); } function truncate(value: string, maxChars: number): string; function truncate(value: unknown, maxChars: number): unknown; function truncate(value: unknown, maxChars: number): unknown { if (typeof value !== 'string') return value; if (value.length <= maxChars) return value; return value.slice(0, maxChars) + `\n… [truncated ${value.length - maxChars} chars; rerun this page with --full]`; } function truncateMiddle(value: string, maxChars: number): string { if (value.length <= maxChars) return value; const marker = `\n… [truncated ${value.length - maxChars} chars; rerun with --full] …\n`; const available = Math.max(2, maxChars - marker.length); const head = Math.max(1, Math.floor(available * 0.45)); const tail = Math.max(1, available - head); return value.slice(0, head) + marker + value.slice(value.length - tail); } function hasRecoveryContent(value: unknown): boolean { if (typeof value === 'string') return value.trim().length > 0; if (Array.isArray(value)) return value.length > 0; if (value && typeof value === 'object') return Object.keys(value as JsonObject).length > 0; return value !== null && value !== undefined; } function isRecoveryControlContent(value: unknown): boolean { let text: string | null = null; if (typeof value === 'string') text = value.trim(); if (Array.isArray(value) && value.length === 1 && value[0] && value[0].type === 'text' && typeof value[0].text === 'string') { text = value[0].text.trim(); } if (!text) return false; return text === 'Continue from where you left off.' || text === 'No response requested.' || text.startsWith('<local-command-caveat>') || text.startsWith('<command-name>/compact</command-name>') || text.startsWith('<local-command-stdout>Compacted'); } function countThinkingBlocks(content: unknown): number { if (!Array.isArray(content)) return 0; let count = 0; for (const block of content) if (block && block.type === 'thinking') count++; return count; } function collectToolNames(value: JsonObject, tools: Record<string, number>): void { const content = value.message && value.message.content; if (!Array.isArray(content)) return; for (const block of content) { if (block && block.type === 'tool_use' && typeof block.name === 'string') { tools[block.name] = (tools[block.name] || 0) + 1; } } } function captureEditedFiles(files: Map<string, number>, value: JsonObject, line: number): void { const attachment = value.attachment; if (attachment && attachment.type === 'edited_text_file' && typeof attachment.filename === 'string') { files.set(attachment.filename, line); } const content = value.message && value.message.content; if (Array.isArray(content)) { for (const block of content) { if (!block || block.type !== 'tool_use' || !/^(Edit|Write|MultiEdit|NotebookEdit)$/i.test(String(block.name || ''))) continue; const filePath = block.input && (block.input.file_path || block.input.path || block.input.notebook_path); if (typeof filePath === 'string') files.set(filePath, line); } } const result = value.toolUseResult; if (result && typeof result === 'object' && !Array.isArray(result)) { const filePath = result.filePath || result.file_path; const looksEdited = result.oldString !== undefined || result.newString !== undefined || result.structuredPatch !== undefined; if (looksEdited && typeof filePath === 'string') files.set(filePath, line); } } function captureSessionFields(session: JsonObject, value: JsonObject): void { const fields = [ ['sessionId', value.sessionId], ['cwd', value.cwd], ['gitBranch', value.gitBranch], ['claudeVersion', value.version], ['entrypoint', value.entrypoint], ]; for (const [key, item] of fields) if (typeof item === 'string' && item) session[key] = item; } function indexTranscriptBranch(transcriptPath: string): BranchIndex { const nodes = new Map<string, BranchNode>(); let leafUuid: string | undefined; let leafLine: number | undefined; for (const item of iterateTranscript(transcriptPath)) { if (!item.value) continue; captureBranchNode(nodes, item.value, item.line); if (item.value.type === 'last-prompt' && typeof item.value.leafUuid === 'string') { leafUuid = item.value.leafUuid; leafLine = item.line; } } return resolveBranchIndex(nodes, leafUuid, leafLine); } function captureBranchNode(nodes: Map<string, BranchNode>, value: JsonObject, line: number): void { if (typeof value.uuid !== 'string') return; const content = value.message && value.message.content; const toolUseIds: string[] = []; const toolResultIds: string[] = []; if (Array.isArray(content)) { for (const block of content) { if (block && block.type === 'tool_use' && typeof block.id === 'string') toolUseIds.push(block.id); if (block && block.type === 'tool_result' && typeof block.tool_use_id === 'string') toolResultIds.push(block.tool_use_id); } } nodes.set(value.uuid, { parentUuid: typeof value.parentUuid === 'string' ? value.parentUuid : null, line, messageId: value.message && typeof value.message.id === 'string' ? value.message.id : undefined, toolUseIds, toolResultIds, sourceToolAssistantUuid: typeof value.sourceToolAssistantUUID === 'string' ? value.sourceToolAssistantUUID : undefined, }); } function resolveBranchIndex(nodes: Map<string, BranchNode>, leafUuid?: string, leafLine?: number): BranchIndex { const activeUuids = new Set<string>(); if (leafUuid && leafLine != null) { let extendedLeaf = leafUuid; for (const [uuid, node] of nodes) { if (node.line > leafLine && node.parentUuid === extendedLeaf) extendedLeaf = uuid; } leafUuid = extendedLeaf; } if (!leafUuid || !nodes.has(leafUuid)) return { leafUuid, activeUuids, indexedUuids: nodes.size }; let current: string | undefined = leafUuid; while (current && !activeUuids.has(current)) { activeUuids.add(current); current = nodes.get(current)?.parentUuid || undefined; } // Claude can serialize parallel tool calls as sibling result rows. They are // not all ancestors of leafUuid, but remain active evidence from the same // assistant message/tool IDs. Expand the lineage without treating arbitrary // descendants (which may be rewound user branches) as active. let changed = true; while (changed) { changed = false; const activeMessageIds = new Set<string>(); const activeToolUseIds = new Set<string>(); for (const uuid of activeUuids) { const node = nodes.get(uuid); if (!node) continue; if (node.messageId) activeMessageIds.add(node.messageId); for (const id of node.toolUseIds) activeToolUseIds.add(id); } for (const [uuid, node] of nodes) { if (activeUuids.has(uuid)) continue; const sameAssistantMessage = !!node.messageId && activeMessageIds.has(node.messageId); const linkedAssistant = !!node.sourceToolAssistantUuid && activeUuids.has(node.sourceToolAssistantUuid); const linkedToolResult = node.toolResultIds.some(id => activeToolUseIds.has(id)); if (sameAssistantMessage || linkedAssistant || linkedToolResult) { activeUuids.add(uuid); changed = true; } } } return { leafUuid, activeUuids, indexedUuids: nodes.size }; } function selectCheckpoint(summaries: JsonObject[], branch: BranchIndex): JsonObject | null { if (branch.leafUuid && branch.activeUuids.has(branch.leafUuid)) { for (let index = summaries.length - 1; index >= 0; index--) { const uuid = summaries[index].uuid; if (typeof uuid === 'string' && branch.activeUuids.has(uuid)) return summaries[index]; } } return summaries.length ? summaries[summaries.length - 1] : null; } function summarizeBranch(branch: BranchIndex): JsonObject { const available = !!branch.leafUuid && branch.activeUuids.has(branch.leafUuid); return { available, leafUuid: branch.leafUuid, indexedUuids: branch.indexedUuids, activeUuids: available ? branch.activeUuids.size : 0, nonLineageUuids: available ? Math.max(0, branch.indexedUuids - branch.activeUuids.size) : 0, }; } function annotateBranch(entry: JsonObject, branch: BranchIndex): JsonObject { if (!branch.leafUuid || !branch.activeUuids.has(branch.leafUuid) || typeof entry.uuid !== 'string') return entry; return { ...entry, branch: branch.activeUuids.has(entry.uuid) ? 'active' : 'non-lineage' }; } function findSubagentTranscripts(transcriptPath: string): string[] { const sessionDir = transcriptPath.slice(0, -'.jsonl'.length); const subagents = path.join(sessionDir, 'subagents'); try { return fs.readdirSync(subagents) .filter(name => name.endsWith('.jsonl')) .map(name => path.join(subagents, name)) .sort(); } catch { return []; } } export function findLatestTranscript(projectsDir: string, cwd?: string): string | null { const files: { path: string; mtime: number }[] = []; walk(projectsDir, files, 0); files.sort((a, b) => b.mtime - a.mtime); if (!cwd) return files[0] ? files[0].path : null; const wanted = path.resolve(cwd); for (const candidate of files) { if (transcriptMentionsCwd(candidate.path, wanted)) return candidate.path; } return null; } function walk(dir: string, files: { path: string; mtime: number }[], depth: number): void { if (depth > 4) return; let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (entry.name !== 'subagents') walk(full, files, depth + 1); } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { try { files.push({ path: full, mtime: fs.statSync(full).mtimeMs }); } catch { /* skip */ } } } } function transcriptMentionsCwd(transcriptPath: string, cwd: string): boolean { for (const item of iterateTranscript(transcriptPath)) { if (item.value && typeof item.value.cwd === 'string' && path.resolve(item.value.cwd) === cwd) return true; } return false; } function omitEnvelope(value: JsonObject): JsonObject { const result = { ...value }; for (const key of ['type', 'timestamp', 'uuid', 'parentUuid', 'cwd', 'gitBranch', 'sessionId', 'version', 'entrypoint', 'isSidechain']) delete result[key]; return result; } function removeUndefined(value: JsonObject): JsonObject { for (const key of Object.keys(value)) if (value[key] === undefined) delete value[key]; return value; } function clampInteger(value: number | undefined, fallback: number, min: number, max: number): number { if (value == null) return fallback; if (!Number.isInteger(value) || value < min || value > max) throw new Error(`expected an integer between ${min} and ${max}, got ${value}`); return value; }