| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296 |
- import { randomUUID, type UUID } from 'crypto'
- import { mkdir, readFile, writeFile } from 'fs/promises'
- import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js'
- import type { LocalJSXCommandContext } from '../../commands.js'
- import { logEvent } from '../../services/analytics/index.js'
- import type { LocalJSXCommandOnDone } from '../../types/command.js'
- import type {
- ContentReplacementEntry,
- Entry,
- LogOption,
- SerializedMessage,
- TranscriptMessage,
- } from '../../types/logs.js'
- import { parseJSONL } from '../../utils/json.js'
- import {
- getProjectDir,
- getTranscriptPath,
- getTranscriptPathForSession,
- isTranscriptMessage,
- saveCustomTitle,
- searchSessionsByCustomTitle,
- } from '../../utils/sessionStorage.js'
- import { jsonStringify } from '../../utils/slowOperations.js'
- import { escapeRegExp } from '../../utils/stringUtils.js'
- type TranscriptEntry = TranscriptMessage & {
- forkedFrom?: {
- sessionId: string
- messageUuid: UUID
- }
- }
- /**
- * Derive a single-line title base from the first user message.
- * Collapses whitespace — multiline first messages (pasted stacks, code)
- * otherwise flow into the saved title and break the resume hint.
- */
- export function deriveFirstPrompt(
- firstUserMessage: Extract<SerializedMessage, { type: 'user' }> | undefined,
- ): string {
- const content = firstUserMessage?.message?.content
- if (!content) return 'Branched conversation'
- const raw =
- typeof content === 'string'
- ? content
- : content.find(
- (block): block is { type: 'text'; text: string } =>
- block.type === 'text',
- )?.text
- if (!raw) return 'Branched conversation'
- return (
- raw.replace(/\s+/g, ' ').trim().slice(0, 100) || 'Branched conversation'
- )
- }
- /**
- * Creates a fork of the current conversation by copying from the transcript file.
- * Preserves all original metadata (timestamps, gitBranch, etc.) while updating
- * sessionId and adding forkedFrom traceability.
- */
- async function createFork(customTitle?: string): Promise<{
- sessionId: UUID
- title: string | undefined
- forkPath: string
- serializedMessages: SerializedMessage[]
- contentReplacementRecords: ContentReplacementEntry['replacements']
- }> {
- const forkSessionId = randomUUID() as UUID
- const originalSessionId = getSessionId()
- const projectDir = getProjectDir(getOriginalCwd())
- const forkSessionPath = getTranscriptPathForSession(forkSessionId)
- const currentTranscriptPath = getTranscriptPath()
- // Ensure project directory exists
- await mkdir(projectDir, { recursive: true, mode: 0o700 })
- // Read current transcript file
- let transcriptContent: Buffer
- try {
- transcriptContent = await readFile(currentTranscriptPath)
- } catch {
- throw new Error('No conversation to branch')
- }
- if (transcriptContent.length === 0) {
- throw new Error('No conversation to branch')
- }
- // Parse all transcript entries (messages + metadata entries like content-replacement)
- const entries = parseJSONL<Entry>(transcriptContent)
- // Filter to only main conversation messages (exclude sidechains and non-message entries)
- const mainConversationEntries = entries.filter(
- (entry): entry is TranscriptMessage =>
- isTranscriptMessage(entry) && !entry.isSidechain,
- )
- // Content-replacement entries for the original session. These record which
- // tool_result blocks were replaced with previews by the per-message budget.
- // Without them in the fork JSONL, `claude -r {forkId}` reconstructs state
- // with an empty replacements Map → previously-replaced results are classified
- // as FROZEN and sent as full content (prompt cache miss + permanent overage).
- // sessionId must be rewritten since loadTranscriptFile keys lookup by the
- // session's messages' sessionId.
- const contentReplacementRecords = entries
- .filter(
- (entry): entry is ContentReplacementEntry =>
- entry.type === 'content-replacement' &&
- entry.sessionId === originalSessionId,
- )
- .flatMap(entry => entry.replacements)
- if (mainConversationEntries.length === 0) {
- throw new Error('No messages to branch')
- }
- // Build forked entries with new sessionId and preserved metadata
- let parentUuid: UUID | null = null
- const lines: string[] = []
- const serializedMessages: SerializedMessage[] = []
- for (const entry of mainConversationEntries) {
- // Create forked transcript entry preserving all original metadata
- const forkedEntry: TranscriptEntry = {
- ...entry,
- sessionId: forkSessionId,
- parentUuid,
- isSidechain: false,
- forkedFrom: {
- sessionId: originalSessionId,
- messageUuid: entry.uuid,
- },
- }
- // Build serialized message for LogOption
- const serialized: SerializedMessage = {
- ...entry,
- sessionId: forkSessionId,
- }
- serializedMessages.push(serialized)
- lines.push(jsonStringify(forkedEntry))
- if (entry.type !== 'progress') {
- parentUuid = entry.uuid
- }
- }
- // Append content-replacement entry (if any) with the fork's sessionId.
- // Written as a SINGLE entry (same shape as insertContentReplacement) so
- // loadTranscriptFile's content-replacement branch picks it up.
- if (contentReplacementRecords.length > 0) {
- const forkedReplacementEntry: ContentReplacementEntry = {
- type: 'content-replacement',
- sessionId: forkSessionId,
- replacements: contentReplacementRecords,
- }
- lines.push(jsonStringify(forkedReplacementEntry))
- }
- // Write the fork session file
- await writeFile(forkSessionPath, lines.join('\n') + '\n', {
- encoding: 'utf8',
- mode: 0o600,
- })
- return {
- sessionId: forkSessionId,
- title: customTitle,
- forkPath: forkSessionPath,
- serializedMessages,
- contentReplacementRecords,
- }
- }
- /**
- * Generates a unique fork name by checking for collisions with existing session names.
- * If "baseName (Branch)" already exists, tries "baseName (Branch 2)", "baseName (Branch 3)", etc.
- */
- async function getUniqueForkName(baseName: string): Promise<string> {
- const candidateName = `${baseName} (Branch)`
- // Check if this exact name already exists
- const existingWithExactName = await searchSessionsByCustomTitle(
- candidateName,
- { exact: true },
- )
- if (existingWithExactName.length === 0) {
- return candidateName
- }
- // Name collision - find a unique numbered suffix
- // Search for all sessions that start with the base pattern
- const existingForks = await searchSessionsByCustomTitle(`${baseName} (Branch`)
- // Extract existing fork numbers to find the next available
- const usedNumbers = new Set<number>([1]) // Consider " (Branch)" as number 1
- const forkNumberPattern = new RegExp(
- `^${escapeRegExp(baseName)} \\(Branch(?: (\\d+))?\\)$`,
- )
- for (const session of existingForks) {
- const match = session.customTitle?.match(forkNumberPattern)
- if (match) {
- if (match[1]) {
- usedNumbers.add(parseInt(match[1], 10))
- } else {
- usedNumbers.add(1) // " (Branch)" without number is treated as 1
- }
- }
- }
- // Find the next available number
- let nextNumber = 2
- while (usedNumbers.has(nextNumber)) {
- nextNumber++
- }
- return `${baseName} (Branch ${nextNumber})`
- }
- export async function call(
- onDone: LocalJSXCommandOnDone,
- context: LocalJSXCommandContext,
- args: string,
- ): Promise<React.ReactNode> {
- const customTitle = args?.trim() || undefined
- const originalSessionId = getSessionId()
- try {
- const {
- sessionId,
- title,
- forkPath,
- serializedMessages,
- contentReplacementRecords,
- } = await createFork(customTitle)
- // Build LogOption for resume
- const now = new Date()
- const firstPrompt = deriveFirstPrompt(
- serializedMessages.find(m => m.type === 'user'),
- )
- // Save custom title - use provided title or firstPrompt as default
- // This ensures /status and /resume show the same session name
- // Always add " (Branch)" suffix to make it clear this is a branched session
- // Handle collisions by adding a number suffix (e.g., " (Branch 2)", " (Branch 3)")
- const baseName = title ?? firstPrompt
- const effectiveTitle = await getUniqueForkName(baseName)
- await saveCustomTitle(sessionId, effectiveTitle, forkPath)
- logEvent('tengu_conversation_forked', {
- message_count: serializedMessages.length,
- has_custom_title: !!title,
- })
- const forkLog: LogOption = {
- date: now.toISOString().split('T')[0]!,
- messages: serializedMessages,
- fullPath: forkPath,
- value: now.getTime(),
- created: now,
- modified: now,
- firstPrompt,
- messageCount: serializedMessages.length,
- isSidechain: false,
- sessionId,
- customTitle: effectiveTitle,
- contentReplacements: contentReplacementRecords,
- }
- // Resume into the fork
- const titleInfo = title ? ` "${title}"` : ''
- const resumeHint = `\nTo resume the original: claude -r ${originalSessionId}`
- const successMessage = `Branched conversation${titleInfo}. You are now in the branch.${resumeHint}`
- if (context.resume) {
- await context.resume(sessionId, forkLog, 'fork')
- onDone(successMessage, { display: 'system' })
- } else {
- // Fallback if resume not available
- onDone(
- `Branched conversation${titleInfo}. Resume with: /resume ${sessionId}`,
- )
- }
- return null
- } catch (error) {
- const message =
- error instanceof Error ? error.message : 'Unknown error occurred'
- onDone(`Failed to branch conversation: ${message}`)
- return null
- }
- }
|