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
14 changes: 0 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,6 @@ DCP uses multiple tools and strategies to reduce context size:

**Purge Errors** — Prunes tool inputs for tools that returned errors after a configurable number of turns (default: 4). Error messages are preserved for context, but the potentially large input content is removed. Runs automatically on every request with zero LLM cost.

**On Idle Analysis** — Uses a language model to semantically analyze conversation context during idle periods and identify tool outputs that are no longer relevant. Disabled by default (legacy behavior).

Your session history is never modified—DCP replaces pruned content with placeholders before sending requests to your LLM.

## Impact on Prompt Caching
Expand Down Expand Up @@ -118,18 +116,6 @@ DCP uses its own config file:
// Additional tools to protect from pruning
"protectedTools": [],
},
// (Legacy) Run an LLM to analyze what tool calls are no longer relevant on idle
"onIdle": {
"enabled": false,
// Additional tools to protect from pruning
"protectedTools": [],
// Override model for analysis (format: "provider/model")
// "model": "anthropic/claude-haiku-4-5",
// Show toast notifications when model selection fails
"showModelErrorToasts": true,
// When true, fallback models are not permitted
"strictModelSelection": false,
},
},
}
```
Expand Down
5 changes: 2 additions & 3 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { Plugin } from "@opencode-ai/plugin"
import { getConfig } from "./lib/config"
import { Logger } from "./lib/logger"
import { loadPrompt } from "./lib/prompt"
import { loadPrompt } from "./lib/prompts"
import { createSessionState } from "./lib/state"
import { createDiscardTool, createExtractTool } from "./lib/strategies"
import { createChatMessageTransformHandler, createEventHandler } from "./lib/hooks"
import { createChatMessageTransformHandler } from "./lib/hooks"

const plugin: Plugin = (async (ctx) => {
const config = getConfig(ctx)
Expand Down Expand Up @@ -91,7 +91,6 @@ const plugin: Plugin = (async (ctx) => {
)
}
},
event: createEventHandler(ctx.client, config, state, logger, ctx.directory),
}
}) satisfies Plugin

Expand Down
106 changes: 0 additions & 106 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,6 @@ export interface Deduplication {
protectedTools: string[]
}

export interface OnIdle {
enabled: boolean
model?: string
showModelErrorToasts?: boolean
strictModelSelection?: boolean
protectedTools: string[]
}

export interface DiscardTool {
enabled: boolean
}
Expand Down Expand Up @@ -63,7 +55,6 @@ export interface PluginConfig {
deduplication: Deduplication
supersedeWrites: SupersedeWrites
purgeErrors: PurgeErrors
onIdle: OnIdle
}
}

Expand Down Expand Up @@ -102,13 +93,6 @@ export const VALID_CONFIG_KEYS = new Set([
"strategies.purgeErrors.enabled",
"strategies.purgeErrors.turns",
"strategies.purgeErrors.protectedTools",
// strategies.onIdle
"strategies.onIdle",
"strategies.onIdle.enabled",
"strategies.onIdle.model",
"strategies.onIdle.showModelErrorToasts",
"strategies.onIdle.strictModelSelection",
"strategies.onIdle.protectedTools",
])

// Extract all key paths from a config object for validation
Expand Down Expand Up @@ -272,60 +256,6 @@ function validateConfigTypes(config: Record<string, any>): ValidationError[] {
})
}

// onIdle
if (strategies.onIdle) {
if (
strategies.onIdle.enabled !== undefined &&
typeof strategies.onIdle.enabled !== "boolean"
) {
errors.push({
key: "strategies.onIdle.enabled",
expected: "boolean",
actual: typeof strategies.onIdle.enabled,
})
}
if (
strategies.onIdle.model !== undefined &&
typeof strategies.onIdle.model !== "string"
) {
errors.push({
key: "strategies.onIdle.model",
expected: "string",
actual: typeof strategies.onIdle.model,
})
}
if (
strategies.onIdle.showModelErrorToasts !== undefined &&
typeof strategies.onIdle.showModelErrorToasts !== "boolean"
) {
errors.push({
key: "strategies.onIdle.showModelErrorToasts",
expected: "boolean",
actual: typeof strategies.onIdle.showModelErrorToasts,
})
}
if (
strategies.onIdle.strictModelSelection !== undefined &&
typeof strategies.onIdle.strictModelSelection !== "boolean"
) {
errors.push({
key: "strategies.onIdle.strictModelSelection",
expected: "boolean",
actual: typeof strategies.onIdle.strictModelSelection,
})
}
if (
strategies.onIdle.protectedTools !== undefined &&
!Array.isArray(strategies.onIdle.protectedTools)
) {
errors.push({
key: "strategies.onIdle.protectedTools",
expected: "string[]",
actual: typeof strategies.onIdle.protectedTools,
})
}
}

// supersedeWrites
if (strategies.supersedeWrites) {
if (
Expand Down Expand Up @@ -459,12 +389,6 @@ const defaultConfig: PluginConfig = {
turns: 4,
protectedTools: [...DEFAULT_PROTECTED_TOOLS],
},
onIdle: {
enabled: false,
protectedTools: [...DEFAULT_PROTECTED_TOOLS],
showModelErrorToasts: true,
strictModelSelection: false,
},
},
}

Expand Down Expand Up @@ -587,18 +511,6 @@ function createDefaultConfig(): void {
"turns": 4,
// Additional tools to protect from pruning
"protectedTools": []
},
// (Legacy) Run an LLM to analyze what tool calls are no longer relevant on idle
"onIdle": {
"enabled": false,
// Additional tools to protect from pruning
"protectedTools": [],
// Override model for analysis (format: "provider/model")
// "model": "anthropic/claude-haiku-4-5",
// Show toast notifications when model selection fails
"showModelErrorToasts": true,
// When true, fallback models are not permitted
"strictModelSelection": false
}
}
}
Expand Down Expand Up @@ -660,20 +572,6 @@ function mergeStrategies(
]),
],
},
onIdle: {
enabled: override.onIdle?.enabled ?? base.onIdle.enabled,
model: override.onIdle?.model ?? base.onIdle.model,
showModelErrorToasts:
override.onIdle?.showModelErrorToasts ?? base.onIdle.showModelErrorToasts,
strictModelSelection:
override.onIdle?.strictModelSelection ?? base.onIdle.strictModelSelection,
protectedTools: [
...new Set([
...base.onIdle.protectedTools,
...(override.onIdle?.protectedTools ?? []),
]),
],
},
}
}

Expand Down Expand Up @@ -728,10 +626,6 @@ function deepCloneConfig(config: PluginConfig): PluginConfig {
...config.strategies.purgeErrors,
protectedTools: [...config.strategies.purgeErrors.protectedTools],
},
onIdle: {
...config.strategies.onIdle,
protectedTools: [...config.strategies.onIdle.protectedTools],
},
},
}
}
Expand Down
31 changes: 0 additions & 31 deletions lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { syncToolCache } from "./state/tool-cache"
import { deduplicate, supersedeWrites, purgeErrors } from "./strategies"
import { prune, insertPruneToolContext } from "./messages"
import { checkSession } from "./state"
import { runOnIdle } from "./strategies/on-idle"

export function createChatMessageTransformHandler(
client: any,
Expand Down Expand Up @@ -35,33 +34,3 @@ export function createChatMessageTransformHandler(
}
}
}

export function createEventHandler(
client: any,
config: PluginConfig,
state: SessionState,
logger: Logger,
workingDirectory?: string,
) {
return async ({ event }: { event: any }) => {
if (state.sessionId === null || state.isSubAgent) {
return
}

if (event.type === "session.status" && event.properties.status.type === "idle") {
if (!config.strategies.onIdle.enabled) {
return
}
if (state.lastToolPrune) {
logger.info("Skipping OnIdle pruning - last tool was prune")
return
}

try {
await runOnIdle(client, state, logger, config, workingDirectory)
} catch (err: any) {
logger.error("OnIdle pruning failed", { error: err.message })
}
}
}
}
3 changes: 2 additions & 1 deletion lib/messages/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { prune, insertPruneToolContext } from "./prune"
export { prune } from "./prune"
export { insertPruneToolContext } from "./inject"
129 changes: 129 additions & 0 deletions lib/messages/inject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type { SessionState, WithParts } from "../state"
import type { Logger } from "../logger"
import type { PluginConfig } from "../config"
import { loadPrompt } from "../prompts"
import { extractParameterKey, buildToolIdList, createSyntheticUserMessage } from "./utils"
import { getLastUserMessage } from "../shared-utils"

const getNudgeString = (config: PluginConfig): string => {
const discardEnabled = config.tools.discard.enabled
const extractEnabled = config.tools.extract.enabled

if (discardEnabled && extractEnabled) {
return loadPrompt(`user/nudge/nudge-both`)
} else if (discardEnabled) {
return loadPrompt(`user/nudge/nudge-discard`)
} else if (extractEnabled) {
return loadPrompt(`user/nudge/nudge-extract`)
}
return ""
}

const wrapPrunableTools = (content: string): string => `<prunable-tools>
The following tools have been invoked and are available for pruning. This list does not mandate immediate action. Consider your current goals and the resources you need before discarding valuable tool inputs or outputs. Consolidate your prunes for efficiency; it is rarely worth pruning a single tiny tool output. Keep the context free of noise.
${content}
</prunable-tools>`

const getCooldownMessage = (config: PluginConfig): string => {
const discardEnabled = config.tools.discard.enabled
const extractEnabled = config.tools.extract.enabled

let toolName: string
if (discardEnabled && extractEnabled) {
toolName = "discard or extract tools"
} else if (discardEnabled) {
toolName = "discard tool"
} else {
toolName = "extract tool"
}

return `<prunable-tools>
Context management was just performed. Do not use the ${toolName} again. A fresh list will be available after your next tool use.
</prunable-tools>`
}

const buildPrunableToolsList = (
state: SessionState,
config: PluginConfig,
logger: Logger,
messages: WithParts[],
): string => {
const lines: string[] = []
const toolIdList: string[] = buildToolIdList(state, messages, logger)

state.toolParameters.forEach((toolParameterEntry, toolCallId) => {
if (state.prune.toolIds.includes(toolCallId)) {
return
}

const allProtectedTools = config.tools.settings.protectedTools
if (allProtectedTools.includes(toolParameterEntry.tool)) {
return
}

const numericId = toolIdList.indexOf(toolCallId)
if (numericId === -1) {
logger.warn(`Tool in cache but not in toolIdList - possible stale entry`, {
toolCallId,
tool: toolParameterEntry.tool,
})
return
}
const paramKey = extractParameterKey(toolParameterEntry.tool, toolParameterEntry.parameters)
const description = paramKey
? `${toolParameterEntry.tool}, ${paramKey}`
: toolParameterEntry.tool
lines.push(`${numericId}: ${description}`)
logger.debug(
`Prunable tool found - ID: ${numericId}, Tool: ${toolParameterEntry.tool}, Call ID: ${toolCallId}`,
)
})

if (lines.length === 0) {
return ""
}

return wrapPrunableTools(lines.join("\n"))
}

export const insertPruneToolContext = (
state: SessionState,
config: PluginConfig,
logger: Logger,
messages: WithParts[],
): void => {
if (!config.tools.discard.enabled && !config.tools.extract.enabled) {
return
}

let prunableToolsContent: string

if (state.lastToolPrune) {
logger.debug("Last tool was prune - injecting cooldown message")
prunableToolsContent = getCooldownMessage(config)
} else {
const prunableToolsList = buildPrunableToolsList(state, config, logger, messages)
if (!prunableToolsList) {
return
}

logger.debug("prunable-tools: \n" + prunableToolsList)

let nudgeString = ""
if (
config.tools.settings.nudgeEnabled &&
state.nudgeCounter >= config.tools.settings.nudgeFrequency
) {
logger.info("Inserting prune nudge message")
nudgeString = "\n" + getNudgeString(config)
}

prunableToolsContent = prunableToolsList + nudgeString
}

const lastUserMessage = getLastUserMessage(messages)
if (!lastUserMessage) {
return
}
messages.push(createSyntheticUserMessage(lastUserMessage, prunableToolsContent))
}
Loading