297 lines
9.4 KiB
TypeScript
297 lines
9.4 KiB
TypeScript
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
|
|
}
|
|
}
|