Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 26 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,39 @@ Add to your OpenCode config:

Using `@latest` ensures you always get the newest version automatically when OpenCode starts.

> **Note:** If you use OAuth plugins (e.g., for Google or other services), place this plugin last in your `plugin` array to avoid interfering with their authentication flows.

Restart OpenCode. The plugin will automatically start optimizing your sessions.

## How Pruning Works

DCP uses multiple strategies to reduce context size:
DCP uses multiple tools and strategies to reduce context size:

### Tools

**Discard** — Exposes a `discard` tool that the AI can call to remove completed or noisy tool content from context.

**Extract** — Exposes an `extract` tool that the AI can call to distill valuable context into concise summaries before removing the tool content.

### Strategies

**Deduplication** — Identifies repeated tool calls (e.g., reading the same file multiple times) and keeps only the most recent output. Runs automatically on every request with zero LLM cost.

**Supersede Writes** — Prunes write tool inputs for files that have subsequently been read. When a file is written and later read, the original write content becomes redundant since the current file state is captured in the read result. Runs automatically on every request with zero LLM cost.

**Discard Tool** — Exposes a `discard` tool that the AI can call to remove completed or noisy tool outputs from context. Use this for task completion cleanup and removing irrelevant outputs.

**Extract Tool** — Exposes an `extract` tool that the AI can call to distill valuable context into concise summaries before removing the raw outputs. Use this when you need to preserve key findings while reducing context size.
**Purge Errors** — Prunes tool inputs for tools that returned errors after a configurable number of turns (default: 4). Error messages are preserved for context, but the potentially large input content is removed. Runs automatically on every request with zero LLM cost.

**On Idle Analysis** — Uses a language model to semantically analyze conversation context during idle periods and identify tool outputs that are no longer relevant.
**On Idle Analysis** — Uses a language model to semantically analyze conversation context during idle periods and identify tool outputs that are no longer relevant. Disabled by default (legacy behavior).

Your session history is never modified. DCP replaces pruned outputs with a placeholder before sending requests to your LLM.
Your session history is never modifiedDCP replaces pruned content with placeholders before sending requests to your LLM.

## Impact on Prompt Caching

LLM providers like Anthropic and OpenAI cache prompts based on exact prefix matching. When DCP prunes a tool output, it changes the message content, which invalidates cached prefixes from that point forward.

**Trade-off:** You lose some cache read benefits but gain larger token savings from reduced context size. In most cases, the token savings outweigh the cache miss cost—especially in long sessions where context bloat becomes significant.
**Trade-off:** You lose some cache read benefits but gain larger token savings from reduced context size and performance improvements through reduced context poisoning. In most cases, the token savings outweigh the cache miss cost—especially in long sessions where context bloat becomes significant.

**Best use case:** Providers that count usage in requests, such as Github Copilot and Google Antigravity have no negative price impact.

## Configuration

Expand Down Expand Up @@ -100,6 +110,14 @@ DCP uses its own config file:
"supersedeWrites": {
"enabled": true,
},
// Prune tool inputs for errored tools after X turns
"purgeErrors": {
"enabled": true,
// Number of turns before errored tool inputs are pruned
"turns": 4,
// Additional tools to protect from pruning
"protectedTools": [],
},
// (Legacy) Run an LLM to analyze what tool calls are no longer relevant on idle
"onIdle": {
"enabled": false,
Expand Down Expand Up @@ -127,11 +145,7 @@ When enabled, turn protection prevents tool outputs from being pruned for a conf
By default, these tools are always protected from pruning across all strategies:
`task`, `todowrite`, `todoread`, `discard`, `extract`, `batch`

The `protectedTools` arrays in each section add to this default list:

- `tools.settings.protectedTools` — Protects tools from the `discard` and `extract` tools
- `strategies.deduplication.protectedTools` — Protects tools from deduplication
- `strategies.onIdle.protectedTools` — Protects tools from on-idle analysis
The `protectedTools` arrays in each section add to this default list.

### Config Precedence

Expand Down
87 changes: 80 additions & 7 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ export interface SupersedeWrites {
enabled: boolean
}

export interface PurgeErrors {
enabled: boolean
turns: number
protectedTools: string[]
}

export interface TurnProtection {
enabled: boolean
turns: number
Expand All @@ -55,8 +61,9 @@ export interface PluginConfig {
tools: Tools
strategies: {
deduplication: Deduplication
onIdle: OnIdle
supersedeWrites: SupersedeWrites
purgeErrors: PurgeErrors
onIdle: OnIdle
}
}

Expand Down Expand Up @@ -90,6 +97,11 @@ export const VALID_CONFIG_KEYS = new Set([
// strategies.supersedeWrites
"strategies.supersedeWrites",
"strategies.supersedeWrites.enabled",
// strategies.purgeErrors
"strategies.purgeErrors",
"strategies.purgeErrors.enabled",
"strategies.purgeErrors.turns",
"strategies.purgeErrors.protectedTools",
// strategies.onIdle
"strategies.onIdle",
"strategies.onIdle.enabled",
Expand Down Expand Up @@ -327,6 +339,40 @@ function validateConfigTypes(config: Record<string, any>): ValidationError[] {
})
}
}

// purgeErrors
if (strategies.purgeErrors) {
if (
strategies.purgeErrors.enabled !== undefined &&
typeof strategies.purgeErrors.enabled !== "boolean"
) {
errors.push({
key: "strategies.purgeErrors.enabled",
expected: "boolean",
actual: typeof strategies.purgeErrors.enabled,
})
}
if (
strategies.purgeErrors.turns !== undefined &&
typeof strategies.purgeErrors.turns !== "number"
) {
errors.push({
key: "strategies.purgeErrors.turns",
expected: "number",
actual: typeof strategies.purgeErrors.turns,
})
}
if (
strategies.purgeErrors.protectedTools !== undefined &&
!Array.isArray(strategies.purgeErrors.protectedTools)
) {
errors.push({
key: "strategies.purgeErrors.protectedTools",
expected: "string[]",
actual: typeof strategies.purgeErrors.protectedTools,
})
}
}
}

return errors
Expand Down Expand Up @@ -408,6 +454,11 @@ const defaultConfig: PluginConfig = {
supersedeWrites: {
enabled: true,
},
purgeErrors: {
enabled: true,
turns: 4,
protectedTools: [...DEFAULT_PROTECTED_TOOLS],
},
onIdle: {
enabled: false,
protectedTools: [...DEFAULT_PROTECTED_TOOLS],
Expand Down Expand Up @@ -529,6 +580,14 @@ function createDefaultConfig(): void {
"supersedeWrites": {
"enabled": true
},
// Prune tool inputs for errored tools after X turns
"purgeErrors": {
"enabled": true,
// Number of turns before errored tool inputs are pruned
"turns": 4,
// Additional tools to protect from pruning
"protectedTools": []
},
// (Legacy) Run an LLM to analyze what tool calls are no longer relevant on idle
"onIdle": {
"enabled": false,
Expand Down Expand Up @@ -588,6 +647,19 @@ function mergeStrategies(
]),
],
},
supersedeWrites: {
enabled: override.supersedeWrites?.enabled ?? base.supersedeWrites.enabled,
},
purgeErrors: {
enabled: override.purgeErrors?.enabled ?? base.purgeErrors.enabled,
turns: override.purgeErrors?.turns ?? base.purgeErrors.turns,
protectedTools: [
...new Set([
...base.purgeErrors.protectedTools,
...(override.purgeErrors?.protectedTools ?? []),
]),
],
},
onIdle: {
enabled: override.onIdle?.enabled ?? base.onIdle.enabled,
model: override.onIdle?.model ?? base.onIdle.model,
Expand All @@ -602,9 +674,6 @@ function mergeStrategies(
]),
],
},
supersedeWrites: {
enabled: override.supersedeWrites?.enabled ?? base.supersedeWrites.enabled,
},
}
}

Expand Down Expand Up @@ -652,13 +721,17 @@ function deepCloneConfig(config: PluginConfig): PluginConfig {
...config.strategies.deduplication,
protectedTools: [...config.strategies.deduplication.protectedTools],
},
supersedeWrites: {
...config.strategies.supersedeWrites,
},
purgeErrors: {
...config.strategies.purgeErrors,
protectedTools: [...config.strategies.purgeErrors.protectedTools],
},
onIdle: {
...config.strategies.onIdle,
protectedTools: [...config.strategies.onIdle.protectedTools],
},
supersedeWrites: {
...config.strategies.supersedeWrites,
},
},
}
}
Expand Down
3 changes: 2 additions & 1 deletion lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { SessionState, WithParts } from "./state"
import type { Logger } from "./logger"
import type { PluginConfig } from "./config"
import { syncToolCache } from "./state/tool-cache"
import { deduplicate, supersedeWrites } from "./strategies"
import { deduplicate, supersedeWrites, purgeErrors } from "./strategies"
import { prune, insertPruneToolContext } from "./messages"
import { checkSession } from "./state"
import { runOnIdle } from "./strategies/on-idle"
Expand All @@ -24,6 +24,7 @@ export function createChatMessageTransformHandler(

deduplicate(state, logger, config, output.messages)
supersedeWrites(state, logger, config, output.messages)
purgeErrors(state, logger, config, output.messages)

prune(state, logger, config, output.messages)

Expand Down
38 changes: 37 additions & 1 deletion lib/messages/prune.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const PRUNED_TOOL_INPUT_REPLACEMENT =
"[content removed to save context, this is not what was written to the file, but a placeholder]"
const PRUNED_TOOL_OUTPUT_REPLACEMENT =
"[Output removed to save context - information superseded or no longer needed]"
const PRUNED_TOOL_ERROR_INPUT_REPLACEMENT = "[input removed due to failed tool call]"

const getNudgeString = (config: PluginConfig, isReasoningModel: boolean): string => {
const discardEnabled = config.tools.discard.enabled
Expand Down Expand Up @@ -164,6 +165,7 @@ export const prune = (
): void => {
pruneToolOutputs(state, logger, messages)
pruneToolInputs(state, logger, messages)
pruneToolErrors(state, logger, messages)
}

const pruneToolOutputs = (state: SessionState, logger: Logger, messages: WithParts[]): void => {
Expand Down Expand Up @@ -191,6 +193,10 @@ const pruneToolOutputs = (state: SessionState, logger: Logger, messages: WithPar

const pruneToolInputs = (state: SessionState, logger: Logger, messages: WithParts[]): void => {
for (const msg of messages) {
if (isMessageCompacted(state, msg)) {
continue
}

for (const part of msg.parts) {
if (part.type !== "tool") {
continue
Expand All @@ -201,7 +207,7 @@ const pruneToolInputs = (state: SessionState, logger: Logger, messages: WithPart
if (part.tool !== "write" && part.tool !== "edit") {
continue
}
if (part.state.status === "pending" || part.state.status === "running") {
if (part.state.status !== "completed") {
continue
}

Expand All @@ -219,3 +225,33 @@ const pruneToolInputs = (state: SessionState, logger: Logger, messages: WithPart
}
}
}

const pruneToolErrors = (state: SessionState, logger: Logger, messages: WithParts[]): void => {
for (const msg of messages) {
if (isMessageCompacted(state, msg)) {
continue
}

for (const part of msg.parts) {
if (part.type !== "tool") {
continue
}
if (!state.prune.toolIds.includes(part.callID)) {
continue
}
if (part.state.status !== "error") {
continue
}

// Prune all string inputs for errored tools
const input = part.state.input
if (input && typeof input === "object") {
for (const key of Object.keys(input)) {
if (typeof input[key] === "string") {
input[key] = PRUNED_TOOL_ERROR_INPUT_REPLACEMENT
}
}
}
}
}
}
3 changes: 3 additions & 0 deletions lib/messages/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ export const extractParameterKey = (tool: string, parameters: any): string => {
if (tool === "task" && parameters.description) {
return parameters.description
}
if (tool === "skill" && parameters.name) {
return parameters.name
}

const paramStr = JSON.stringify(parameters)
if (paramStr === "{}" || paramStr === "[]" || paramStr === "null") {
Expand Down
1 change: 1 addition & 0 deletions lib/strategies/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { deduplicate } from "./deduplication"
export { runOnIdle } from "./on-idle"
export { createDiscardTool, createExtractTool } from "./tools"
export { supersedeWrites } from "./supersede-writes"
export { purgeErrors } from "./purge-errors"
Loading