branch.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. import { randomUUID, type UUID } from 'crypto'
  2. import { mkdir, readFile, writeFile } from 'fs/promises'
  3. import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js'
  4. import type { LocalJSXCommandContext } from '../../commands.js'
  5. import { logEvent } from '../../services/analytics/index.js'
  6. import type { LocalJSXCommandOnDone } from '../../types/command.js'
  7. import type {
  8. ContentReplacementEntry,
  9. Entry,
  10. LogOption,
  11. SerializedMessage,
  12. TranscriptMessage,
  13. } from '../../types/logs.js'
  14. import { parseJSONL } from '../../utils/json.js'
  15. import {
  16. getProjectDir,
  17. getTranscriptPath,
  18. getTranscriptPathForSession,
  19. isTranscriptMessage,
  20. saveCustomTitle,
  21. searchSessionsByCustomTitle,
  22. } from '../../utils/sessionStorage.js'
  23. import { jsonStringify } from '../../utils/slowOperations.js'
  24. import { escapeRegExp } from '../../utils/stringUtils.js'
  25. type TranscriptEntry = TranscriptMessage & {
  26. forkedFrom?: {
  27. sessionId: string
  28. messageUuid: UUID
  29. }
  30. }
  31. /**
  32. * Derive a single-line title base from the first user message.
  33. * Collapses whitespace — multiline first messages (pasted stacks, code)
  34. * otherwise flow into the saved title and break the resume hint.
  35. */
  36. export function deriveFirstPrompt(
  37. firstUserMessage: Extract<SerializedMessage, { type: 'user' }> | undefined,
  38. ): string {
  39. const content = firstUserMessage?.message?.content
  40. if (!content) return 'Branched conversation'
  41. const raw =
  42. typeof content === 'string'
  43. ? content
  44. : content.find(
  45. (block): block is { type: 'text'; text: string } =>
  46. block.type === 'text',
  47. )?.text
  48. if (!raw) return 'Branched conversation'
  49. return (
  50. raw.replace(/\s+/g, ' ').trim().slice(0, 100) || 'Branched conversation'
  51. )
  52. }
  53. /**
  54. * Creates a fork of the current conversation by copying from the transcript file.
  55. * Preserves all original metadata (timestamps, gitBranch, etc.) while updating
  56. * sessionId and adding forkedFrom traceability.
  57. */
  58. async function createFork(customTitle?: string): Promise<{
  59. sessionId: UUID
  60. title: string | undefined
  61. forkPath: string
  62. serializedMessages: SerializedMessage[]
  63. contentReplacementRecords: ContentReplacementEntry['replacements']
  64. }> {
  65. const forkSessionId = randomUUID() as UUID
  66. const originalSessionId = getSessionId()
  67. const projectDir = getProjectDir(getOriginalCwd())
  68. const forkSessionPath = getTranscriptPathForSession(forkSessionId)
  69. const currentTranscriptPath = getTranscriptPath()
  70. // Ensure project directory exists
  71. await mkdir(projectDir, { recursive: true, mode: 0o700 })
  72. // Read current transcript file
  73. let transcriptContent: Buffer
  74. try {
  75. transcriptContent = await readFile(currentTranscriptPath)
  76. } catch {
  77. throw new Error('No conversation to branch')
  78. }
  79. if (transcriptContent.length === 0) {
  80. throw new Error('No conversation to branch')
  81. }
  82. // Parse all transcript entries (messages + metadata entries like content-replacement)
  83. const entries = parseJSONL<Entry>(transcriptContent)
  84. // Filter to only main conversation messages (exclude sidechains and non-message entries)
  85. const mainConversationEntries = entries.filter(
  86. (entry): entry is TranscriptMessage =>
  87. isTranscriptMessage(entry) && !entry.isSidechain,
  88. )
  89. // Content-replacement entries for the original session. These record which
  90. // tool_result blocks were replaced with previews by the per-message budget.
  91. // Without them in the fork JSONL, `claude -r {forkId}` reconstructs state
  92. // with an empty replacements Map → previously-replaced results are classified
  93. // as FROZEN and sent as full content (prompt cache miss + permanent overage).
  94. // sessionId must be rewritten since loadTranscriptFile keys lookup by the
  95. // session's messages' sessionId.
  96. const contentReplacementRecords = entries
  97. .filter(
  98. (entry): entry is ContentReplacementEntry =>
  99. entry.type === 'content-replacement' &&
  100. entry.sessionId === originalSessionId,
  101. )
  102. .flatMap(entry => entry.replacements)
  103. if (mainConversationEntries.length === 0) {
  104. throw new Error('No messages to branch')
  105. }
  106. // Build forked entries with new sessionId and preserved metadata
  107. let parentUuid: UUID | null = null
  108. const lines: string[] = []
  109. const serializedMessages: SerializedMessage[] = []
  110. for (const entry of mainConversationEntries) {
  111. // Create forked transcript entry preserving all original metadata
  112. const forkedEntry: TranscriptEntry = {
  113. ...entry,
  114. sessionId: forkSessionId,
  115. parentUuid,
  116. isSidechain: false,
  117. forkedFrom: {
  118. sessionId: originalSessionId,
  119. messageUuid: entry.uuid,
  120. },
  121. }
  122. // Build serialized message for LogOption
  123. const serialized: SerializedMessage = {
  124. ...entry,
  125. sessionId: forkSessionId,
  126. }
  127. serializedMessages.push(serialized)
  128. lines.push(jsonStringify(forkedEntry))
  129. if (entry.type !== 'progress') {
  130. parentUuid = entry.uuid
  131. }
  132. }
  133. // Append content-replacement entry (if any) with the fork's sessionId.
  134. // Written as a SINGLE entry (same shape as insertContentReplacement) so
  135. // loadTranscriptFile's content-replacement branch picks it up.
  136. if (contentReplacementRecords.length > 0) {
  137. const forkedReplacementEntry: ContentReplacementEntry = {
  138. type: 'content-replacement',
  139. sessionId: forkSessionId,
  140. replacements: contentReplacementRecords,
  141. }
  142. lines.push(jsonStringify(forkedReplacementEntry))
  143. }
  144. // Write the fork session file
  145. await writeFile(forkSessionPath, lines.join('\n') + '\n', {
  146. encoding: 'utf8',
  147. mode: 0o600,
  148. })
  149. return {
  150. sessionId: forkSessionId,
  151. title: customTitle,
  152. forkPath: forkSessionPath,
  153. serializedMessages,
  154. contentReplacementRecords,
  155. }
  156. }
  157. /**
  158. * Generates a unique fork name by checking for collisions with existing session names.
  159. * If "baseName (Branch)" already exists, tries "baseName (Branch 2)", "baseName (Branch 3)", etc.
  160. */
  161. async function getUniqueForkName(baseName: string): Promise<string> {
  162. const candidateName = `${baseName} (Branch)`
  163. // Check if this exact name already exists
  164. const existingWithExactName = await searchSessionsByCustomTitle(
  165. candidateName,
  166. { exact: true },
  167. )
  168. if (existingWithExactName.length === 0) {
  169. return candidateName
  170. }
  171. // Name collision - find a unique numbered suffix
  172. // Search for all sessions that start with the base pattern
  173. const existingForks = await searchSessionsByCustomTitle(`${baseName} (Branch`)
  174. // Extract existing fork numbers to find the next available
  175. const usedNumbers = new Set<number>([1]) // Consider " (Branch)" as number 1
  176. const forkNumberPattern = new RegExp(
  177. `^${escapeRegExp(baseName)} \\(Branch(?: (\\d+))?\\)$`,
  178. )
  179. for (const session of existingForks) {
  180. const match = session.customTitle?.match(forkNumberPattern)
  181. if (match) {
  182. if (match[1]) {
  183. usedNumbers.add(parseInt(match[1], 10))
  184. } else {
  185. usedNumbers.add(1) // " (Branch)" without number is treated as 1
  186. }
  187. }
  188. }
  189. // Find the next available number
  190. let nextNumber = 2
  191. while (usedNumbers.has(nextNumber)) {
  192. nextNumber++
  193. }
  194. return `${baseName} (Branch ${nextNumber})`
  195. }
  196. export async function call(
  197. onDone: LocalJSXCommandOnDone,
  198. context: LocalJSXCommandContext,
  199. args: string,
  200. ): Promise<React.ReactNode> {
  201. const customTitle = args?.trim() || undefined
  202. const originalSessionId = getSessionId()
  203. try {
  204. const {
  205. sessionId,
  206. title,
  207. forkPath,
  208. serializedMessages,
  209. contentReplacementRecords,
  210. } = await createFork(customTitle)
  211. // Build LogOption for resume
  212. const now = new Date()
  213. const firstPrompt = deriveFirstPrompt(
  214. serializedMessages.find(m => m.type === 'user'),
  215. )
  216. // Save custom title - use provided title or firstPrompt as default
  217. // This ensures /status and /resume show the same session name
  218. // Always add " (Branch)" suffix to make it clear this is a branched session
  219. // Handle collisions by adding a number suffix (e.g., " (Branch 2)", " (Branch 3)")
  220. const baseName = title ?? firstPrompt
  221. const effectiveTitle = await getUniqueForkName(baseName)
  222. await saveCustomTitle(sessionId, effectiveTitle, forkPath)
  223. logEvent('tengu_conversation_forked', {
  224. message_count: serializedMessages.length,
  225. has_custom_title: !!title,
  226. })
  227. const forkLog: LogOption = {
  228. date: now.toISOString().split('T')[0]!,
  229. messages: serializedMessages,
  230. fullPath: forkPath,
  231. value: now.getTime(),
  232. created: now,
  233. modified: now,
  234. firstPrompt,
  235. messageCount: serializedMessages.length,
  236. isSidechain: false,
  237. sessionId,
  238. customTitle: effectiveTitle,
  239. contentReplacements: contentReplacementRecords,
  240. }
  241. // Resume into the fork
  242. const titleInfo = title ? ` "${title}"` : ''
  243. const resumeHint = `\nTo resume the original: claude -r ${originalSessionId}`
  244. const successMessage = `Branched conversation${titleInfo}. You are now in the branch.${resumeHint}`
  245. if (context.resume) {
  246. await context.resume(sessionId, forkLog, 'fork')
  247. onDone(successMessage, { display: 'system' })
  248. } else {
  249. // Fallback if resume not available
  250. onDone(
  251. `Branched conversation${titleInfo}. Resume with: /resume ${sessionId}`,
  252. )
  253. }
  254. return null
  255. } catch (error) {
  256. const message =
  257. error instanceof Error ? error.message : 'Unknown error occurred'
  258. onDone(`Failed to branch conversation: ${message}`)
  259. return null
  260. }
  261. }