diff --git a/README.md b/README.md index c5484b5..e48a5e0 100644 --- a/README.md +++ b/README.md @@ -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 modified—DCP 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 @@ -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..179cb86 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 due to failed tool call]" 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/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") { 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)`, + ) + } +} 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",