diff --git a/index.ts b/index.ts index 2372d2c..0802afb 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) @@ -13,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() @@ -26,38 +20,8 @@ const plugin: Plugin = (async (ctx) => { }) return { - "experimental.chat.system.transform": async ( - _input: unknown, - output: { system: string[] }, - ) => { - 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 - } + "experimental.chat.system.transform": createSystemPromptHandler(state, logger, config), - 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.messages.transform": createChatMessageTransformHandler( ctx.client, state, diff --git a/lib/hooks.ts b/lib/hooks.ts index e70c892..fc5e479 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 = "system/system-prompt-both" + } else if (discardEnabled) { + promptName = "system/system-prompt-discard" + } else if (extractEnabled) { + promptName = "system/system-prompt-extract" + } else { + return + } + + const syntheticPrompt = loadPrompt(promptName) + output.system.push(syntheticPrompt) + } +} export function createChatMessageTransformHandler( client: any, 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/messages/prune.ts b/lib/messages/prune.ts index eb8aae6..f224ce1 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,20 +32,18 @@ const pruneToolOutputs = (state: SessionState, logger: Logger, messages: WithPar if (!state.prune.toolIds.includes(part.callID)) { continue } - if (part.tool === "write" || part.tool === "edit") { + 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 } } } -// 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 +57,15 @@ const pruneToolInputs = (state: SessionState, logger: Logger, messages: WithPart if (!state.prune.toolIds.includes(part.callID)) { continue } - if (part.tool !== "write" && part.tool !== "edit") { + if (part.state.status !== "completed") { continue } - if (part.state.status !== "completed") { + if (part.tool !== "question") { 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.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/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/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" 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",