mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
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:
@@ -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`.
|
||||
|
||||
@@ -159,5 +159,5 @@ For advanced provider setup, use the built-in provider manager:
|
||||
|
||||
~~~powershell
|
||||
oc-provider
|
||||
~~~
|
||||
|
||||
~~~
|
||||
|
||||
|
||||
@@ -203,4 +203,4 @@ function oc-help {
|
||||
param()
|
||||
|
||||
Get-OpenClaudeQuickHelp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user