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.
This commit is contained in:
JATMN
2026-06-10 13:46:53 +08:00
committed by GitHub
parent 5040c491da
commit 14036209cd
7 changed files with 137 additions and 27 deletions
+23
View File
@@ -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`.
+2 -2
View File
@@ -159,5 +159,5 @@ For advanced provider setup, use the built-in provider manager:
~~~powershell
oc-provider
~~~
~~~
+1 -1
View File
@@ -203,4 +203,4 @@ function oc-help {
param()
Get-OpenClaudeQuickHelp
}
}
+25 -1
View File
@@ -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`);
}
+14 -5
View File
@@ -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',
+32 -13
View File
@@ -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<typeof setTimeout> | undefined
let result: Awaited<ReturnType<typeof runForkedAgent>>
try {
result = await Promise.race([
runForkedAgent({
promptMessages: [summaryRequest],
cacheSafeParams,
canUseTool: createCompactCanUseTool(),
querySource: 'compact',
forkLabel: 'compact',
maxTurns: 1,
skipCacheWrite: true,
overrides: { abortController: forkAbortController },
}),
new Promise<never>((_, 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)
+40 -5
View File
@@ -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,
}