Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e8faac5
feat: add question tool support for pruning
Tarquinen Jan 13, 2026
4fe0a5c
Merge pull request #247 from Opencode-DCP/feat/question-tool-support
Tarquinen Jan 13, 2026
c06dd7f
fix: skip system prompt injection for subagent sessions
Tarquinen Jan 13, 2026
4a18692
style: fix formatting
Tarquinen Jan 13, 2026
0462d2f
refactor: extract system prompt handler to hooks.ts
Tarquinen Jan 13, 2026
5c66449
cleanup
Tarquinen Jan 13, 2026
72e9d5e
format
Tarquinen Jan 13, 2026
9204bd2
chore: remove redundant AI_SDK_LOG_WARNINGS suppression
Tarquinen Jan 13, 2026
d7ea156
Merge pull request #249 from Opencode-DCP/fix/skip-subagent-system-pr…
Tarquinen Jan 13, 2026
0a6a80a
refactor: simplify and unify prune.ts check ordering
Tarquinen Jan 13, 2026
0962811
Merge pull request #250 from Opencode-DCP/refactor/prune-checks-cleanup
Tarquinen Jan 13, 2026
0668794
refactor: flatten prompts directory structure
Tarquinen Jan 13, 2026
5849833
Merge pull request #251 from Opencode-DCP/refactor/flatten-prompts-di…
Tarquinen Jan 13, 2026
1e0298b
fix: skip synthetic message injection for GitHub Copilot when last me…
Tarquinen Jan 13, 2026
3b3de42
Merge pull request #252 from Opencode-DCP/fix/skip-github-copilot-use…
Tarquinen Jan 13, 2026
b21cca3
Merge master: use unified injection with isAnthropic check
Tarquinen Jan 14, 2026
530540c
Merge pull request #253 from Opencode-DCP/merge-master-into-dev
Tarquinen Jan 14, 2026
9415b77
feat: add plan_enter and plan_exit to default protected tools
Tarquinen Jan 14, 2026
89e311d
Merge pull request #254 from Opencode-DCP/feat/protect-plan-mode-tools
Tarquinen Jan 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 2 additions & 38 deletions index.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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()

Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ const DEFAULT_PROTECTED_TOOLS = [
"batch",
"write",
"edit",
"plan_enter",
"plan_exit",
]

// Valid config keys for validation against user config
Expand Down
42 changes: 42 additions & 0 deletions lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions lib/messages/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,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 ""
}
Expand Down
31 changes: 10 additions & 21 deletions lib/messages/prune.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)) {
Expand All @@ -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
}
}
}
Expand Down
20 changes: 20 additions & 0 deletions lib/messages/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,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 ""
Expand Down
File renamed without changes.
26 changes: 6 additions & 20 deletions lib/strategies/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down