From eab82f0a4a1e17b1696f87714915dbca71c601a7 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 25 Dec 2025 02:22:07 -0500 Subject: [PATCH 1/6] feat: add skill tool support to extractParameterKey --- lib/messages/utils.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index ca01bc7..f5a599a 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -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") { From a78151325e6b991dc3bc21749fbf70b524762dc2 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 25 Dec 2025 16:52:38 -0500 Subject: [PATCH 2/6] feat: add purge errors strategy to prune tool inputs after failed calls Introduces a new automatic strategy that removes potentially large tool inputs from errored tool calls after a configurable number of turns (default: 4). Error messages are preserved for context while reducing token usage. - Add PurgeErrors config interface with enabled, turns, and protectedTools - Implement purgeErrors strategy with turn-based pruning logic - Integrate into message transform pipeline - Update documentation with new strategy details --- README.md | 36 +++++++++----- lib/config.ts | 87 +++++++++++++++++++++++++++++++--- lib/hooks.ts | 3 +- lib/messages/prune.ts | 38 ++++++++++++++- lib/strategies/index.ts | 1 + lib/strategies/purge-errors.ts | 74 +++++++++++++++++++++++++++++ 6 files changed, 219 insertions(+), 20 deletions(-) create mode 100644 lib/strategies/purge-errors.ts diff --git a/README.md b/README.md index c5484b5..6835857 100644 --- a/README.md +++ b/README.md @@ -19,23 +19,31 @@ 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 GitHub 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 modified—DCP replaces pruned content with placeholders before sending requests to your LLM. ## Impact on Prompt Caching @@ -43,6 +51,8 @@ LLM providers like Anthropic and OpenAI cache prompts based on exact prefix matc **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. +**Best use case:** Providers that count usage in requests, such as Github Copilot and Google Antigravity have no negative price impact. + ## Configuration DCP uses its own config file: @@ -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, @@ -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 diff --git a/lib/config.ts b/lib/config.ts index 06a2d7a..15fe647 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -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 @@ -55,8 +61,9 @@ export interface PluginConfig { tools: Tools strategies: { deduplication: Deduplication - onIdle: OnIdle supersedeWrites: SupersedeWrites + purgeErrors: PurgeErrors + onIdle: OnIdle } } @@ -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", @@ -327,6 +339,40 @@ function validateConfigTypes(config: Record): 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 @@ -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], @@ -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, @@ -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, @@ -602,9 +674,6 @@ function mergeStrategies( ]), ], }, - supersedeWrites: { - enabled: override.supersedeWrites?.enabled ?? base.supersedeWrites.enabled, - }, } } @@ -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, - }, }, } } diff --git a/lib/hooks.ts b/lib/hooks.ts index be9e851..d3ec6d2 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -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" @@ -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) diff --git a/lib/messages/prune.ts b/lib/messages/prune.ts index 8baefed..151b398 100644 --- a/lib/messages/prune.ts +++ b/lib/messages/prune.ts @@ -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 - see error message for details]" const getNudgeString = (config: PluginConfig, isReasoningModel: boolean): string => { const discardEnabled = config.tools.discard.enabled @@ -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 => { @@ -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 @@ -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 } @@ -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 + } + } + } + } + } +} diff --git a/lib/strategies/index.ts b/lib/strategies/index.ts index 02d2f83..1d0659e 100644 --- a/lib/strategies/index.ts +++ b/lib/strategies/index.ts @@ -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" diff --git a/lib/strategies/purge-errors.ts b/lib/strategies/purge-errors.ts new file mode 100644 index 0000000..84d3aa8 --- /dev/null +++ b/lib/strategies/purge-errors.ts @@ -0,0 +1,74 @@ +import { PluginConfig } from "../config" +import { Logger } from "../logger" +import type { SessionState, WithParts } from "../state" +import { buildToolIdList } from "../messages/utils" +import { calculateTokensSaved } from "./utils" + +/** + * Purge Errors strategy - prunes tool inputs for tools that errored + * after they are older than a configurable number of turns. + * The error message is preserved, but the (potentially large) inputs + * are removed to save context. + * + * Modifies the session state in place to add pruned tool call IDs. + */ +export const purgeErrors = ( + state: SessionState, + logger: Logger, + config: PluginConfig, + messages: WithParts[], +): void => { + if (!config.strategies.purgeErrors.enabled) { + return + } + + // Build list of all tool call IDs from messages (chronological order) + const allToolIds = buildToolIdList(state, messages, logger) + if (allToolIds.length === 0) { + return + } + + // Filter out IDs already pruned + const alreadyPruned = new Set(state.prune.toolIds) + const unprunedIds = allToolIds.filter((id) => !alreadyPruned.has(id)) + + if (unprunedIds.length === 0) { + return + } + + const protectedTools = config.strategies.purgeErrors.protectedTools + const turnThreshold = config.strategies.purgeErrors.turns + + const newPruneIds: string[] = [] + + for (const id of unprunedIds) { + const metadata = state.toolParameters.get(id) + if (!metadata) { + continue + } + + // Skip protected tools + if (protectedTools.includes(metadata.tool)) { + continue + } + + // Only process error tools + if (metadata.status !== "error") { + continue + } + + // Check if the tool is old enough to prune + const turnAge = state.currentTurn - metadata.turn + if (turnAge >= turnThreshold) { + newPruneIds.push(id) + } + } + + if (newPruneIds.length > 0) { + state.stats.totalPruneTokens += calculateTokensSaved(state, messages, newPruneIds) + state.prune.toolIds.push(...newPruneIds) + logger.debug( + `Marked ${newPruneIds.length} error tool calls for pruning (older than ${turnThreshold} turns)`, + ) + } +} From f5eb533cc96b24e592d7f0ba97f3968f0dfcc4a8 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 25 Dec 2025 17:05:49 -0500 Subject: [PATCH 3/6] oauth readme tip --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6835857..b8975cc 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ 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 GitHub or other services), place this plugin last in your `plugin` array to avoid interfering with their authentication flows. +> **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. From beeac5263955af4fad638eb463c4030673cc5838 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 25 Dec 2025 17:07:37 -0500 Subject: [PATCH 4/6] chore: bump version to 1.1.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e232782..4943876 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tarquinen/opencode-dcp", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "1.1.0", + "version": "1.1.1", "license": "MIT", "dependencies": { "@ai-sdk/openai-compatible": "^1.0.28", diff --git a/package.json b/package.json index 8bc35d6..15e8d5c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@tarquinen/opencode-dcp", - "version": "1.1.0", + "version": "1.1.1", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js", From b73ceb2efd1ad2accdb3042ad3acf8687273fbd3 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 25 Dec 2025 17:09:47 -0500 Subject: [PATCH 5/6] readme update about context poisoning --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b8975cc..e48a5e0 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Your session history is never modified—DCP replaces pruned content with placeh 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. From a57cb6640c50cc353f5f80dacbd4baa0d9b88ce1 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 25 Dec 2025 17:11:47 -0500 Subject: [PATCH 6/6] chore: improve error pruning replacement text --- lib/messages/prune.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/messages/prune.ts b/lib/messages/prune.ts index 151b398..179cb86 100644 --- a/lib/messages/prune.ts +++ b/lib/messages/prune.ts @@ -14,7 +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 - see error message for details]" +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