288 lines
9.8 KiB
TypeScript
288 lines
9.8 KiB
TypeScript
import { feature } from 'bun:bundle'
|
|
import chalk from 'chalk'
|
|
import { markPostCompaction } from 'src/bootstrap/state.js'
|
|
import { getSystemPrompt } from '../../constants/prompts.js'
|
|
import { getSystemContext, getUserContext } from '../../context.js'
|
|
import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js'
|
|
import { notifyCompaction } from '../../services/api/promptCacheBreakDetection.js'
|
|
import {
|
|
type CompactionResult,
|
|
compactConversation,
|
|
ERROR_MESSAGE_INCOMPLETE_RESPONSE,
|
|
ERROR_MESSAGE_NOT_ENOUGH_MESSAGES,
|
|
ERROR_MESSAGE_USER_ABORT,
|
|
mergeHookInstructions,
|
|
} from '../../services/compact/compact.js'
|
|
import { suppressCompactWarning } from '../../services/compact/compactWarningState.js'
|
|
import { microcompactMessages } from '../../services/compact/microCompact.js'
|
|
import { runPostCompactCleanup } from '../../services/compact/postCompactCleanup.js'
|
|
import { trySessionMemoryCompaction } from '../../services/compact/sessionMemoryCompact.js'
|
|
import { setLastSummarizedMessageId } from '../../services/SessionMemory/sessionMemoryUtils.js'
|
|
import type { ToolUseContext } from '../../Tool.js'
|
|
import type { LocalCommandCall } from '../../types/command.js'
|
|
import type { Message } from '../../types/message.js'
|
|
import { hasExactErrorMessage } from '../../utils/errors.js'
|
|
import { executePreCompactHooks } from '../../utils/hooks.js'
|
|
import { logError } from '../../utils/log.js'
|
|
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'
|
|
import { getUpgradeMessage } from '../../utils/model/contextWindowUpgradeCheck.js'
|
|
import {
|
|
buildEffectiveSystemPrompt,
|
|
type SystemPrompt,
|
|
} from '../../utils/systemPrompt.js'
|
|
|
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
const reactiveCompact = feature('REACTIVE_COMPACT')
|
|
? (require('../../services/compact/reactiveCompact.js') as typeof import('../../services/compact/reactiveCompact.js'))
|
|
: null
|
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
|
|
export const call: LocalCommandCall = async (args, context) => {
|
|
const { abortController } = context
|
|
let { messages } = context
|
|
|
|
// REPL keeps snipped messages for UI scrollback — project so the compact
|
|
// model doesn't summarize content that was intentionally removed.
|
|
messages = getMessagesAfterCompactBoundary(messages)
|
|
|
|
if (messages.length === 0) {
|
|
throw new Error('No messages to compact')
|
|
}
|
|
|
|
const customInstructions = args.trim()
|
|
|
|
try {
|
|
// Try session memory compaction first if no custom instructions
|
|
// (session memory compaction doesn't support custom instructions)
|
|
if (!customInstructions) {
|
|
const sessionMemoryResult = await trySessionMemoryCompaction(
|
|
messages,
|
|
context.agentId,
|
|
)
|
|
if (sessionMemoryResult) {
|
|
getUserContext.cache.clear?.()
|
|
runPostCompactCleanup()
|
|
// Reset cache read baseline so the post-compact drop isn't flagged
|
|
// as a break. compactConversation does this internally; SM-compact doesn't.
|
|
if (feature('PROMPT_CACHE_BREAK_DETECTION')) {
|
|
notifyCompaction(
|
|
context.options.querySource ?? 'compact',
|
|
context.agentId,
|
|
)
|
|
}
|
|
markPostCompaction()
|
|
// Suppress warning immediately after successful compaction
|
|
suppressCompactWarning()
|
|
|
|
return {
|
|
type: 'compact',
|
|
compactionResult: sessionMemoryResult,
|
|
displayText: buildDisplayText(context),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reactive-only mode: route /compact through the reactive path.
|
|
// Checked after session-memory (that path is cheap and orthogonal).
|
|
if (reactiveCompact?.isReactiveOnlyMode()) {
|
|
return await compactViaReactive(
|
|
messages,
|
|
context,
|
|
customInstructions,
|
|
reactiveCompact,
|
|
)
|
|
}
|
|
|
|
// Fall back to traditional compaction
|
|
// Run microcompact first to reduce tokens before summarization
|
|
const microcompactResult = await microcompactMessages(messages, context)
|
|
const messagesForCompact = microcompactResult.messages
|
|
|
|
const result = await compactConversation(
|
|
messagesForCompact,
|
|
context,
|
|
await getCacheSharingParams(context, messagesForCompact),
|
|
false,
|
|
customInstructions,
|
|
false,
|
|
)
|
|
|
|
// Reset lastSummarizedMessageId since legacy compaction replaces all messages
|
|
// and the old message UUID will no longer exist in the new messages array
|
|
setLastSummarizedMessageId(undefined)
|
|
|
|
// Suppress the "Context left until auto-compact" warning after successful compaction
|
|
suppressCompactWarning()
|
|
|
|
getUserContext.cache.clear?.()
|
|
runPostCompactCleanup()
|
|
|
|
return {
|
|
type: 'compact',
|
|
compactionResult: result,
|
|
displayText: buildDisplayText(context, result.userDisplayMessage),
|
|
}
|
|
} catch (error) {
|
|
if (abortController.signal.aborted) {
|
|
throw new Error('Compaction canceled.')
|
|
} else if (hasExactErrorMessage(error, ERROR_MESSAGE_NOT_ENOUGH_MESSAGES)) {
|
|
throw new Error(ERROR_MESSAGE_NOT_ENOUGH_MESSAGES)
|
|
} else if (hasExactErrorMessage(error, ERROR_MESSAGE_INCOMPLETE_RESPONSE)) {
|
|
throw new Error(ERROR_MESSAGE_INCOMPLETE_RESPONSE)
|
|
} else {
|
|
logError(error)
|
|
throw new Error(`Error during compaction: ${error}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function compactViaReactive(
|
|
messages: Message[],
|
|
context: ToolUseContext,
|
|
customInstructions: string,
|
|
reactive: NonNullable<typeof reactiveCompact>,
|
|
): Promise<{
|
|
type: 'compact'
|
|
compactionResult: CompactionResult
|
|
displayText: string
|
|
}> {
|
|
context.onCompactProgress?.({
|
|
type: 'hooks_start',
|
|
hookType: 'pre_compact',
|
|
})
|
|
context.setSDKStatus?.('compacting')
|
|
|
|
try {
|
|
// Hooks and cache-param build are independent — run concurrently.
|
|
// getCacheSharingParams walks all tools to build the system prompt;
|
|
// pre-compact hooks spawn subprocesses. Neither depends on the other.
|
|
const [hookResult, cacheSafeParams] = await Promise.all([
|
|
executePreCompactHooks(
|
|
{ trigger: 'manual', customInstructions: customInstructions || null },
|
|
context.abortController.signal,
|
|
),
|
|
getCacheSharingParams(context, messages),
|
|
])
|
|
const mergedInstructions = mergeHookInstructions(
|
|
customInstructions,
|
|
hookResult.newCustomInstructions,
|
|
)
|
|
|
|
context.setStreamMode?.('requesting')
|
|
context.setResponseLength?.(() => 0)
|
|
context.onCompactProgress?.({ type: 'compact_start' })
|
|
|
|
const outcome = await reactive.reactiveCompactOnPromptTooLong(
|
|
messages,
|
|
cacheSafeParams,
|
|
{ customInstructions: mergedInstructions, trigger: 'manual' },
|
|
)
|
|
|
|
if (!outcome.ok) {
|
|
// The outer catch in `call` translates these: aborted → "Compaction
|
|
// canceled." (via abortController.signal.aborted check), NOT_ENOUGH →
|
|
// re-thrown as-is, everything else → "Error during compaction: …".
|
|
switch (outcome.reason) {
|
|
case 'too_few_groups':
|
|
throw new Error(ERROR_MESSAGE_NOT_ENOUGH_MESSAGES)
|
|
case 'aborted':
|
|
throw new Error(ERROR_MESSAGE_USER_ABORT)
|
|
case 'exhausted':
|
|
case 'error':
|
|
case 'media_unstrippable':
|
|
throw new Error(ERROR_MESSAGE_INCOMPLETE_RESPONSE)
|
|
}
|
|
}
|
|
|
|
// Mirrors the post-success cleanup in tryReactiveCompact, minus
|
|
// resetMicrocompactState — processSlashCommand calls that for all
|
|
// type:'compact' results.
|
|
setLastSummarizedMessageId(undefined)
|
|
runPostCompactCleanup()
|
|
suppressCompactWarning()
|
|
getUserContext.cache.clear?.()
|
|
|
|
// reactiveCompactOnPromptTooLong runs PostCompact hooks but not PreCompact
|
|
// — both callers (here and tryReactiveCompact) run PreCompact outside so
|
|
// they can merge its userDisplayMessage with PostCompact's here. This
|
|
// caller additionally runs it concurrently with getCacheSharingParams.
|
|
const combinedMessage =
|
|
[hookResult.userDisplayMessage, outcome.result.userDisplayMessage]
|
|
.filter(Boolean)
|
|
.join('\n') || undefined
|
|
|
|
return {
|
|
type: 'compact',
|
|
compactionResult: {
|
|
...outcome.result,
|
|
userDisplayMessage: combinedMessage,
|
|
},
|
|
displayText: buildDisplayText(context, combinedMessage),
|
|
}
|
|
} finally {
|
|
context.setStreamMode?.('requesting')
|
|
context.setResponseLength?.(() => 0)
|
|
context.onCompactProgress?.({ type: 'compact_end' })
|
|
context.setSDKStatus?.(null)
|
|
}
|
|
}
|
|
|
|
function buildDisplayText(
|
|
context: ToolUseContext,
|
|
userDisplayMessage?: string,
|
|
): string {
|
|
const upgradeMessage = getUpgradeMessage('tip')
|
|
const expandShortcut = getShortcutDisplay(
|
|
'app:toggleTranscript',
|
|
'Global',
|
|
'ctrl+o',
|
|
)
|
|
const dimmed = [
|
|
...(context.options.verbose
|
|
? []
|
|
: [`(${expandShortcut} to see full summary)`]),
|
|
...(userDisplayMessage ? [userDisplayMessage] : []),
|
|
...(upgradeMessage ? [upgradeMessage] : []),
|
|
]
|
|
return chalk.dim('Compacted ' + dimmed.join('\n'))
|
|
}
|
|
|
|
async function getCacheSharingParams(
|
|
context: ToolUseContext,
|
|
forkContextMessages: Message[],
|
|
): Promise<{
|
|
systemPrompt: SystemPrompt
|
|
userContext: { [k: string]: string }
|
|
systemContext: { [k: string]: string }
|
|
toolUseContext: ToolUseContext
|
|
forkContextMessages: Message[]
|
|
}> {
|
|
const appState = context.getAppState()
|
|
const defaultSysPrompt = await getSystemPrompt(
|
|
context.options.tools,
|
|
context.options.mainLoopModel,
|
|
Array.from(
|
|
appState.toolPermissionContext.additionalWorkingDirectories.keys(),
|
|
),
|
|
context.options.mcpClients,
|
|
)
|
|
const systemPrompt = buildEffectiveSystemPrompt({
|
|
mainThreadAgentDefinition: undefined,
|
|
toolUseContext: context,
|
|
customSystemPrompt: context.options.customSystemPrompt,
|
|
defaultSystemPrompt: defaultSysPrompt,
|
|
appendSystemPrompt: context.options.appendSystemPrompt,
|
|
})
|
|
const [userContext, systemContext] = await Promise.all([
|
|
getUserContext(),
|
|
getSystemContext(),
|
|
])
|
|
return {
|
|
systemPrompt,
|
|
userContext,
|
|
systemContext,
|
|
toolUseContext: context,
|
|
forkContextMessages,
|
|
}
|
|
}
|