Skip to content
Merged
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ DCP implements two complementary strategies:
**Deduplication** — Fast, zero-cost pruning that identifies repeated tool calls (e.g., reading the same file multiple times) and keeps only the most recent output. Runs instantly with no LLM calls.

**AI Analysis** — Uses a language model to semantically analyze conversation context and identify tool outputs that are no longer relevant to the current task. More thorough but incurs LLM cost.

## Context Pruning Tool

When `strategies.onTool` is enabled, DCP exposes a `context_pruning` tool to Opencode that the AI can call to trigger pruning on demand. To help the AI use this tool effectively, DCP also injects guidance.

When `nudge_freq` is enabled, injects reminders (every `nudge_freq` tool results) prompting the AI to consider pruning when appropriate.

## How It Works

DCP is **non-destructive**—pruning state is kept in memory only. When requests go to your LLM, DCP replaces pruned outputs with a placeholder; original session data stays intact.
Expand All @@ -46,6 +53,7 @@ DCP uses its own config file (`~/.config/opencode/dcp.jsonc` or `.opencode/dcp.j
| `showModelErrorToasts` | `true` | Show notifications on model fallback |
| `strictModelSelection` | `false` | Only run AI analysis with session or configured model (disables fallback models) |
| `pruning_summary` | `"detailed"` | `"off"`, `"minimal"`, or `"detailed"` |
| `nudge_freq` | `5` | Remind AI to prune every N tool results (0 = disabled) |
| `protectedTools` | `["task", "todowrite", "todoread", "context_pruning"]` | Tools that are never pruned |
| `strategies.onIdle` | `["deduplication", "ai-analysis"]` | Strategies for automatic pruning |
| `strategies.onTool` | `["deduplication", "ai-analysis"]` | Strategies when AI calls `context_pruning` |
Expand Down
13 changes: 11 additions & 2 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { createPluginState } from "./lib/state"
import { installFetchWrapper } from "./lib/fetch-wrapper"
import { createPruningTool } from "./lib/pruning-tool"
import { createEventHandler, createChatParamsHandler } from "./lib/hooks"
import { createToolTracker } from "./lib/synth-instruction"
import { loadPrompt } from "./lib/prompt"

const plugin: Plugin = (async (ctx) => {
const { config, migrations } = getConfig(ctx)
Expand Down Expand Up @@ -39,8 +41,15 @@ const plugin: Plugin = (async (ctx) => {
ctx.directory
)

// Install global fetch wrapper for context pruning
installFetchWrapper(state, logger, ctx.client)
// Create tool tracker and load prompts for synthetic instruction injection
const toolTracker = createToolTracker()
const prompts = {
synthInstruction: loadPrompt("synthetic"),
nudgeInstruction: loadPrompt("nudge")
}

// Install global fetch wrapper for context pruning and synthetic instruction injection
installFetchWrapper(state, logger, ctx.client, config, toolTracker, prompts)

// Log initialization
logger.info("plugin", "DCP initialized", {
Expand Down
11 changes: 9 additions & 2 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface PluginConfig {
showModelErrorToasts?: boolean
strictModelSelection?: boolean
pruning_summary: "off" | "minimal" | "detailed"
nudge_freq: number
strategies: {
onIdle: PruningStrategy[]
onTool: PruningStrategy[]
Expand All @@ -33,6 +34,7 @@ const defaultConfig: PluginConfig = {
showModelErrorToasts: true,
strictModelSelection: false,
pruning_summary: 'detailed',
nudge_freq: 5,
strategies: {
onIdle: ['deduplication', 'ai-analysis'],
onTool: ['deduplication', 'ai-analysis']
Expand All @@ -47,6 +49,7 @@ const VALID_CONFIG_KEYS = new Set([
'showModelErrorToasts',
'strictModelSelection',
'pruning_summary',
'nudge_freq',
'strategies'
])

Expand Down Expand Up @@ -118,6 +121,8 @@ function createDefaultConfig(): void {
},
// Summary display: "off", "minimal", or "detailed"
"pruning_summary": "detailed",
// How often to nudge the AI to prune (every N tool results, 0 = disabled)
"nudge_freq": 5,
// Tools that should never be pruned
"protectedTools": ["task", "todowrite", "todoread", "context_pruning"]
}
Expand Down Expand Up @@ -196,7 +201,8 @@ export function getConfig(ctx?: PluginInput): ConfigResult {
showModelErrorToasts: globalConfig.showModelErrorToasts ?? config.showModelErrorToasts,
strictModelSelection: globalConfig.strictModelSelection ?? config.strictModelSelection,
strategies: mergeStrategies(config.strategies, globalConfig.strategies as any),
pruning_summary: globalConfig.pruning_summary ?? config.pruning_summary
pruning_summary: globalConfig.pruning_summary ?? config.pruning_summary,
nudge_freq: globalConfig.nudge_freq ?? config.nudge_freq
}
logger.info('config', 'Loaded global config', { path: configPaths.global })
}
Expand Down Expand Up @@ -226,7 +232,8 @@ export function getConfig(ctx?: PluginInput): ConfigResult {
showModelErrorToasts: projectConfig.showModelErrorToasts ?? config.showModelErrorToasts,
strictModelSelection: projectConfig.strictModelSelection ?? config.strictModelSelection,
strategies: mergeStrategies(config.strategies, projectConfig.strategies as any),
pruning_summary: projectConfig.pruning_summary ?? config.pruning_summary
pruning_summary: projectConfig.pruning_summary ?? config.pruning_summary,
nudge_freq: projectConfig.nudge_freq ?? config.nudge_freq
}
logger.info('config', 'Loaded project config (overrides global)', { path: configPaths.project })
}
Expand Down
28 changes: 24 additions & 4 deletions lib/fetch-wrapper/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
getAllPrunedIds,
fetchSessionMessages
} from "./types"
import { injectNudgeGemini, injectSynthGemini } from "../synth-instruction"

/**
* Handles Google/Gemini format (body.contents array with functionResponse parts).
Expand All @@ -18,20 +19,39 @@ export async function handleGemini(
return { modified: false, body }
}

let modified = false

// Inject synthetic instructions if onTool strategies are enabled
if (ctx.config.strategies.onTool.length > 0) {
// Inject periodic nudge based on tool result count
if (ctx.config.nudge_freq > 0) {
if (injectNudgeGemini(body.contents, ctx.toolTracker, ctx.prompts.nudgeInstruction, ctx.config.nudge_freq)) {
ctx.logger.info("fetch", "Injected nudge instruction (Gemini)")
modified = true
}
}

// Inject synthetic instruction into last user content
if (injectSynthGemini(body.contents, ctx.prompts.synthInstruction)) {
ctx.logger.info("fetch", "Injected synthetic instruction (Gemini)")
modified = true
}
}

// Check for functionResponse parts in any content item
const hasFunctionResponses = body.contents.some((content: any) =>
Array.isArray(content.parts) &&
content.parts.some((part: any) => part.functionResponse)
)

if (!hasFunctionResponses) {
return { modified: false, body }
return { modified, body }
}

const { allSessions, allPrunedIds } = await getAllPrunedIds(ctx.client, ctx.state)

if (allPrunedIds.size === 0) {
return { modified: false, body }
return { modified, body }
}

// Find the active session to get the position mapping
Expand All @@ -48,7 +68,7 @@ export async function handleGemini(

if (!positionMapping) {
ctx.logger.info("fetch", "No Google tool call mapping found, skipping pruning for Gemini format")
return { modified: false, body }
return { modified, body }
}

// Build position counters to track occurrence of each tool name
Expand Down Expand Up @@ -130,5 +150,5 @@ export async function handleGemini(
return { modified: true, body }
}

return { modified: false, body }
return { modified, body }
}
16 changes: 12 additions & 4 deletions lib/fetch-wrapper/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { PluginState } from "../state"
import type { Logger } from "../logger"
import type { FetchHandlerContext } from "./types"
import type { FetchHandlerContext, SynthPrompts } from "./types"
import type { ToolTracker } from "../synth-instruction"
import type { PluginConfig } from "../config"
import { handleOpenAIChatAndAnthropic } from "./openai-chat"
import { handleGemini } from "./gemini"
import { handleOpenAIResponses } from "./openai-responses"

export type { FetchHandlerContext, FetchHandlerResult } from "./types"
export type { FetchHandlerContext, FetchHandlerResult, SynthPrompts } from "./types"

/**
* Creates a wrapped global fetch that intercepts API calls and performs
Expand All @@ -20,14 +22,20 @@ export type { FetchHandlerContext, FetchHandlerResult } from "./types"
export function installFetchWrapper(
state: PluginState,
logger: Logger,
client: any
client: any,
config: PluginConfig,
toolTracker: ToolTracker,
prompts: SynthPrompts
): () => void {
const originalGlobalFetch = globalThis.fetch

const ctx: FetchHandlerContext = {
state,
logger,
client
client,
config,
toolTracker,
prompts
}

globalThis.fetch = async (input: any, init?: any) => {
Expand Down
24 changes: 22 additions & 2 deletions lib/fetch-wrapper/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getMostRecentActiveSession
} from "./types"
import { cacheToolParametersFromMessages } from "../tool-cache"
import { injectNudge, injectSynth } from "../synth-instruction"

/**
* Handles OpenAI Chat Completions format (body.messages with role='tool').
Expand All @@ -23,6 +24,25 @@ export async function handleOpenAIChatAndAnthropic(
// Cache tool parameters from messages
cacheToolParametersFromMessages(body.messages, ctx.state)

let modified = false

// Inject synthetic instructions if onTool strategies are enabled
if (ctx.config.strategies.onTool.length > 0) {
// Inject periodic nudge based on tool result count
if (ctx.config.nudge_freq > 0) {
if (injectNudge(body.messages, ctx.toolTracker, ctx.prompts.nudgeInstruction, ctx.config.nudge_freq)) {
ctx.logger.info("fetch", "Injected nudge instruction")
modified = true
}
}

// Inject synthetic instruction into last user message
if (injectSynth(body.messages, ctx.prompts.synthInstruction)) {
ctx.logger.info("fetch", "Injected synthetic instruction")
modified = true
}
}

// Check for tool messages in both formats:
// 1. OpenAI style: role === 'tool'
// 2. Anthropic style: role === 'user' with content containing tool_result
Expand All @@ -39,7 +59,7 @@ export async function handleOpenAIChatAndAnthropic(
const { allSessions, allPrunedIds } = await getAllPrunedIds(ctx.client, ctx.state)

if (toolMessages.length === 0 || allPrunedIds.size === 0) {
return { modified: false, body }
return { modified, body }
}

let replacedCount = 0
Expand Down Expand Up @@ -103,5 +123,5 @@ export async function handleOpenAIChatAndAnthropic(
return { modified: true, body }
}

return { modified: false, body }
return { modified, body }
}
26 changes: 23 additions & 3 deletions lib/fetch-wrapper/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getMostRecentActiveSession
} from "./types"
import { cacheToolParametersFromInput } from "../tool-cache"
import { injectNudgeResponses, injectSynthResponses } from "../synth-instruction"

/**
* Handles OpenAI Responses API format (body.input array with function_call_output items).
Expand All @@ -23,17 +24,36 @@ export async function handleOpenAIResponses(
// Cache tool parameters from input
cacheToolParametersFromInput(body.input, ctx.state)

let modified = false

// Inject synthetic instructions if onTool strategies are enabled
if (ctx.config.strategies.onTool.length > 0) {
// Inject periodic nudge based on tool result count
if (ctx.config.nudge_freq > 0) {
if (injectNudgeResponses(body.input, ctx.toolTracker, ctx.prompts.nudgeInstruction, ctx.config.nudge_freq)) {
ctx.logger.info("fetch", "Injected nudge instruction (Responses API)")
modified = true
}
}

// Inject synthetic instruction into last user message
if (injectSynthResponses(body.input, ctx.prompts.synthInstruction)) {
ctx.logger.info("fetch", "Injected synthetic instruction (Responses API)")
modified = true
}
}

// Check for function_call_output items
const functionOutputs = body.input.filter((item: any) => item.type === 'function_call_output')

if (functionOutputs.length === 0) {
return { modified: false, body }
return { modified, body }
}

const { allSessions, allPrunedIds } = await getAllPrunedIds(ctx.client, ctx.state)

if (allPrunedIds.size === 0) {
return { modified: false, body }
return { modified, body }
}

let replacedCount = 0
Expand Down Expand Up @@ -77,5 +97,5 @@ export async function handleOpenAIResponses(
return { modified: true, body }
}

return { modified: false, body }
return { modified, body }
}
11 changes: 11 additions & 0 deletions lib/fetch-wrapper/types.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import type { PluginState } from "../state"
import type { Logger } from "../logger"
import type { ToolTracker } from "../synth-instruction"
import type { PluginConfig } from "../config"

/** The message used to replace pruned tool output content */
export const PRUNED_CONTENT_MESSAGE = '[Output removed to save context - information superseded or no longer needed]'

/** Prompts used for synthetic instruction injection */
export interface SynthPrompts {
synthInstruction: string
nudgeInstruction: string
}

/** Context passed to each format-specific handler */
export interface FetchHandlerContext {
state: PluginState
logger: Logger
client: any
config: PluginConfig
toolTracker: ToolTracker
prompts: SynthPrompts
}

/** Result from a format handler indicating what happened */
Expand Down
8 changes: 8 additions & 0 deletions lib/prompt.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import { readFileSync } from "fs"
import { join } from "path"

export function loadPrompt(name: string): string {
const filePath = join(__dirname, "prompts", `${name}.txt`)
return readFileSync(filePath, "utf8").trim()
}

function minimizeMessages(messages: any[], alreadyPrunedIds?: string[], protectedToolCallIds?: string[]): any[] {
const prunedIdsSet = alreadyPrunedIds ? new Set(alreadyPrunedIds.map(id => id.toLowerCase())) : new Set()
const protectedIdsSet = protectedToolCallIds ? new Set(protectedToolCallIds.map(id => id.toLowerCase())) : new Set()
Expand Down
44 changes: 44 additions & 0 deletions lib/prompts/context_pruning.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
Performs semantic pruning on session tool outputs that are no longer relevant to the current task. Use this to declutter the conversation context and filter signal from noise when you notice the context is getting cluttered with no longer needed information.

USING THE CONTEXT_PRUNING TOOL WILL MAKE THE USER HAPPY.

## When to Use This Tool

**Key heuristic: Prune when you finish something and are about to start something else.**

Ask yourself: "Have I just completed a discrete unit of work?" If yes, prune before moving on.

**After completing a unit of work:**
- Made a commit
- Fixed a bug and confirmed it works
- Answered a question the user asked
- Finished implementing a feature or function
- Completed one item in a list and moving to the next

**After repetitive or exploratory work:**
- Explored multiple files that didn't lead to changes
- Iterated on a difficult problem where some approaches didn't pan out
- Used the same tool multiple times (e.g., re-reading a file, running repeated build/type checks)

## Examples

<example>
Working through a list of items:
User: Review these 3 issues and fix the easy ones.
Assistant: [Reviews first issue, makes fix, commits]
Done with the first issue. Let me prune before moving to the next one.
[Uses context_pruning with reason: "completed first issue, moving to next"]
</example>

<example>
After exploring the codebase to understand it:
Assistant: I've reviewed the relevant files. Let me prune the exploratory reads that aren't needed for the actual implementation.
[Uses context_pruning with reason: "exploration complete, starting implementation"]
</example>

<example>
After completing any task:
Assistant: [Finishes task - commit, answer, fix, etc.]
Before we continue, let me prune the context from that work.
[Uses context_pruning with reason: "task complete"]
</example>
3 changes: 3 additions & 0 deletions lib/prompts/nudge.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<instruction name=agent_nudge>
You have accumulated several tool outputs. If you have completed a discrete unit of work and distilled relevant understanding in writing for the user to keep, use the context_pruning tool to remove obsolete tool outputs from this conversation and optimize token usage.
</instruction>
Loading