From e8faac578f4f26d0b188cebc1647ba8c5120dea2 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 12 Jan 2026 23:55:26 -0500 Subject: [PATCH 01/10] feat: add question tool support for pruning - Add display formatting in extractParameterKey() to show question headers - Prune verbose input.questions array while preserving output with user answers - Update token counting to measure input.questions (what actually gets pruned) - Remove dead write/edit code from calculateTokensSaved (now protected by default) --- lib/messages/prune.ts | 25 +++++-------------------- lib/messages/utils.ts | 20 ++++++++++++++++++++ lib/strategies/utils.ts | 26 ++++++-------------------- 3 files changed, 31 insertions(+), 40 deletions(-) diff --git a/lib/messages/prune.ts b/lib/messages/prune.ts index eb8aae6..8b95e45 100644 --- a/lib/messages/prune.ts +++ b/lib/messages/prune.ts @@ -3,11 +3,10 @@ import type { Logger } from "../logger" import type { PluginConfig } from "../config" import { isMessageCompacted } from "../shared-utils" -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 PRUNED_QUESTION_INPUT_REPLACEMENT = "[questions removed - see output for user's answers]" export const prune = ( state: SessionState, @@ -33,7 +32,8 @@ const pruneToolOutputs = (state: SessionState, logger: Logger, messages: WithPar if (!state.prune.toolIds.includes(part.callID)) { continue } - if (part.tool === "write" || part.tool === "edit") { + // Skip write/edit (protected) and question (output contains answers we want to keep) + if (part.tool === "write" || part.tool === "edit" || part.tool === "question") { continue } if (part.state.status === "completed") { @@ -43,10 +43,6 @@ const pruneToolOutputs = (state: SessionState, logger: Logger, messages: WithPar } } -// NOTE: This function is currently unused because "write" and "edit" are protected by default. -// Some models incorrectly use PRUNED_TOOL_INPUT_REPLACEMENT in their output when they see it in context. -// See: https://github.com/Opencode-DCP/opencode-dynamic-context-pruning/issues/215 -// Keeping this function in case the bug is resolved in the future. const pruneToolInputs = (state: SessionState, logger: Logger, messages: WithParts[]): void => { for (const msg of messages) { if (isMessageCompacted(state, msg)) { @@ -60,23 +56,12 @@ const pruneToolInputs = (state: SessionState, logger: Logger, messages: WithPart if (!state.prune.toolIds.includes(part.callID)) { continue } - if (part.tool !== "write" && part.tool !== "edit") { - continue - } if (part.state.status !== "completed") { continue } - if (part.tool === "write" && part.state.input?.content !== undefined) { - part.state.input.content = PRUNED_TOOL_INPUT_REPLACEMENT - } - if (part.tool === "edit") { - if (part.state.input?.oldString !== undefined) { - part.state.input.oldString = PRUNED_TOOL_INPUT_REPLACEMENT - } - if (part.state.input?.newString !== undefined) { - part.state.input.newString = PRUNED_TOOL_INPUT_REPLACEMENT - } + if (part.tool === "question" && part.state.input?.questions !== undefined) { + part.state.input.questions = PRUNED_QUESTION_INPUT_REPLACEMENT } } } diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index 9a9a2d8..3e8006d 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -177,6 +177,26 @@ export const extractParameterKey = (tool: string, parameters: any): string => { return op } + if (tool === "question") { + const questions = parameters.questions + if (Array.isArray(questions) && questions.length > 0) { + const headers = questions + .map((q: any) => q.header || "") + .filter(Boolean) + .slice(0, 3) + + const count = questions.length + const plural = count > 1 ? "s" : "" + + if (headers.length > 0) { + const suffix = count > 3 ? ` (+${count - 3} more)` : "" + return `${count} question${plural}: ${headers.join(", ")}${suffix}` + } + return `${count} question${plural}` + } + return "question" + } + const paramStr = JSON.stringify(parameters) if (paramStr === "{}" || paramStr === "[]" || paramStr === "null") { return "" diff --git a/lib/strategies/utils.ts b/lib/strategies/utils.ts index c75081f..fa7d5fe 100644 --- a/lib/strategies/utils.ts +++ b/lib/strategies/utils.ts @@ -62,29 +62,15 @@ export const calculateTokensSaved = ( if (part.type !== "tool" || !pruneToolIds.includes(part.callID)) { continue } - // For write and edit tools, count input content as that is all we prune for these tools - // (input is present in both completed and error states) - if (part.tool === "write") { - const inputContent = part.state.input?.content - const content = - typeof inputContent === "string" - ? inputContent - : JSON.stringify(inputContent ?? "") - contents.push(content) - continue - } - if (part.tool === "edit") { - const oldString = part.state.input?.oldString - const newString = part.state.input?.newString - if (typeof oldString === "string") { - contents.push(oldString) - } - if (typeof newString === "string") { - contents.push(newString) + if (part.tool === "question") { + const questions = part.state.input?.questions + if (questions !== undefined) { + const content = + typeof questions === "string" ? questions : JSON.stringify(questions) + contents.push(content) } continue } - // For other tools, count output or error based on status if (part.state.status === "completed") { const content = typeof part.state.output === "string" From c06dd7f37bb200c33a9475347bebf8acccd07cf2 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 13 Jan 2026 01:00:11 -0500 Subject: [PATCH 02/10] fix: skip system prompt injection for subagent sessions --- index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/index.ts b/index.ts index 2372d2c..dfc14d5 100644 --- a/index.ts +++ b/index.ts @@ -15,7 +15,7 @@ const plugin: Plugin = (async (ctx) => { // Suppress AI SDK warnings if (typeof globalThis !== "undefined") { - ;(globalThis as any).AI_SDK_LOG_WARNINGS = false + ; (globalThis as any).AI_SDK_LOG_WARNINGS = false } const logger = new Logger(config.debug) @@ -30,6 +30,10 @@ const plugin: Plugin = (async (ctx) => { _input: unknown, output: { system: string[] }, ) => { + if (state.isSubAgent) { + return + } + const systemText = output.system.join("\n") const internalAgentSignatures = [ "You are a title generator", From 4a186925ecaa0e9b6b74ecde492d1de717fcf66c Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 13 Jan 2026 01:02:00 -0500 Subject: [PATCH 03/10] style: fix formatting --- index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.ts b/index.ts index dfc14d5..60e1c8b 100644 --- a/index.ts +++ b/index.ts @@ -15,7 +15,7 @@ const plugin: Plugin = (async (ctx) => { // Suppress AI SDK warnings if (typeof globalThis !== "undefined") { - ; (globalThis as any).AI_SDK_LOG_WARNINGS = false + ;(globalThis as any).AI_SDK_LOG_WARNINGS = false } const logger = new Logger(config.debug) From 0462d2f9c269e90cefcbfd0711cd2bf9d86806c8 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 13 Jan 2026 01:18:09 -0500 Subject: [PATCH 04/10] refactor: extract system prompt handler to hooks.ts --- index.ts | 40 ++-------------------------------------- lib/hooks.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 38 deletions(-) diff --git a/index.ts b/index.ts index 60e1c8b..a145e24 100644 --- a/index.ts +++ b/index.ts @@ -1,10 +1,9 @@ import type { Plugin } from "@opencode-ai/plugin" import { getConfig } from "./lib/config" import { Logger } from "./lib/logger" -import { loadPrompt } from "./lib/prompts" import { createSessionState } from "./lib/state" import { createDiscardTool, createExtractTool } from "./lib/strategies" -import { createChatMessageTransformHandler } from "./lib/hooks" +import { createChatMessageTransformHandler, createSystemPromptHandler } from "./lib/hooks" const plugin: Plugin = (async (ctx) => { const config = getConfig(ctx) @@ -26,42 +25,7 @@ const plugin: Plugin = (async (ctx) => { }) return { - "experimental.chat.system.transform": async ( - _input: unknown, - output: { system: string[] }, - ) => { - if (state.isSubAgent) { - return - } - - const systemText = output.system.join("\n") - const internalAgentSignatures = [ - "You are a title generator", - "You are a helpful AI assistant tasked with summarizing conversations", - "Summarize what was done in this conversation", - ] - if (internalAgentSignatures.some((sig) => systemText.includes(sig))) { - logger.info("Skipping DCP system prompt injection for internal agent") - return - } - - const discardEnabled = config.tools.discard.enabled - const extractEnabled = config.tools.extract.enabled - - let promptName: string - if (discardEnabled && extractEnabled) { - promptName = "user/system/system-prompt-both" - } else if (discardEnabled) { - promptName = "user/system/system-prompt-discard" - } else if (extractEnabled) { - promptName = "user/system/system-prompt-extract" - } else { - return - } - - const syntheticPrompt = loadPrompt(promptName) - output.system.push(syntheticPrompt) - }, + "experimental.chat.system.transform": createSystemPromptHandler(state, logger, config), "experimental.chat.messages.transform": createChatMessageTransformHandler( ctx.client, state, diff --git a/lib/hooks.ts b/lib/hooks.ts index e70c892..e1583a5 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -5,6 +5,48 @@ import { syncToolCache } from "./state/tool-cache" import { deduplicate, supersedeWrites, purgeErrors } from "./strategies" import { prune, insertPruneToolContext } from "./messages" import { checkSession } from "./state" +import { loadPrompt } from "./prompts" + +const INTERNAL_AGENT_SIGNATURES = [ + "You are a title generator", + "You are a helpful AI assistant tasked with summarizing conversations", + "Summarize what was done in this conversation", +] + +export function createSystemPromptHandler( + state: SessionState, + logger: Logger, + config: PluginConfig, +) { + return async (_input: unknown, output: { system: string[] }) => { + if (state.isSubAgent) { + return + } + + const systemText = output.system.join("\n") + if (INTERNAL_AGENT_SIGNATURES.some((sig) => systemText.includes(sig))) { + logger.info("Skipping DCP system prompt injection for internal agent") + return + } + + const discardEnabled = config.tools.discard.enabled + const extractEnabled = config.tools.extract.enabled + + let promptName: string + if (discardEnabled && extractEnabled) { + promptName = "user/system/system-prompt-both" + } else if (discardEnabled) { + promptName = "user/system/system-prompt-discard" + } else if (extractEnabled) { + promptName = "user/system/system-prompt-extract" + } else { + return + } + + const syntheticPrompt = loadPrompt(promptName) + output.system.push(syntheticPrompt) + } +} export function createChatMessageTransformHandler( client: any, From 5c66449ddb53000a34f17a34c39ef93d3c990ece Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 13 Jan 2026 01:21:40 -0500 Subject: [PATCH 05/10] cleanup --- index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/index.ts b/index.ts index a145e24..9f23274 100644 --- a/index.ts +++ b/index.ts @@ -14,7 +14,7 @@ const plugin: Plugin = (async (ctx) => { // Suppress AI SDK warnings if (typeof globalThis !== "undefined") { - ;(globalThis as any).AI_SDK_LOG_WARNINGS = false + ; (globalThis as any).AI_SDK_LOG_WARNINGS = false } const logger = new Logger(config.debug) @@ -26,6 +26,7 @@ const plugin: Plugin = (async (ctx) => { return { "experimental.chat.system.transform": createSystemPromptHandler(state, logger, config), + "experimental.chat.messages.transform": createChatMessageTransformHandler( ctx.client, state, From 72e9d5e38334b0350947ef9d763d8d90919f18a7 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 13 Jan 2026 01:22:48 -0500 Subject: [PATCH 06/10] format --- index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.ts b/index.ts index 9f23274..8817c90 100644 --- a/index.ts +++ b/index.ts @@ -14,7 +14,7 @@ const plugin: Plugin = (async (ctx) => { // Suppress AI SDK warnings if (typeof globalThis !== "undefined") { - ; (globalThis as any).AI_SDK_LOG_WARNINGS = false + ;(globalThis as any).AI_SDK_LOG_WARNINGS = false } const logger = new Logger(config.debug) From 9204bd2ef259506c70e4a0cb055eca47889d8a4e Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 13 Jan 2026 01:24:12 -0500 Subject: [PATCH 07/10] chore: remove redundant AI_SDK_LOG_WARNINGS suppression --- index.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/index.ts b/index.ts index 8817c90..0802afb 100644 --- a/index.ts +++ b/index.ts @@ -12,11 +12,6 @@ const plugin: Plugin = (async (ctx) => { return {} } - // Suppress AI SDK warnings - if (typeof globalThis !== "undefined") { - ;(globalThis as any).AI_SDK_LOG_WARNINGS = false - } - const logger = new Logger(config.debug) const state = createSessionState() From 0a6a80aad27f95cb7ee5d9c2c623d565d5ce4b18 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 13 Jan 2026 01:43:04 -0500 Subject: [PATCH 08/10] refactor: simplify and unify prune.ts check ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove redundant write/edit checks (already protected via config) - Use consistent continue pattern for tool-specific filtering - Standardize check ordering: type → toolIds → status → tool-specific --- lib/messages/prune.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/messages/prune.ts b/lib/messages/prune.ts index 8b95e45..f224ce1 100644 --- a/lib/messages/prune.ts +++ b/lib/messages/prune.ts @@ -32,13 +32,14 @@ const pruneToolOutputs = (state: SessionState, logger: Logger, messages: WithPar if (!state.prune.toolIds.includes(part.callID)) { continue } - // Skip write/edit (protected) and question (output contains answers we want to keep) - if (part.tool === "write" || part.tool === "edit" || part.tool === "question") { + if (part.state.status !== "completed") { continue } - if (part.state.status === "completed") { - part.state.output = PRUNED_TOOL_OUTPUT_REPLACEMENT + if (part.tool === "question") { + continue } + + part.state.output = PRUNED_TOOL_OUTPUT_REPLACEMENT } } } @@ -59,8 +60,11 @@ const pruneToolInputs = (state: SessionState, logger: Logger, messages: WithPart if (part.state.status !== "completed") { continue } + if (part.tool !== "question") { + continue + } - if (part.tool === "question" && part.state.input?.questions !== undefined) { + if (part.state.input?.questions !== undefined) { part.state.input.questions = PRUNED_QUESTION_INPUT_REPLACEMENT } } From 0668794a4d4b81cf9fe48204355aaea0ceee37ca Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 13 Jan 2026 02:52:54 -0500 Subject: [PATCH 09/10] refactor: flatten prompts directory structure Remove unnecessary user/ nesting from prompts directory. Files now live directly under system/ and nudge/ subdirectories. --- lib/hooks.ts | 6 +++--- lib/messages/inject.ts | 6 +++--- lib/prompts/{user => }/nudge/nudge-both.txt | 0 lib/prompts/{user => }/nudge/nudge-discard.txt | 0 lib/prompts/{user => }/nudge/nudge-extract.txt | 0 lib/prompts/{user => }/system/system-prompt-both.txt | 0 lib/prompts/{user => }/system/system-prompt-discard.txt | 0 lib/prompts/{user => }/system/system-prompt-extract.txt | 0 package.json | 2 +- 9 files changed, 7 insertions(+), 7 deletions(-) rename lib/prompts/{user => }/nudge/nudge-both.txt (100%) rename lib/prompts/{user => }/nudge/nudge-discard.txt (100%) rename lib/prompts/{user => }/nudge/nudge-extract.txt (100%) rename lib/prompts/{user => }/system/system-prompt-both.txt (100%) rename lib/prompts/{user => }/system/system-prompt-discard.txt (100%) rename lib/prompts/{user => }/system/system-prompt-extract.txt (100%) diff --git a/lib/hooks.ts b/lib/hooks.ts index e1583a5..fc5e479 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -34,11 +34,11 @@ export function createSystemPromptHandler( let promptName: string if (discardEnabled && extractEnabled) { - promptName = "user/system/system-prompt-both" + promptName = "system/system-prompt-both" } else if (discardEnabled) { - promptName = "user/system/system-prompt-discard" + promptName = "system/system-prompt-discard" } else if (extractEnabled) { - promptName = "user/system/system-prompt-extract" + promptName = "system/system-prompt-extract" } else { return } diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 132e784..5048e18 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -17,11 +17,11 @@ const getNudgeString = (config: PluginConfig): string => { const extractEnabled = config.tools.extract.enabled if (discardEnabled && extractEnabled) { - return loadPrompt(`user/nudge/nudge-both`) + return loadPrompt(`nudge/nudge-both`) } else if (discardEnabled) { - return loadPrompt(`user/nudge/nudge-discard`) + return loadPrompt(`nudge/nudge-discard`) } else if (extractEnabled) { - return loadPrompt(`user/nudge/nudge-extract`) + return loadPrompt(`nudge/nudge-extract`) } return "" } diff --git a/lib/prompts/user/nudge/nudge-both.txt b/lib/prompts/nudge/nudge-both.txt similarity index 100% rename from lib/prompts/user/nudge/nudge-both.txt rename to lib/prompts/nudge/nudge-both.txt diff --git a/lib/prompts/user/nudge/nudge-discard.txt b/lib/prompts/nudge/nudge-discard.txt similarity index 100% rename from lib/prompts/user/nudge/nudge-discard.txt rename to lib/prompts/nudge/nudge-discard.txt diff --git a/lib/prompts/user/nudge/nudge-extract.txt b/lib/prompts/nudge/nudge-extract.txt similarity index 100% rename from lib/prompts/user/nudge/nudge-extract.txt rename to lib/prompts/nudge/nudge-extract.txt diff --git a/lib/prompts/user/system/system-prompt-both.txt b/lib/prompts/system/system-prompt-both.txt similarity index 100% rename from lib/prompts/user/system/system-prompt-both.txt rename to lib/prompts/system/system-prompt-both.txt diff --git a/lib/prompts/user/system/system-prompt-discard.txt b/lib/prompts/system/system-prompt-discard.txt similarity index 100% rename from lib/prompts/user/system/system-prompt-discard.txt rename to lib/prompts/system/system-prompt-discard.txt diff --git a/lib/prompts/user/system/system-prompt-extract.txt b/lib/prompts/system/system-prompt-extract.txt similarity index 100% rename from lib/prompts/user/system/system-prompt-extract.txt rename to lib/prompts/system/system-prompt-extract.txt diff --git a/package.json b/package.json index 2c9e86a..b047434 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "types": "./dist/index.d.ts", "scripts": { "clean": "rm -rf dist", - "build": "npm run clean && tsc && cp -r lib/prompts/*.txt lib/prompts/user dist/lib/prompts/", + "build": "npm run clean && tsc && cp -r lib/prompts/*.txt lib/prompts/system lib/prompts/nudge dist/lib/prompts/", "postbuild": "rm -rf dist/logs", "prepublishOnly": "npm run build", "dev": "opencode plugin dev", From 1e0298bde81693d8759112aa7a1429fe9445507d Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 13 Jan 2026 14:40:24 -0500 Subject: [PATCH 10/10] fix: skip synthetic message injection for GitHub Copilot when last message is user role --- lib/messages/inject.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 5048e18..81da973 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -143,6 +143,13 @@ export const insertPruneToolContext = ( const isGitHubCopilot = providerID === "github-copilot" || providerID === "github-copilot-enterprise" + if (isGitHubCopilot) { + const lastMessage = messages[messages.length - 1] + if (lastMessage?.info?.role === "user") { + return + } + } + logger.info("Injecting prunable-tools list", { providerID, isGitHubCopilot,