refactor: remove dcp_for_compaction and preemptive_compaction features
- Delete src/hooks/preemptive-compaction/ entirely - Remove dcp_for_compaction from schema and executor - Clean up related imports, options, and test code - Update READMEs to remove experimental options docs
This commit is contained in:
13
README.ja.md
13
README.ja.md
@@ -1047,7 +1047,6 @@ OpenCode でサポートされるすべての LSP 構成およびカスタム設
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"preemptive_compaction_threshold": 0.85,
|
||||
"truncate_all_tool_outputs": true,
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true
|
||||
@@ -1055,13 +1054,11 @@ OpenCode でサポートされるすべての LSP 構成およびカスタム設
|
||||
}
|
||||
```
|
||||
|
||||
| オプション | デフォルト | 説明 |
|
||||
| --------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `preemptive_compaction_threshold` | `0.85` | プリエンプティブコンパクションをトリガーする閾値(0.5-0.95)。`preemptive-compaction` フックはデフォルトで有効です。このオプションで閾値をカスタマイズできます。 |
|
||||
| `truncate_all_tool_outputs` | `false` | ホワイトリストのツール(Grep、Glob、LSP、AST-grep)だけでなく、すべてのツール出力を切り詰めます。Tool output truncator はデフォルトで有効です - `disabled_hooks`で無効化できます。 |
|
||||
| `aggressive_truncation` | `false` | トークン制限を超えた場合、ツール出力を積極的に切り詰めて制限内に収めます。デフォルトの切り詰めより積極的です。不十分な場合は要約/復元にフォールバックします。 |
|
||||
| `auto_resume` | `false` | thinking block エラーや thinking disabled violation からの回復成功後、自動的にセッションを再開します。最後のユーザーメッセージを抽出して続行します。 |
|
||||
| `dcp_for_compaction` | `false` | コンパクション用DCP(動的コンテキスト整理)を有効化 - トークン制限超過時に最初に実行されます。コンパクション前に重複したツール呼び出しと古いツール出力を整理します。 |
|
||||
| オプション | デフォルト | 説明 |
|
||||
| --------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `truncate_all_tool_outputs` | `false` | ホワイトリストのツール(Grep、Glob、LSP、AST-grep)だけでなく、すべてのツール出力を切り詰めます。Tool output truncator はデフォルトで有効です - `disabled_hooks`で無効化できます。 |
|
||||
| `aggressive_truncation` | `false` | トークン制限を超えた場合、ツール出力を積極的に切り詰めて制限内に収めます。デフォルトの切り詰めより積極的です。不十分な場合は要約/復元にフォールバックします。 |
|
||||
| `auto_resume` | `false` | thinking block エラーや thinking disabled violation からの回復成功後、自動的にセッションを再開します。最後のユーザーメッセージを抽出して続行します。 |
|
||||
|
||||
**警告**:これらの機能は実験的であり、予期しない動作を引き起こす可能性があります。影響を理解した場合にのみ有効にしてください。
|
||||
|
||||
|
||||
13
README.md
13
README.md
@@ -1165,7 +1165,6 @@ Opt-in experimental features that may change or be removed in future versions. U
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"preemptive_compaction_threshold": 0.85,
|
||||
"truncate_all_tool_outputs": true,
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true
|
||||
@@ -1173,13 +1172,11 @@ Opt-in experimental features that may change or be removed in future versions. U
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| --------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `preemptive_compaction_threshold` | `0.85` | Threshold percentage (0.5-0.95) to trigger preemptive compaction. The `preemptive-compaction` hook is enabled by default; this option customizes the threshold. |
|
||||
| `truncate_all_tool_outputs` | `false` | Truncates ALL tool outputs instead of just whitelisted tools (Grep, Glob, LSP, AST-grep). Tool output truncator is enabled by default - disable via `disabled_hooks`. |
|
||||
| `aggressive_truncation` | `false` | When token limit is exceeded, aggressively truncates tool outputs to fit within limits. More aggressive than the default truncation behavior. Falls back to summarize/revert if insufficient. |
|
||||
| `auto_resume` | `false` | Automatically resumes session after successful recovery from thinking block errors or thinking disabled violations. Extracts the last user message and continues. |
|
||||
| `dcp_for_compaction` | `false` | Enable DCP (Dynamic Context Pruning) for compaction - runs first when token limit exceeded. Prunes duplicate tool calls and old tool outputs before running compaction. |
|
||||
| Option | Default | Description |
|
||||
| --------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `truncate_all_tool_outputs` | `false` | Truncates ALL tool outputs instead of just whitelisted tools (Grep, Glob, LSP, AST-grep). Tool output truncator is enabled by default - disable via `disabled_hooks`. |
|
||||
| `aggressive_truncation` | `false` | When token limit is exceeded, aggressively truncates tool outputs to fit within limits. More aggressive than the default truncation behavior. Falls back to summarize/revert if insufficient. |
|
||||
| `auto_resume` | `false` | Automatically resumes session after successful recovery from thinking block errors or thinking disabled violations. Extracts the last user message and continues. |
|
||||
|
||||
**Warning**: These features are experimental and may cause unexpected behavior. Enable only if you understand the implications.
|
||||
|
||||
|
||||
@@ -1174,7 +1174,6 @@ Oh My OpenCode 添加了重构工具(重命名、代码操作)。
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"preemptive_compaction_threshold": 0.85,
|
||||
"truncate_all_tool_outputs": true,
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true
|
||||
@@ -1182,13 +1181,11 @@ Oh My OpenCode 添加了重构工具(重命名、代码操作)。
|
||||
}
|
||||
```
|
||||
|
||||
| 选项 | 默认 | 描述 |
|
||||
| --------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `preemptive_compaction_threshold` | `0.85` | 触发预防性压缩的阈值百分比(0.5-0.95)。`preemptive-compaction` 钩子默认启用;此选项自定义阈值。 |
|
||||
| `truncate_all_tool_outputs` | `false` | 截断所有工具输出而不仅仅是白名单工具(Grep、Glob、LSP、AST-grep)。工具输出截断器默认启用——通过 `disabled_hooks` 禁用。 |
|
||||
| `aggressive_truncation` | `false` | 当超过 token 限制时,积极截断工具输出以适应限制。比默认截断行为更激进。如果不足以满足,则回退到总结/恢复。 |
|
||||
| `auto_resume` | `false` | 从思考块错误或禁用思考违规成功恢复后自动恢复会话。提取最后一条用户消息并继续。 |
|
||||
| `dcp_for_compaction` | `false` | 为压缩启用 DCP(动态上下文修剪)——当超过 token 限制时首先运行。在运行压缩之前修剪重复的工具调用和旧的工具输出。 |
|
||||
| 选项 | 默认 | 描述 |
|
||||
| --------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `truncate_all_tool_outputs` | `false` | 截断所有工具输出而不仅仅是白名单工具(Grep、Glob、LSP、AST-grep)。工具输出截断器默认启用——通过 `disabled_hooks` 禁用。 |
|
||||
| `aggressive_truncation` | `false` | 当超过 token 限制时,积极截断工具输出以适应限制。比默认截断行为更激进。如果不足以满足,则回退到总结/恢复。 |
|
||||
| `auto_resume` | `false` | 从思考块错误或禁用思考违规成功恢复后自动恢复会话。提取最后一条用户消息并继续。 |
|
||||
|
||||
**警告**:这些功能是实验性的,可能导致意外行为。只有在理解其影响后才启用。
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export const HookNameSchema = z.enum([
|
||||
"empty-message-sanitizer",
|
||||
"thinking-block-validator",
|
||||
"ralph-loop",
|
||||
"preemptive-compaction",
|
||||
|
||||
"compaction-context-injector",
|
||||
"claude-code-hooks",
|
||||
"auto-slash-command",
|
||||
@@ -225,16 +225,10 @@ export const DynamicContextPruningConfigSchema = z.object({
|
||||
export const ExperimentalConfigSchema = z.object({
|
||||
aggressive_truncation: z.boolean().optional(),
|
||||
auto_resume: z.boolean().optional(),
|
||||
/** Enable preemptive compaction at threshold (default: true since v2.9.0) */
|
||||
preemptive_compaction: z.boolean().optional(),
|
||||
/** Threshold percentage to trigger preemptive compaction (default: 0.80) */
|
||||
preemptive_compaction_threshold: z.number().min(0.5).max(0.95).optional(),
|
||||
/** Truncate all tool outputs, not just whitelisted tools (default: false). Tool output truncator is enabled by default - disable via disabled_hooks. */
|
||||
truncate_all_tool_outputs: z.boolean().optional(),
|
||||
/** Dynamic context pruning configuration */
|
||||
dynamic_context_pruning: DynamicContextPruningConfigSchema.optional(),
|
||||
/** Enable DCP (Dynamic Context Pruning) for compaction - runs first when token limit exceeded (default: false) */
|
||||
dcp_for_compaction: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const SkillSourceSchema = z.union([
|
||||
|
||||
@@ -17,7 +17,6 @@ describe("executeCompact lock management", () => {
|
||||
errorDataBySession: new Map(),
|
||||
retryStateBySession: new Map(),
|
||||
truncateStateBySession: new Map(),
|
||||
dcpStateBySession: new Map(),
|
||||
emptyContentAttemptBySession: new Map(),
|
||||
compactionInProgress: new Set<string>(),
|
||||
}
|
||||
@@ -119,7 +118,6 @@ describe("executeCompact lock management", () => {
|
||||
truncate_all_tool_outputs: false,
|
||||
aggressive_truncation: true,
|
||||
}
|
||||
const dcpForCompaction = true
|
||||
|
||||
// #when: Execute compaction with experimental flag
|
||||
await executeCompact(
|
||||
@@ -129,7 +127,6 @@ describe("executeCompact lock management", () => {
|
||||
mockClient,
|
||||
directory,
|
||||
experimental,
|
||||
dcpForCompaction,
|
||||
)
|
||||
|
||||
// #then: Lock should be cleared even on early return
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type {
|
||||
AutoCompactState,
|
||||
DcpState,
|
||||
RetryState,
|
||||
TruncateState,
|
||||
} from "./types";
|
||||
import type { ExperimentalConfig } from "../../config";
|
||||
import { RETRY_CONFIG, TRUNCATE_CONFIG } from "./types";
|
||||
import { executeDynamicContextPruning } from "./pruning-executor";
|
||||
|
||||
import {
|
||||
findLargestToolResult,
|
||||
truncateToolResult,
|
||||
@@ -82,17 +81,7 @@ function getOrCreateTruncateState(
|
||||
return state;
|
||||
}
|
||||
|
||||
function getOrCreateDcpState(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string,
|
||||
): DcpState {
|
||||
let state = autoCompactState.dcpStateBySession.get(sessionID);
|
||||
if (!state) {
|
||||
state = { attempted: false, itemsPruned: 0 };
|
||||
autoCompactState.dcpStateBySession.set(sessionID, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
|
||||
function sanitizeEmptyMessagesBeforeSummarize(sessionID: string): number {
|
||||
const emptyMessageIds = findEmptyMessages(sessionID);
|
||||
@@ -168,7 +157,6 @@ function clearSessionState(
|
||||
autoCompactState.errorDataBySession.delete(sessionID);
|
||||
autoCompactState.retryStateBySession.delete(sessionID);
|
||||
autoCompactState.truncateStateBySession.delete(sessionID);
|
||||
autoCompactState.dcpStateBySession.delete(sessionID);
|
||||
autoCompactState.emptyContentAttemptBySession.delete(sessionID);
|
||||
autoCompactState.compactionInProgress.delete(sessionID);
|
||||
}
|
||||
@@ -275,7 +263,6 @@ export async function executeCompact(
|
||||
client: any,
|
||||
directory: string,
|
||||
experimental?: ExperimentalConfig,
|
||||
dcpForCompaction?: boolean,
|
||||
): Promise<void> {
|
||||
if (autoCompactState.compactionInProgress.has(sessionID)) {
|
||||
await (client as Client).tui
|
||||
@@ -302,61 +289,7 @@ export async function executeCompact(
|
||||
errorData?.maxTokens &&
|
||||
errorData.currentTokens > errorData.maxTokens;
|
||||
|
||||
// PHASE 1: DCP (Dynamic Context Pruning) - prune duplicate tool calls first
|
||||
const dcpState = getOrCreateDcpState(autoCompactState, sessionID);
|
||||
if (dcpForCompaction !== false && !dcpState.attempted && isOverLimit) {
|
||||
dcpState.attempted = true;
|
||||
log("[auto-compact] PHASE 1: DCP triggered on token limit error", {
|
||||
sessionID,
|
||||
currentTokens: errorData.currentTokens,
|
||||
maxTokens: errorData.maxTokens,
|
||||
});
|
||||
|
||||
const dcpConfig = experimental?.dynamic_context_pruning ?? {
|
||||
enabled: true,
|
||||
notification: "detailed" as const,
|
||||
protected_tools: [
|
||||
"task",
|
||||
"todowrite",
|
||||
"todoread",
|
||||
"lsp_rename",
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const pruningResult = await executeDynamicContextPruning(
|
||||
sessionID,
|
||||
dcpConfig,
|
||||
client,
|
||||
);
|
||||
|
||||
if (pruningResult.itemsPruned > 0) {
|
||||
dcpState.itemsPruned = pruningResult.itemsPruned;
|
||||
log("[auto-compact] DCP successful, proceeding to truncation", {
|
||||
itemsPruned: pruningResult.itemsPruned,
|
||||
tokensSaved: pruningResult.totalTokensSaved,
|
||||
});
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Dynamic Context Pruning",
|
||||
message: `Pruned ${pruningResult.itemsPruned} items (~${Math.round(pruningResult.totalTokensSaved / 1000)}k tokens). Proceeding to truncation...`,
|
||||
variant: "success",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
// Continue to PHASE 2 (truncation) instead of summarizing immediately
|
||||
} else {
|
||||
log("[auto-compact] DCP did not prune any items", { sessionID });
|
||||
}
|
||||
} catch (error) {
|
||||
log("[auto-compact] DCP failed", { error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
// PHASE 2: Aggressive Truncation - always try when over limit (not experimental-only)
|
||||
// Aggressive Truncation - always try when over limit
|
||||
if (
|
||||
isOverLimit &&
|
||||
truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts
|
||||
@@ -448,7 +381,6 @@ export async function executeCompact(
|
||||
client,
|
||||
directory,
|
||||
experimental,
|
||||
dcpForCompaction,
|
||||
);
|
||||
}, 500);
|
||||
return;
|
||||
@@ -517,7 +449,6 @@ export async function executeCompact(
|
||||
client,
|
||||
directory,
|
||||
experimental,
|
||||
dcpForCompaction,
|
||||
);
|
||||
}, cappedDelay);
|
||||
return;
|
||||
|
||||
@@ -7,7 +7,6 @@ import { log } from "../../shared/logger"
|
||||
|
||||
export interface AnthropicContextWindowLimitRecoveryOptions {
|
||||
experimental?: ExperimentalConfig
|
||||
dcpForCompaction?: boolean
|
||||
}
|
||||
|
||||
function createRecoveryState(): AutoCompactState {
|
||||
@@ -16,7 +15,6 @@ function createRecoveryState(): AutoCompactState {
|
||||
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
|
||||
retryStateBySession: new Map(),
|
||||
truncateStateBySession: new Map(),
|
||||
dcpStateBySession: new Map(),
|
||||
emptyContentAttemptBySession: new Map(),
|
||||
compactionInProgress: new Set<string>(),
|
||||
}
|
||||
@@ -25,7 +23,6 @@ function createRecoveryState(): AutoCompactState {
|
||||
export function createAnthropicContextWindowLimitRecoveryHook(ctx: PluginInput, options?: AnthropicContextWindowLimitRecoveryOptions) {
|
||||
const autoCompactState = createRecoveryState()
|
||||
const experimental = options?.experimental
|
||||
const dcpForCompaction = options?.dcpForCompaction
|
||||
|
||||
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
@@ -37,7 +34,6 @@ export function createAnthropicContextWindowLimitRecoveryHook(ctx: PluginInput,
|
||||
autoCompactState.errorDataBySession.delete(sessionInfo.id)
|
||||
autoCompactState.retryStateBySession.delete(sessionInfo.id)
|
||||
autoCompactState.truncateStateBySession.delete(sessionInfo.id)
|
||||
autoCompactState.dcpStateBySession.delete(sessionInfo.id)
|
||||
autoCompactState.emptyContentAttemptBySession.delete(sessionInfo.id)
|
||||
autoCompactState.compactionInProgress.delete(sessionInfo.id)
|
||||
}
|
||||
@@ -81,8 +77,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(ctx: PluginInput,
|
||||
autoCompactState,
|
||||
ctx.client,
|
||||
ctx.directory,
|
||||
experimental,
|
||||
dcpForCompaction
|
||||
experimental
|
||||
)
|
||||
}, 300)
|
||||
}
|
||||
@@ -141,8 +136,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(ctx: PluginInput,
|
||||
autoCompactState,
|
||||
ctx.client,
|
||||
ctx.directory,
|
||||
experimental,
|
||||
dcpForCompaction
|
||||
experimental
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -152,6 +146,6 @@ export function createAnthropicContextWindowLimitRecoveryHook(ctx: PluginInput,
|
||||
}
|
||||
}
|
||||
|
||||
export type { AutoCompactState, DcpState, ParsedTokenLimitError, TruncateState } from "./types"
|
||||
export type { AutoCompactState, ParsedTokenLimitError, TruncateState } from "./types"
|
||||
export { parseAnthropicTokenLimitError } from "./parser"
|
||||
export { executeCompact, getLastAssistant } from "./executor"
|
||||
|
||||
@@ -18,17 +18,11 @@ export interface TruncateState {
|
||||
lastTruncatedPartId?: string
|
||||
}
|
||||
|
||||
export interface DcpState {
|
||||
attempted: boolean
|
||||
itemsPruned: number
|
||||
}
|
||||
|
||||
export interface AutoCompactState {
|
||||
pendingCompact: Set<string>
|
||||
errorDataBySession: Map<string, ParsedTokenLimitError>
|
||||
retryStateBySession: Map<string, RetryState>
|
||||
truncateStateBySession: Map<string, TruncateState>
|
||||
dcpStateBySession: Map<string, DcpState>
|
||||
emptyContentAttemptBySession: Map<string, number>
|
||||
compactionInProgress: Set<string>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { SummarizeContext } from "../preemptive-compaction"
|
||||
import { injectHookMessage } from "../../features/hook-message-injector"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export interface SummarizeContext {
|
||||
sessionID: string
|
||||
providerID: string
|
||||
modelID: string
|
||||
usageRatio: number
|
||||
directory: string
|
||||
}
|
||||
|
||||
const SUMMARIZE_CONTEXT_PROMPT = `[COMPACTION CONTEXT INJECTION]
|
||||
|
||||
When summarizing this session, you MUST include the following sections in your summary:
|
||||
|
||||
@@ -8,7 +8,7 @@ export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector";
|
||||
export { createDirectoryReadmeInjectorHook } from "./directory-readme-injector";
|
||||
export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector";
|
||||
export { createAnthropicContextWindowLimitRecoveryHook, type AnthropicContextWindowLimitRecoveryOptions } from "./anthropic-context-window-limit-recovery";
|
||||
export { createPreemptiveCompactionHook, type PreemptiveCompactionOptions, type SummarizeContext, type BeforeSummarizeCallback } from "./preemptive-compaction";
|
||||
|
||||
export { createCompactionContextInjector } from "./compaction-context-injector";
|
||||
export { createThinkModeHook } from "./think-mode";
|
||||
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export const DEFAULT_THRESHOLD = 0.85
|
||||
export const MIN_TOKENS_FOR_COMPACTION = 50_000
|
||||
export const COMPACTION_COOLDOWN_MS = 60_000
|
||||
@@ -1,265 +0,0 @@
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { ExperimentalConfig } from "../../config"
|
||||
import type { PreemptiveCompactionState, TokenInfo } from "./types"
|
||||
import {
|
||||
DEFAULT_THRESHOLD,
|
||||
MIN_TOKENS_FOR_COMPACTION,
|
||||
COMPACTION_COOLDOWN_MS,
|
||||
} from "./constants"
|
||||
import {
|
||||
findNearestMessageWithFields,
|
||||
MESSAGE_STORAGE,
|
||||
} from "../../features/hook-message-injector"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export interface SummarizeContext {
|
||||
sessionID: string
|
||||
providerID: string
|
||||
modelID: string
|
||||
usageRatio: number
|
||||
directory: string
|
||||
}
|
||||
|
||||
export type BeforeSummarizeCallback = (ctx: SummarizeContext) => Promise<void> | void
|
||||
|
||||
export type GetModelLimitCallback = (providerID: string, modelID: string) => number | undefined
|
||||
|
||||
export interface PreemptiveCompactionOptions {
|
||||
experimental?: ExperimentalConfig
|
||||
onBeforeSummarize?: BeforeSummarizeCallback
|
||||
getModelLimit?: GetModelLimitCallback
|
||||
}
|
||||
|
||||
interface MessageInfo {
|
||||
id: string
|
||||
role: string
|
||||
sessionID: string
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
tokens?: TokenInfo
|
||||
summary?: boolean
|
||||
finish?: boolean
|
||||
}
|
||||
|
||||
interface MessageWrapper {
|
||||
info: MessageInfo
|
||||
}
|
||||
|
||||
const CLAUDE_MODEL_PATTERN = /claude-(opus|sonnet|haiku)/i
|
||||
const CLAUDE_DEFAULT_CONTEXT_LIMIT =
|
||||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
|
||||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
|
||||
? 1_000_000
|
||||
: 200_000
|
||||
|
||||
function isSupportedModel(modelID: string): boolean {
|
||||
return CLAUDE_MODEL_PATTERN.test(modelID)
|
||||
}
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) return directPath
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) return sessionPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function createState(): PreemptiveCompactionState {
|
||||
return {
|
||||
lastCompactionTime: new Map(),
|
||||
compactionInProgress: new Set(),
|
||||
}
|
||||
}
|
||||
|
||||
export function createPreemptiveCompactionHook(
|
||||
ctx: PluginInput,
|
||||
options?: PreemptiveCompactionOptions
|
||||
) {
|
||||
const experimental = options?.experimental
|
||||
const onBeforeSummarize = options?.onBeforeSummarize
|
||||
const getModelLimit = options?.getModelLimit
|
||||
// Preemptive compaction is now enabled by default.
|
||||
// Backward compatibility: explicit false in experimental config disables the hook.
|
||||
const explicitlyDisabled = experimental?.preemptive_compaction === false
|
||||
const threshold = experimental?.preemptive_compaction_threshold ?? DEFAULT_THRESHOLD
|
||||
|
||||
if (explicitlyDisabled) {
|
||||
return { event: async () => {} }
|
||||
}
|
||||
|
||||
const state = createState()
|
||||
|
||||
const checkAndTriggerCompaction = async (
|
||||
sessionID: string,
|
||||
lastAssistant: MessageInfo
|
||||
): Promise<void> => {
|
||||
if (state.compactionInProgress.has(sessionID)) return
|
||||
|
||||
const lastCompaction = state.lastCompactionTime.get(sessionID) ?? 0
|
||||
if (Date.now() - lastCompaction < COMPACTION_COOLDOWN_MS) return
|
||||
|
||||
if (lastAssistant.summary === true) return
|
||||
|
||||
const tokens = lastAssistant.tokens
|
||||
if (!tokens) return
|
||||
|
||||
const modelID = lastAssistant.modelID ?? ""
|
||||
const providerID = lastAssistant.providerID ?? ""
|
||||
|
||||
if (!isSupportedModel(modelID)) {
|
||||
log("[preemptive-compaction] skipping unsupported model", { modelID })
|
||||
return
|
||||
}
|
||||
|
||||
const configLimit = getModelLimit?.(providerID, modelID)
|
||||
const contextLimit = configLimit ?? CLAUDE_DEFAULT_CONTEXT_LIMIT
|
||||
const totalUsed = tokens.input + tokens.cache.read + tokens.output
|
||||
|
||||
if (totalUsed < MIN_TOKENS_FOR_COMPACTION) return
|
||||
|
||||
const usageRatio = totalUsed / contextLimit
|
||||
|
||||
log("[preemptive-compaction] checking", {
|
||||
sessionID,
|
||||
totalUsed,
|
||||
contextLimit,
|
||||
usageRatio: usageRatio.toFixed(2),
|
||||
threshold,
|
||||
})
|
||||
|
||||
if (usageRatio < threshold) return
|
||||
|
||||
state.compactionInProgress.add(sessionID)
|
||||
state.lastCompactionTime.set(sessionID, Date.now())
|
||||
|
||||
if (!providerID || !modelID) {
|
||||
state.compactionInProgress.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Preemptive Compaction",
|
||||
message: `Context at ${(usageRatio * 100).toFixed(0)}% - compacting to prevent overflow...`,
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
log("[preemptive-compaction] triggering compaction", { sessionID, usageRatio })
|
||||
|
||||
try {
|
||||
if (onBeforeSummarize) {
|
||||
await onBeforeSummarize({
|
||||
sessionID,
|
||||
providerID,
|
||||
modelID,
|
||||
usageRatio,
|
||||
directory: ctx.directory,
|
||||
})
|
||||
}
|
||||
|
||||
const summarizeBody = { providerID, modelID, auto: true }
|
||||
await ctx.client.session.summarize({
|
||||
path: { id: sessionID },
|
||||
body: summarizeBody as never,
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Compaction Complete",
|
||||
message: "Session compacted successfully. Resuming...",
|
||||
variant: "success",
|
||||
duration: 2000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
state.compactionInProgress.delete(sessionID)
|
||||
return
|
||||
} catch (err) {
|
||||
log("[preemptive-compaction] compaction failed", { sessionID, error: err })
|
||||
} finally {
|
||||
state.compactionInProgress.delete(sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
state.lastCompactionTime.delete(sessionInfo.id)
|
||||
state.compactionInProgress.delete(sessionInfo.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as MessageInfo | undefined
|
||||
if (!info) return
|
||||
|
||||
if (info.role !== "assistant" || !info.finish) return
|
||||
|
||||
const sessionID = info.sessionID
|
||||
if (!sessionID) return
|
||||
|
||||
await checkAndTriggerCompaction(sessionID, info)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
try {
|
||||
const resp = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
|
||||
const messages = (resp.data ?? resp) as MessageWrapper[]
|
||||
const assistants = messages
|
||||
.filter((m) => m.info.role === "assistant")
|
||||
.map((m) => m.info)
|
||||
|
||||
if (assistants.length === 0) return
|
||||
|
||||
const lastAssistant = assistants[assistants.length - 1]
|
||||
|
||||
if (!lastAssistant.providerID || !lastAssistant.modelID) {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const storedMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
if (storedMessage?.model?.providerID && storedMessage?.model?.modelID) {
|
||||
lastAssistant.providerID = storedMessage.model.providerID
|
||||
lastAssistant.modelID = storedMessage.model.modelID
|
||||
log("[preemptive-compaction] using stored message model info", {
|
||||
sessionID,
|
||||
providerID: lastAssistant.providerID,
|
||||
modelID: lastAssistant.modelID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await checkAndTriggerCompaction(sessionID, lastAssistant)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventHandler,
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export interface PreemptiveCompactionState {
|
||||
lastCompactionTime: Map<string, number>
|
||||
compactionInProgress: Set<string>
|
||||
}
|
||||
|
||||
export interface TokenInfo {
|
||||
input: number
|
||||
output: number
|
||||
reasoning: number
|
||||
cache: { read: number; write: number }
|
||||
}
|
||||
|
||||
export interface ModelLimits {
|
||||
context: number
|
||||
output: number
|
||||
}
|
||||
12
src/index.ts
12
src/index.ts
@@ -12,7 +12,7 @@ import {
|
||||
createThinkModeHook,
|
||||
createClaudeCodeHooksHook,
|
||||
createAnthropicContextWindowLimitRecoveryHook,
|
||||
createPreemptiveCompactionHook,
|
||||
|
||||
createCompactionContextInjector,
|
||||
createRulesInjectorHook,
|
||||
createBackgroundNotificationHook,
|
||||
@@ -145,20 +145,11 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
)
|
||||
? createAnthropicContextWindowLimitRecoveryHook(ctx, {
|
||||
experimental: pluginConfig.experimental,
|
||||
dcpForCompaction: pluginConfig.experimental?.dcp_for_compaction,
|
||||
})
|
||||
: null;
|
||||
const compactionContextInjector = isHookEnabled("compaction-context-injector")
|
||||
? createCompactionContextInjector()
|
||||
: undefined;
|
||||
const preemptiveCompaction = isHookEnabled("preemptive-compaction")
|
||||
? createPreemptiveCompactionHook(ctx, {
|
||||
experimental: pluginConfig.experimental,
|
||||
onBeforeSummarize: compactionContextInjector,
|
||||
getModelLimit: (providerID, modelID) =>
|
||||
getModelLimit(modelCacheState, providerID, modelID),
|
||||
})
|
||||
: null;
|
||||
const rulesInjector = isHookEnabled("rules-injector")
|
||||
? createRulesInjectorHook(ctx)
|
||||
: null;
|
||||
@@ -420,7 +411,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
await rulesInjector?.event(input);
|
||||
await thinkMode?.event(input);
|
||||
await anthropicContextWindowLimitRecovery?.event(input);
|
||||
await preemptiveCompaction?.event(input);
|
||||
await agentUsageReminder?.event(input);
|
||||
await interactiveBashSession?.event(input);
|
||||
await ralphLoop?.event(input);
|
||||
|
||||
Reference in New Issue
Block a user