Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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 @@ -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 ""
}
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 @@ -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 ""
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