From a99fb5b2b692c749f5ee47ee8da0faca1c8ee434 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 21 Jan 2026 12:53:47 -0500 Subject: [PATCH 1/6] Simplify default config to only include schema URL --- lib/config.ts | 64 +-------------------------------------------------- 1 file changed, 1 insertion(+), 63 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index 0bda681e..beabaa3f 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -531,69 +531,7 @@ function createDefaultConfig(): void { } const configContent = `{ - "$schema": "https://raw.githubusercontent.com/Opencode-DCP/opencode-dynamic-context-pruning/master/dcp.schema.json", - // Enable or disable the plugin - "enabled": true, - // Enable debug logging to ~/.config/opencode/logs/dcp/ - "debug": false, - // Notification display: "off", "minimal", or "detailed" - "pruneNotification": "detailed", - // Slash commands (/dcp) configuration - "commands": { - "enabled": true, - // Additional tools to protect from pruning via commands - "protectedTools": [] - }, - // Protect from pruning for message turns - "turnProtection": { - "enabled": false, - "turns": 4 - }, - // Protect file operations from pruning via glob patterns - // Patterns match tool parameters.filePath (e.g. read/write/edit) - "protectedFilePatterns": [], - // LLM-driven context pruning tools - "tools": { - // Shared settings for all prune tools - "settings": { - // Nudge the LLM to use prune tools (every tool results) - "nudgeEnabled": true, - "nudgeFrequency": 10, - // Additional tools to protect from pruning - "protectedTools": [] - }, - // Removes tool content from context without preservation (for completed tasks or noise) - "discard": { - "enabled": true - }, - // Distills key findings into preserved knowledge before removing raw content - "extract": { - "enabled": true, - // Show distillation content as an ignored message notification - "showDistillation": false - } - }, - // Automatic pruning strategies - "strategies": { - // Remove duplicate tool calls (same tool with same arguments) - "deduplication": { - "enabled": true, - // Additional tools to protect from pruning - "protectedTools": [] - }, - // Prune write tool inputs when the file has been subsequently read - "supersedeWrites": { - "enabled": false - }, - // 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": [] - } - } + "$schema": "https://raw.githubusercontent.com/Opencode-DCP/opencode-dynamic-context-pruning/master/dcp.schema.json" } ` writeFileSync(GLOBAL_CONFIG_PATH_JSONC, configContent, "utf-8") From b612110f8a859e983c972d7dd5681c869db29d1e Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 21 Jan 2026 12:56:38 -0500 Subject: [PATCH 2/6] readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c5d22457..4d39d2cd 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![npm version](https://img.shields.io/npm/v/@tarquinen/opencode-dcp.svg)](https://www.npmjs.com/package/@tarquinen/opencode-dcp) -Automatically reduces token usage in OpenCode by removing obsolete tool outputs from conversation history. +Automatically reduces token usage in OpenCode by removing obsolete tools from conversation history. ![DCP in action](dcp-demo5.png) From 6b34721eb5db632bc8b0d4d1b530152eba611c67 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 21 Jan 2026 20:29:39 -0500 Subject: [PATCH 3/6] fix: add defensive array checks for msg.parts iteration Prevent runtime errors when msg.parts is undefined or not an array by using Array.isArray() guard before iteration. --- lib/commands/context.ts | 3 ++- lib/commands/sweep.ts | 5 +++-- lib/messages/prune.ts | 9 ++++++--- lib/state/state.ts | 3 ++- lib/state/tool-cache.ts | 3 ++- lib/strategies/utils.ts | 3 ++- 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/lib/commands/context.ts b/lib/commands/context.ts index 9923c53d..bd2e8661 100644 --- a/lib/commands/context.ts +++ b/lib/commands/context.ts @@ -117,7 +117,8 @@ function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdo if (isMessageCompacted(state, msg)) continue if (msg.info.role === "user" && isIgnoredUserMessage(msg)) continue - for (const part of msg.parts) { + const parts = Array.isArray(msg.parts) ? msg.parts : [] + for (const part of parts) { if (part.type === "text" && msg.info.role === "user") { const textPart = part as TextPart const text = textPart.text || "" diff --git a/lib/commands/sweep.ts b/lib/commands/sweep.ts index 961976af..a241b684 100644 --- a/lib/commands/sweep.ts +++ b/lib/commands/sweep.ts @@ -52,8 +52,9 @@ function collectToolIdsAfterIndex( if (isMessageCompacted(state, msg)) { continue } - if (msg.parts) { - for (const part of msg.parts) { + const parts = Array.isArray(msg.parts) ? msg.parts : [] + if (parts.length > 0) { + for (const part of parts) { if (part.type === "tool" && part.callID && part.tool) { toolIds.push(part.callID) } diff --git a/lib/messages/prune.ts b/lib/messages/prune.ts index f224ce1c..fb86036e 100644 --- a/lib/messages/prune.ts +++ b/lib/messages/prune.ts @@ -25,7 +25,8 @@ const pruneToolOutputs = (state: SessionState, logger: Logger, messages: WithPar continue } - for (const part of msg.parts) { + const parts = Array.isArray(msg.parts) ? msg.parts : [] + for (const part of parts) { if (part.type !== "tool") { continue } @@ -50,7 +51,8 @@ const pruneToolInputs = (state: SessionState, logger: Logger, messages: WithPart continue } - for (const part of msg.parts) { + const parts = Array.isArray(msg.parts) ? msg.parts : [] + for (const part of parts) { if (part.type !== "tool") { continue } @@ -77,7 +79,8 @@ const pruneToolErrors = (state: SessionState, logger: Logger, messages: WithPart continue } - for (const part of msg.parts) { + const parts = Array.isArray(msg.parts) ? msg.parts : [] + for (const part of parts) { if (part.type !== "tool") { continue } diff --git a/lib/state/state.ts b/lib/state/state.ts index e68ecf89..69add020 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -131,7 +131,8 @@ export function countTurns(state: SessionState, messages: WithParts[]): number { if (isMessageCompacted(state, msg)) { continue } - for (const part of msg.parts) { + const parts = Array.isArray(msg.parts) ? msg.parts : [] + for (const part of parts) { if (part.type === "step-start") { turnCount++ } diff --git a/lib/state/tool-cache.ts b/lib/state/tool-cache.ts index 057bcf10..38d3b54b 100644 --- a/lib/state/tool-cache.ts +++ b/lib/state/tool-cache.ts @@ -25,7 +25,8 @@ export async function syncToolCache( continue } - for (const part of msg.parts) { + const parts = Array.isArray(msg.parts) ? msg.parts : [] + for (const part of parts) { if (part.type === "step-start") { turnCounter++ continue diff --git a/lib/strategies/utils.ts b/lib/strategies/utils.ts index 43bfdf7a..7ae04154 100644 --- a/lib/strategies/utils.ts +++ b/lib/strategies/utils.ts @@ -57,7 +57,8 @@ export const calculateTokensSaved = ( if (isMessageCompacted(state, msg)) { continue } - for (const part of msg.parts) { + const parts = Array.isArray(msg.parts) ? msg.parts : [] + for (const part of parts) { if (part.type !== "tool" || !pruneToolIds.includes(part.callID)) { continue } From e8ca80f007ebbcd8a2970e03d30bd49c82be6de9 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 21 Jan 2026 20:29:44 -0500 Subject: [PATCH 4/6] feat: inject context as user message when last message is user - Add createSyntheticUserMessage helper function - Update injection logic to create user message when appropriate instead of skipping injection entirely - Update prompt wording: 'assistant turn' -> 'turn', 'assistant message' -> 'synthetic message' - Make discard tool spec guidance tool-agnostic --- lib/messages/inject.ts | 24 +++++++++++-------- lib/messages/utils.ts | 40 ++++++++++++++++++++++++++++---- lib/prompts/discard-tool-spec.ts | 2 +- lib/prompts/system/both.ts | 4 ++-- lib/prompts/system/discard.ts | 4 ++-- lib/prompts/system/extract.ts | 4 ++-- 6 files changed, 57 insertions(+), 21 deletions(-) diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index c4218260..3dedaf68 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -7,6 +7,7 @@ import { extractParameterKey, buildToolIdList, createSyntheticAssistantMessage, + createSyntheticUserMessage, isIgnoredUserMessage, } from "./utils" import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" @@ -138,16 +139,19 @@ export const insertPruneToolContext = ( return } - // Never inject immediately following a user message - wait until assistant has started its turn - // This avoids interfering with model reasoning/thinking phases - // TODO: This can be skipped if there is a good way to check if the model has reasoning, - // can't find a good way to do this yet - const lastMessage = messages[messages.length - 1] - if (lastMessage?.info?.role === "user" && !isIgnoredUserMessage(lastMessage)) { - return - } - const userInfo = lastUserMessage.info as UserMessage const variant = state.variant ?? userInfo.variant - messages.push(createSyntheticAssistantMessage(lastUserMessage, prunableToolsContent, variant)) + + const lastMessage = messages[messages.length - 1] + const isLastMessageUser = + lastMessage?.info?.role === "user" && !isIgnoredUserMessage(lastMessage) + + if (isLastMessageUser) { + logger.debug("Injecting prunable tools list as user message") + messages.push(createSyntheticUserMessage(lastUserMessage, prunableToolsContent, variant)) + } else { + messages.push( + createSyntheticAssistantMessage(lastUserMessage, prunableToolsContent, variant), + ) + } } diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index 219027c6..48ae0e6c 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -12,6 +12,36 @@ const isGeminiModel = (modelID: string): boolean => { return lowerModelID.includes("gemini") } +export const createSyntheticUserMessage = ( + baseMessage: WithParts, + content: string, + variant?: string, +): WithParts => { + const userInfo = baseMessage.info as UserMessage + const now = Date.now() + + return { + info: { + id: SYNTHETIC_MESSAGE_ID, + sessionID: userInfo.sessionID, + role: "user" as const, + agent: userInfo.agent || "code", + model: userInfo.model, + time: { created: now }, + ...(variant !== undefined && { variant }), + }, + parts: [ + { + id: SYNTHETIC_PART_ID, + sessionID: userInfo.sessionID, + messageID: SYNTHETIC_MESSAGE_ID, + type: "text", + text: content, + }, + ], + } +} + export const createSyntheticAssistantMessage = ( baseMessage: WithParts, content: string, @@ -197,8 +227,9 @@ export function buildToolIdList( if (isMessageCompacted(state, msg)) { continue } - if (msg.parts) { - for (const part of msg.parts) { + const parts = Array.isArray(msg.parts) ? msg.parts : [] + if (parts.length > 0) { + for (const part of parts) { if (part.type === "tool" && part.callID && part.tool) { toolIds.push(part.callID) } @@ -209,11 +240,12 @@ export function buildToolIdList( } export const isIgnoredUserMessage = (message: WithParts): boolean => { - if (!message.parts || message.parts.length === 0) { + const parts = Array.isArray(message.parts) ? message.parts : [] + if (parts.length === 0) { return true } - for (const part of message.parts) { + for (const part of parts) { if (!(part as any).ignored) { return false } diff --git a/lib/prompts/discard-tool-spec.ts b/lib/prompts/discard-tool-spec.ts index dcd21358..e5084212 100644 --- a/lib/prompts/discard-tool-spec.ts +++ b/lib/prompts/discard-tool-spec.ts @@ -12,7 +12,7 @@ Use \`discard\` for removing tool content that is no longer needed ## When NOT to Use This Tool -- **If the output contains useful information:** Use \`extract\` instead to preserve key findings. +- **If the output contains useful information:** Keep it in context rather than discarding. - **If you'll need the output later:** Don't discard files you plan to edit or context you'll need for implementation. ## Best Practices diff --git a/lib/prompts/system/both.ts b/lib/prompts/system/both.ts index f5551aaa..9c53a748 100644 --- a/lib/prompts/system/both.ts +++ b/lib/prompts/system/both.ts @@ -2,7 +2,7 @@ export const SYSTEM_PROMPT_BOTH = ` ENVIRONMENT -You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` and \`extract\` tools. The environment calls the \`context_info\` tool to provide an up-to-date list after each assistant turn. Use this information when deciding what to prune. +You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` and \`extract\` tools. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to prune. IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. @@ -44,7 +44,7 @@ There may be tools in session context that do not appear in the -After each assistant turn, the environment calls the \`context_info\` tool to inject an assistant message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. +After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: - NEVER reference the prune encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the prune encouragement appears. diff --git a/lib/prompts/system/discard.ts b/lib/prompts/system/discard.ts index 1bf661fc..e5cd77da 100644 --- a/lib/prompts/system/discard.ts +++ b/lib/prompts/system/discard.ts @@ -2,7 +2,7 @@ export const SYSTEM_PROMPT_DISCARD = ` ENVIRONMENT -You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` tool. The environment calls the \`context_info\` tool to provide an up-to-date list after each assistant turn. Use this information when deciding what to discard. +You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` tool. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to discard. IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. @@ -35,7 +35,7 @@ There may be tools in session context that do not appear in the -After each assistant turn, the environment calls the \`context_info\` tool to inject an assistant message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. +After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: - NEVER reference the discard encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the discard encouragement appears. diff --git a/lib/prompts/system/extract.ts b/lib/prompts/system/extract.ts index 859f36dd..3f225e1e 100644 --- a/lib/prompts/system/extract.ts +++ b/lib/prompts/system/extract.ts @@ -2,7 +2,7 @@ export const SYSTEM_PROMPT_EXTRACT = ` ENVIRONMENT -You are operating in a context-constrained environment and thus must proactively manage your context window using the \`extract\` tool. The environment calls the \`context_info\` tool to provide an up-to-date list after each assistant turn. Use this information when deciding what to extract. +You are operating in a context-constrained environment and thus must proactively manage your context window using the \`extract\` tool. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to extract. IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. @@ -35,7 +35,7 @@ There may be tools in session context that do not appear in the -After each assistant turn, the environment calls the \`context_info\` tool to inject an assistant message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. +After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: - NEVER reference the extract encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the extract encouragement appears. From d1f3f43616673ef37d9aee6339109d16023cf484 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 21 Jan 2026 20:31:27 -0500 Subject: [PATCH 5/6] cleanup --- lib/messages/inject.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 3dedaf68..5920566a 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -147,7 +147,6 @@ export const insertPruneToolContext = ( lastMessage?.info?.role === "user" && !isIgnoredUserMessage(lastMessage) if (isLastMessageUser) { - logger.debug("Injecting prunable tools list as user message") messages.push(createSyntheticUserMessage(lastUserMessage, prunableToolsContent, variant)) } else { messages.push( From f5bb89c8e55502fb6c4726a592b7a7f60d1e0566 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 22 Jan 2026 00:04:55 -0500 Subject: [PATCH 6/6] feat: show extracted token count in prune notifications Add token count display for extractions in both minimal and detailed notification formats. The extracted token count always appears when distillation is present, while the full distillation text remains controlled by the showDistillation config setting. --- lib/ui/notification.ts | 36 ++++++++++++++++++++++++------------ lib/ui/utils.ts | 6 ++++++ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index 81bba142..acb948cd 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -1,6 +1,7 @@ import type { Logger } from "../logger" import type { SessionState } from "../state" import { + countDistillationTokens, formatExtracted, formatPrunedItemsList, formatStatsHeader, @@ -19,14 +20,19 @@ export const PRUNE_REASON_LABELS: Record = { function buildMinimalMessage( state: SessionState, reason: PruneReason | undefined, - distillation?: string[], + distillation: string[] | undefined, + showDistillation: boolean, ): string { - const reasonSuffix = reason ? ` — ${PRUNE_REASON_LABELS[reason]}` : "" + const extractedTokens = countDistillationTokens(distillation) + const extractedSuffix = + extractedTokens > 0 ? ` (extracted ${formatTokenCount(extractedTokens)})` : "" + const reasonSuffix = reason && extractedTokens === 0 ? ` — ${PRUNE_REASON_LABELS[reason]}` : "" let message = formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter) + - reasonSuffix + reasonSuffix + + extractedSuffix - return message + formatExtracted(distillation) + return message + formatExtracted(showDistillation ? distillation : undefined) } function buildDetailedMessage( @@ -34,21 +40,26 @@ function buildDetailedMessage( reason: PruneReason | undefined, pruneToolIds: string[], toolMetadata: Map, - workingDirectory?: string, - distillation?: string[], + workingDirectory: string, + distillation: string[] | undefined, + showDistillation: boolean, ): string { let message = formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter) if (pruneToolIds.length > 0) { const pruneTokenCounterStr = `~${formatTokenCount(state.stats.pruneTokenCounter)}` - const reasonLabel = reason ? ` — ${PRUNE_REASON_LABELS[reason]}` : "" - message += `\n\n▣ Pruning (${pruneTokenCounterStr})${reasonLabel}` + const extractedTokens = countDistillationTokens(distillation) + const extractedSuffix = + extractedTokens > 0 ? `, extracted ${formatTokenCount(extractedTokens)}` : "" + const reasonLabel = + reason && extractedTokens === 0 ? ` — ${PRUNE_REASON_LABELS[reason]}` : "" + message += `\n\n▣ Pruning (${pruneTokenCounterStr}${extractedSuffix})${reasonLabel}` const itemLines = formatPrunedItemsList(pruneToolIds, toolMetadata, workingDirectory) message += "\n" + itemLines.join("\n") } - return (message + formatExtracted(distillation)).trim() + return (message + formatExtracted(showDistillation ? distillation : undefined)).trim() } export async function sendUnifiedNotification( @@ -73,18 +84,19 @@ export async function sendUnifiedNotification( return false } - const showExtraction = config.tools.extract.showDistillation ? distillation : undefined + const showDistillation = config.tools.extract.showDistillation const message = config.pruneNotification === "minimal" - ? buildMinimalMessage(state, reason, showExtraction) + ? buildMinimalMessage(state, reason, distillation, showDistillation) : buildDetailedMessage( state, reason, pruneToolIds, toolMetadata, workingDirectory, - showExtraction, + distillation, + showDistillation, ) await sendIgnoredMessage(client, sessionId, message, params, logger) diff --git a/lib/ui/utils.ts b/lib/ui/utils.ts index 9bee8d5b..b1e00ed9 100644 --- a/lib/ui/utils.ts +++ b/lib/ui/utils.ts @@ -1,5 +1,11 @@ import { ToolParameterEntry } from "../state" import { extractParameterKey } from "../messages/utils" +import { countTokens } from "../strategies/utils" + +export function countDistillationTokens(distillation?: string[]): number { + if (!distillation || distillation.length === 0) return 0 + return countTokens(distillation.join("\n")) +} export function formatExtracted(distillation?: string[]): string { if (!distillation || distillation.length === 0) {