/ commands / compact / compact.ts
compact.ts
  1  import { feature } from 'bun:bundle'
  2  import chalk from 'chalk'
  3  import { markPostCompaction } from 'src/bootstrap/state.js'
  4  import { getSystemPrompt } from '../../constants/prompts.js'
  5  import { getSystemContext, getUserContext } from '../../context.js'
  6  import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js'
  7  import { notifyCompaction } from '../../services/api/promptCacheBreakDetection.js'
  8  import {
  9    type CompactionResult,
 10    compactConversation,
 11    ERROR_MESSAGE_INCOMPLETE_RESPONSE,
 12    ERROR_MESSAGE_NOT_ENOUGH_MESSAGES,
 13    ERROR_MESSAGE_USER_ABORT,
 14    mergeHookInstructions,
 15  } from '../../services/compact/compact.js'
 16  import { suppressCompactWarning } from '../../services/compact/compactWarningState.js'
 17  import { microcompactMessages } from '../../services/compact/microCompact.js'
 18  import { runPostCompactCleanup } from '../../services/compact/postCompactCleanup.js'
 19  import { trySessionMemoryCompaction } from '../../services/compact/sessionMemoryCompact.js'
 20  import { setLastSummarizedMessageId } from '../../services/SessionMemory/sessionMemoryUtils.js'
 21  import type { ToolUseContext } from '../../Tool.js'
 22  import type { LocalCommandCall } from '../../types/command.js'
 23  import type { Message } from '../../types/message.js'
 24  import { hasExactErrorMessage } from '../../utils/errors.js'
 25  import { executePreCompactHooks } from '../../utils/hooks.js'
 26  import { logError } from '../../utils/log.js'
 27  import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'
 28  import { getUpgradeMessage } from '../../utils/model/contextWindowUpgradeCheck.js'
 29  import {
 30    buildEffectiveSystemPrompt,
 31    type SystemPrompt,
 32  } from '../../utils/systemPrompt.js'
 33  
 34  /* eslint-disable @typescript-eslint/no-require-imports */
 35  const reactiveCompact = feature('REACTIVE_COMPACT')
 36    ? (require('../../services/compact/reactiveCompact.js') as typeof import('../../services/compact/reactiveCompact.js'))
 37    : null
 38  /* eslint-enable @typescript-eslint/no-require-imports */
 39  
 40  export const call: LocalCommandCall = async (args, context) => {
 41    const { abortController } = context
 42    let { messages } = context
 43  
 44    // REPL keeps snipped messages for UI scrollback — project so the compact
 45    // model doesn't summarize content that was intentionally removed.
 46    messages = getMessagesAfterCompactBoundary(messages)
 47  
 48    if (messages.length === 0) {
 49      throw new Error('No messages to compact')
 50    }
 51  
 52    const customInstructions = args.trim()
 53  
 54    try {
 55      // Try session memory compaction first if no custom instructions
 56      // (session memory compaction doesn't support custom instructions)
 57      if (!customInstructions) {
 58        const sessionMemoryResult = await trySessionMemoryCompaction(
 59          messages,
 60          context.agentId,
 61        )
 62        if (sessionMemoryResult) {
 63          getUserContext.cache.clear?.()
 64          runPostCompactCleanup()
 65          // Reset cache read baseline so the post-compact drop isn't flagged
 66          // as a break. compactConversation does this internally; SM-compact doesn't.
 67          if (feature('PROMPT_CACHE_BREAK_DETECTION')) {
 68            notifyCompaction(
 69              context.options.querySource ?? 'compact',
 70              context.agentId,
 71            )
 72          }
 73          markPostCompaction()
 74          // Suppress warning immediately after successful compaction
 75          suppressCompactWarning()
 76  
 77          return {
 78            type: 'compact',
 79            compactionResult: sessionMemoryResult,
 80            displayText: buildDisplayText(context),
 81          }
 82        }
 83      }
 84  
 85      // Reactive-only mode: route /compact through the reactive path.
 86      // Checked after session-memory (that path is cheap and orthogonal).
 87      if (reactiveCompact?.isReactiveOnlyMode()) {
 88        return await compactViaReactive(
 89          messages,
 90          context,
 91          customInstructions,
 92          reactiveCompact,
 93        )
 94      }
 95  
 96      // Fall back to traditional compaction
 97      // Run microcompact first to reduce tokens before summarization
 98      const microcompactResult = await microcompactMessages(messages, context)
 99      const messagesForCompact = microcompactResult.messages
100  
101      const result = await compactConversation(
102        messagesForCompact,
103        context,
104        await getCacheSharingParams(context, messagesForCompact),
105        false,
106        customInstructions,
107        false,
108      )
109  
110      // Reset lastSummarizedMessageId since legacy compaction replaces all messages
111      // and the old message UUID will no longer exist in the new messages array
112      setLastSummarizedMessageId(undefined)
113  
114      // Suppress the "Context left until auto-compact" warning after successful compaction
115      suppressCompactWarning()
116  
117      getUserContext.cache.clear?.()
118      runPostCompactCleanup()
119  
120      return {
121        type: 'compact',
122        compactionResult: result,
123        displayText: buildDisplayText(context, result.userDisplayMessage),
124      }
125    } catch (error) {
126      if (abortController.signal.aborted) {
127        throw new Error('Compaction canceled.')
128      } else if (hasExactErrorMessage(error, ERROR_MESSAGE_NOT_ENOUGH_MESSAGES)) {
129        throw new Error(ERROR_MESSAGE_NOT_ENOUGH_MESSAGES)
130      } else if (hasExactErrorMessage(error, ERROR_MESSAGE_INCOMPLETE_RESPONSE)) {
131        throw new Error(ERROR_MESSAGE_INCOMPLETE_RESPONSE)
132      } else {
133        logError(error)
134        throw new Error(`Error during compaction: ${error}`)
135      }
136    }
137  }
138  
139  async function compactViaReactive(
140    messages: Message[],
141    context: ToolUseContext,
142    customInstructions: string,
143    reactive: NonNullable<typeof reactiveCompact>,
144  ): Promise<{
145    type: 'compact'
146    compactionResult: CompactionResult
147    displayText: string
148  }> {
149    context.onCompactProgress?.({
150      type: 'hooks_start',
151      hookType: 'pre_compact',
152    })
153    context.setSDKStatus?.('compacting')
154  
155    try {
156      // Hooks and cache-param build are independent — run concurrently.
157      // getCacheSharingParams walks all tools to build the system prompt;
158      // pre-compact hooks spawn subprocesses. Neither depends on the other.
159      const [hookResult, cacheSafeParams] = await Promise.all([
160        executePreCompactHooks(
161          { trigger: 'manual', customInstructions: customInstructions || null },
162          context.abortController.signal,
163        ),
164        getCacheSharingParams(context, messages),
165      ])
166      const mergedInstructions = mergeHookInstructions(
167        customInstructions,
168        hookResult.newCustomInstructions,
169      )
170  
171      context.setStreamMode?.('requesting')
172      context.setResponseLength?.(() => 0)
173      context.onCompactProgress?.({ type: 'compact_start' })
174  
175      const outcome = await reactive.reactiveCompactOnPromptTooLong(
176        messages,
177        cacheSafeParams,
178        { customInstructions: mergedInstructions, trigger: 'manual' },
179      )
180  
181      if (!outcome.ok) {
182        // The outer catch in `call` translates these: aborted → "Compaction
183        // canceled." (via abortController.signal.aborted check), NOT_ENOUGH →
184        // re-thrown as-is, everything else → "Error during compaction: …".
185        switch (outcome.reason) {
186          case 'too_few_groups':
187            throw new Error(ERROR_MESSAGE_NOT_ENOUGH_MESSAGES)
188          case 'aborted':
189            throw new Error(ERROR_MESSAGE_USER_ABORT)
190          case 'exhausted':
191          case 'error':
192          case 'media_unstrippable':
193            throw new Error(ERROR_MESSAGE_INCOMPLETE_RESPONSE)
194        }
195      }
196  
197      // Mirrors the post-success cleanup in tryReactiveCompact, minus
198      // resetMicrocompactState — processSlashCommand calls that for all
199      // type:'compact' results.
200      setLastSummarizedMessageId(undefined)
201      runPostCompactCleanup()
202      suppressCompactWarning()
203      getUserContext.cache.clear?.()
204  
205      // reactiveCompactOnPromptTooLong runs PostCompact hooks but not PreCompact
206      // — both callers (here and tryReactiveCompact) run PreCompact outside so
207      // they can merge its userDisplayMessage with PostCompact's here. This
208      // caller additionally runs it concurrently with getCacheSharingParams.
209      const combinedMessage =
210        [hookResult.userDisplayMessage, outcome.result.userDisplayMessage]
211          .filter(Boolean)
212          .join('\n') || undefined
213  
214      return {
215        type: 'compact',
216        compactionResult: {
217          ...outcome.result,
218          userDisplayMessage: combinedMessage,
219        },
220        displayText: buildDisplayText(context, combinedMessage),
221      }
222    } finally {
223      context.setStreamMode?.('requesting')
224      context.setResponseLength?.(() => 0)
225      context.onCompactProgress?.({ type: 'compact_end' })
226      context.setSDKStatus?.(null)
227    }
228  }
229  
230  function buildDisplayText(
231    context: ToolUseContext,
232    userDisplayMessage?: string,
233  ): string {
234    const upgradeMessage = getUpgradeMessage('tip')
235    const expandShortcut = getShortcutDisplay(
236      'app:toggleTranscript',
237      'Global',
238      'ctrl+o',
239    )
240    const dimmed = [
241      ...(context.options.verbose
242        ? []
243        : [`(${expandShortcut} to see full summary)`]),
244      ...(userDisplayMessage ? [userDisplayMessage] : []),
245      ...(upgradeMessage ? [upgradeMessage] : []),
246    ]
247    return chalk.dim('Compacted ' + dimmed.join('\n'))
248  }
249  
250  async function getCacheSharingParams(
251    context: ToolUseContext,
252    forkContextMessages: Message[],
253  ): Promise<{
254    systemPrompt: SystemPrompt
255    userContext: { [k: string]: string }
256    systemContext: { [k: string]: string }
257    toolUseContext: ToolUseContext
258    forkContextMessages: Message[]
259  }> {
260    const appState = context.getAppState()
261    const defaultSysPrompt = await getSystemPrompt(
262      context.options.tools,
263      context.options.mainLoopModel,
264      Array.from(
265        appState.toolPermissionContext.additionalWorkingDirectories.keys(),
266      ),
267      context.options.mcpClients,
268    )
269    const systemPrompt = buildEffectiveSystemPrompt({
270      mainThreadAgentDefinition: undefined,
271      toolUseContext: context,
272      customSystemPrompt: context.options.customSystemPrompt,
273      defaultSystemPrompt: defaultSysPrompt,
274      appendSystemPrompt: context.options.appendSystemPrompt,
275    })
276    const [userContext, systemContext] = await Promise.all([
277      getUserContext(),
278      getSystemContext(),
279    ])
280    return {
281      systemPrompt,
282      userContext,
283      systemContext,
284      toolUseContext: context,
285      forkContextMessages,
286    }
287  }