From 14036209cd9974d6d97c5ff52f0fb1d7bbadd197 Mon Sep 17 00:00:00 2001 From: JATMN Date: Tue, 9 Jun 2026 22:46:53 -0700 Subject: [PATCH] Add configurable message-count compaction (#1587) * Add configurable message-count compaction Add a /config setting for opting into message-count-based compaction thresholds and persist it in global config. Disable the legacy OPENCLAUDE_MAX_ACTIVE_MESSAGES default unless the new setting is off and the environment variable is explicitly set. Add a timeout around forked compact summaries using a child abort controller so timeouts and user aborts clean up without affecting the main thread. Document the diagnostic setting and normalize trailing line endings in Windows alias docs/script. * Address compaction PR review feedback Add a shared literal enum and normalizer for message-count compaction thresholds, use it in config, /config UI, and query threshold handling. Move the compact timeout constant to module scope and mark the /config docs snippet with a text fence. --- docs/advanced-setup.md | 23 +++++++++++++ docs/windows-aliases-and-launchers.md | 4 +-- scripts/windows/openclaude-aliases.ps1 | 2 +- src/components/Settings/Config.tsx | 26 ++++++++++++++- src/query.ts | 19 ++++++++--- src/services/compact/compact.ts | 45 ++++++++++++++++++-------- src/utils/config.ts | 45 +++++++++++++++++++++++--- 7 files changed, 137 insertions(+), 27 deletions(-) diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index cce0fb3f6..8262aa6d9 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -435,3 +435,26 @@ run `doctor:runtime` first and only launch the app if checks pass. For `dev:ollama`, make sure Ollama is running locally before launch. For `dev:atomic-chat`, make sure Atomic Chat is running with a model loaded before launch. + +## Message-Count Compaction Threshold + +By default, OpenClaude compacts conversations based on token usage. A secondary +message-count-based trigger (`OPENCLAUDE_MAX_ACTIVE_MESSAGES`) exists for +diagnostics but is disabled by default. + +If you frequently resume long sessions that accumulate hundreds of small +tool-result messages with negligible token cost, you can opt in to message-count +compaction via the in-app `/config` command: + +```text +/config +``` + +Select **Message-count compaction** and choose a threshold (`100`, `200`, `500`, +or `1000`). Setting it to `off` (default) disables the message-count trigger. + +This setting is intended for power users debugging specific edge cases. Most +users should leave it at `off`. + +The legacy `OPENCLAUDE_MAX_ACTIVE_MESSAGES` environment variable is still +honored when the setting is `off`. diff --git a/docs/windows-aliases-and-launchers.md b/docs/windows-aliases-and-launchers.md index 0ce55647a..a5a9f9834 100644 --- a/docs/windows-aliases-and-launchers.md +++ b/docs/windows-aliases-and-launchers.md @@ -159,5 +159,5 @@ For advanced provider setup, use the built-in provider manager: ~~~powershell oc-provider -~~~ - +~~~ + diff --git a/scripts/windows/openclaude-aliases.ps1 b/scripts/windows/openclaude-aliases.ps1 index 1774336f8..7d1a7a3bf 100644 --- a/scripts/windows/openclaude-aliases.ps1 +++ b/scripts/windows/openclaude-aliases.ps1 @@ -203,4 +203,4 @@ function oc-help { param() Get-OpenClaudeQuickHelp -} +} diff --git a/src/components/Settings/Config.tsx b/src/components/Settings/Config.tsx index 68d63ceed..324c8f689 100644 --- a/src/components/Settings/Config.tsx +++ b/src/components/Settings/Config.tsx @@ -7,7 +7,7 @@ import * as React from 'react'; import { useState, useCallback } from 'react'; import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; import figures from 'figures'; -import { type GlobalConfig, saveGlobalConfig, getCurrentProjectConfig, type OutputStyle } from '../../utils/config.js'; +import { type GlobalConfig, saveGlobalConfig, getCurrentProjectConfig, type OutputStyle, MAX_MESSAGES_COMPACTION_THRESHOLDS, normalizeMaxMessagesCompactionThreshold } from '../../utils/config.js'; import { normalizeApiKeyForConfig } from '../../utils/authPortable.js'; import { getGlobalConfig, getAutoUpdaterDisabledReason, formatAutoUpdaterDisabledReason, getRemoteControlAtStartup } from '../../utils/config.js'; import chalk from 'chalk'; @@ -291,6 +291,26 @@ export function Config({ enabled: autoCompactEnabled }); } + }, { + id: 'maxMessagesCompactionThreshold', + label: 'Message-count compaction', + value: globalConfig.maxMessagesCompactionThreshold ?? 'off', + options: [...MAX_MESSAGES_COMPACTION_THRESHOLDS], + type: 'enum' as const, + onChange(maxMessagesCompactionThreshold: string) { + const normalizedThreshold = normalizeMaxMessagesCompactionThreshold(maxMessagesCompactionThreshold); + saveGlobalConfig(current => ({ + ...current, + maxMessagesCompactionThreshold: normalizedThreshold + })); + setGlobalConfig({ + ...getGlobalConfig(), + maxMessagesCompactionThreshold: normalizedThreshold + }); + logEvent('tengu_max_messages_compaction_threshold_changed', { + threshold: normalizedThreshold as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + }); + } }, { id: 'toolHistoryCompressionEnabled', label: 'Tool history compression', @@ -1172,6 +1192,10 @@ export function Config({ if (globalConfig.autoCompactEnabled !== initialConfig.current.autoCompactEnabled) { formattedChanges.push(`${globalConfig.autoCompactEnabled ? 'Enabled' : 'Disabled'} auto-compact`); } + if (globalConfig.maxMessagesCompactionThreshold !== initialConfig.current.maxMessagesCompactionThreshold) { + const threshold = globalConfig.maxMessagesCompactionThreshold ?? 'off'; + formattedChanges.push(threshold === 'off' ? 'Disabled message-count compaction' : `Set message-count compaction to ${threshold}`); + } if (globalConfig.toolHistoryCompressionEnabled !== initialConfig.current.toolHistoryCompressionEnabled) { formattedChanges.push(`${globalConfig.toolHistoryCompressionEnabled ? 'Enabled' : 'Disabled'} tool history compression`); } diff --git a/src/query.ts b/src/query.ts index f82d586b0..4273fcbf9 100644 --- a/src/query.ts +++ b/src/query.ts @@ -111,7 +111,10 @@ import { updateToolFailureLoopGuard, } from './query/toolFailureLoopGuard.js' import { buildQueryConfig } from './query/config.js' -import { getGlobalConfig } from './utils/config.js' +import { + getGlobalConfig, + normalizeMaxMessagesCompactionThreshold, +} from './utils/config.js' import { productionDeps, type QueryDeps } from './query/deps.js' import type { Terminal, Continue } from './query/transitions.js' import { feature } from 'bun:bundle' @@ -564,11 +567,17 @@ async function* queryLoop( const canForceCompact = querySource !== 'compact' && querySource !== 'session_memory' if (canForceCompact) { - const MAX_ACTIVE_MESSAGES = Number.parseInt( - process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES ?? '200', - 10, + const configSetting = normalizeMaxMessagesCompactionThreshold( + getGlobalConfig().maxMessagesCompactionThreshold, ) - if (messagesForQuery.length > MAX_ACTIVE_MESSAGES) { + const envSetting = process.env.OPENCLAUDE_MAX_ACTIVE_MESSAGES + const maxActiveMessages = configSetting !== 'off' + ? Number.parseInt(configSetting, 10) + : envSetting + ? Number.parseInt(envSetting, 10) + : 0 + + if (maxActiveMessages > 0 && messagesForQuery.length > maxActiveMessages) { tracking = { ...(tracking ?? { compacted: false, turnId: '', turnCounter: 0 }), forceReason: 'message-count', diff --git a/src/services/compact/compact.ts b/src/services/compact/compact.ts index a07d9bb6a..8297e0045 100644 --- a/src/services/compact/compact.ts +++ b/src/services/compact/compact.ts @@ -43,6 +43,7 @@ import { } from '../../utils/attachments.js' import { getMemoryPath } from '../../utils/config.js' import { COMPACT_MAX_OUTPUT_TOKENS } from '../../utils/context.js' +import { createChildAbortController } from '../../utils/abortController.js' import { analyzeContext, tokenStatsToStatsigMetrics, @@ -132,6 +133,7 @@ export const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000 export const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000 export const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000 const MAX_COMPACT_STREAMING_RETRIES = 2 +const COMPACT_TIMEOUT_MS = 120_000 /** * Strip image blocks from user messages before sending for compaction. @@ -1187,19 +1189,36 @@ async function streamCompactSummary({ // creating a thinking config mismatch that invalidates the cache. // The streaming fallback path (below) can safely set maxOutputTokensOverride // since it doesn't share cache with the main thread. - const result = await runForkedAgent({ - promptMessages: [summaryRequest], - cacheSafeParams, - canUseTool: createCompactCanUseTool(), - querySource: 'compact', - forkLabel: 'compact', - maxTurns: 1, - skipCacheWrite: true, - // Pass the compact context's abortController so user Esc aborts the - // fork — same signal the streaming fallback uses at - // `signal: context.abortController.signal` below. - overrides: { abortController: context.abortController }, - }) + // Use a child AbortController that properly propagates parent aborts + // (user ESC) and cleans up listeners automatically via createChildAbortController. + const forkAbortController = context.abortController + ? createChildAbortController(context.abortController) + : new AbortController() + + let timeoutId: ReturnType | undefined + let result: Awaited> + try { + result = await Promise.race([ + runForkedAgent({ + promptMessages: [summaryRequest], + cacheSafeParams, + canUseTool: createCompactCanUseTool(), + querySource: 'compact', + forkLabel: 'compact', + maxTurns: 1, + skipCacheWrite: true, + overrides: { abortController: forkAbortController }, + }), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + forkAbortController.abort() + reject(new Error('Compaction timed out')) + }, COMPACT_TIMEOUT_MS) + }), + ]) + } finally { + clearTimeout(timeoutId) + } const assistantMsg = getLastAssistantMessage(result.messages) const assistantText = assistantMsg ? getAssistantMessageText(assistantMsg) diff --git a/src/utils/config.ts b/src/utils/config.ts index 97b5bdff6..cc6f0d3f6 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -186,6 +186,26 @@ export type DiffTool = 'terminal' | 'auto' export type ShowCacheStatsMode = 'off' | 'compact' | 'full' export const SHOW_CACHE_STATS_MODES = ['off', 'compact', 'full'] as const satisfies readonly ShowCacheStatsMode[] +export const MAX_MESSAGES_COMPACTION_THRESHOLDS = [ + 'off', + '100', + '200', + '500', + '1000', +] as const +export type MaxMessagesCompactionThreshold = + (typeof MAX_MESSAGES_COMPACTION_THRESHOLDS)[number] + +export function normalizeMaxMessagesCompactionThreshold( + value: unknown, +): MaxMessagesCompactionThreshold { + return MAX_MESSAGES_COMPACTION_THRESHOLDS.includes( + value as MaxMessagesCompactionThreshold, + ) + ? (value as MaxMessagesCompactionThreshold) + : 'off' +} + export type OutputStyle = string export type Providers = string @@ -641,6 +661,12 @@ export type GlobalConfig = { // plain string (validated on read) to avoid pulling a UI module into the // config layer. Falls back to 'sunset' if missing or unrecognized. logoColor?: string + + // Message-count-based compaction threshold. Set via /config. + // 'off' = disabled (default). Otherwise, one of '100', '200', '500', '1000'. + // When enabled, triggers forced compaction if the message count exceeds the + // chosen threshold, regardless of token usage. + maxMessagesCompactionThreshold?: MaxMessagesCompactionThreshold } /** @@ -690,6 +716,7 @@ function createDefaultGlobalConfig(): GlobalConfig { providerProfiles: [], openaiAdditionalModelOptionsCacheByProfile: {}, knowledgeGraphEnabled: true, + maxMessagesCompactionThreshold: 'off', } return config } @@ -741,6 +768,7 @@ export const GLOBAL_CONFIG_KEYS = [ 'remoteDialogSeen', 'knowledgeGraphEnabled', 'logoColor', + 'maxMessagesCompactionThreshold', ] as const export type GlobalConfigKey = (typeof GLOBAL_CONFIG_KEYS)[number] @@ -1014,13 +1042,20 @@ registerCleanup(async () => { * @internal */ function migrateConfigFields(config: GlobalConfig): GlobalConfig { + const normalizedConfig = { + ...config, + maxMessagesCompactionThreshold: normalizeMaxMessagesCompactionThreshold( + config.maxMessagesCompactionThreshold, + ), + } + // Already migrated - if (config.installMethod !== undefined) { - return config + if (normalizedConfig.installMethod !== undefined) { + return normalizedConfig } // autoUpdaterStatus is removed from the type but may exist in old configs - const legacy = config as GlobalConfig & { + const legacy = normalizedConfig as GlobalConfig & { autoUpdaterStatus?: | 'migrated' | 'installed' @@ -1032,7 +1067,7 @@ function migrateConfigFields(config: GlobalConfig): GlobalConfig { // Determine install method and auto-update preference from old field let installMethod: InstallMethod = 'unknown' - let autoUpdates = config.autoUpdates ?? true // Default to enabled unless explicitly disabled + let autoUpdates = normalizedConfig.autoUpdates ?? true // Default to enabled unless explicitly disabled switch (legacy.autoUpdaterStatus) { case 'migrated': @@ -1057,7 +1092,7 @@ function migrateConfigFields(config: GlobalConfig): GlobalConfig { } return { - ...config, + ...normalizedConfig, installMethod, autoUpdates, }