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
4 changes: 1 addition & 3 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ name: PR Checks

on:
pull_request:
branches: [main, master]
push:
branches: [main, master]
branches: [master, dev]

jobs:
validate:
Expand Down
88 changes: 58 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,15 @@ Restart OpenCode. The plugin will automatically start optimizing your sessions.

## How Pruning Works

DCP uses two complementary techniques:
DCP uses multiple strategies to reduce context size:

**Automatic Deduplication** — Silently identifies repeated tool calls (e.g., reading the same file multiple times) and keeps only the most recent output. Runs on every request with zero LLM cost.
**Deduplication** — Identifies repeated tool calls (e.g., reading the same file multiple times) and keeps only the most recent output. Runs automatically on every request with zero LLM cost.

**AI Analysis** — Uses a language model to semantically analyze conversation context and identify tool outputs that are no longer relevant to the current task.
**Prune Thinking Blocks** — Removes LLM thinking/reasoning blocks from the conversation history.

## Context Pruning Tool
**On Idle Analysis** — Uses a language model to semantically analyze conversation context during idle periods and identify tool outputs that are no longer relevant.

When `strategies.onTool` is enabled, DCP exposes a `prune` tool to Opencode that the AI can call to trigger pruning on demand.

Adjust `nudgeFreq` to control how aggressively the AI is prompted to prune — lower values trigger reminders sooner and more often.

## How It Works
**Prune Tool** — Exposes a `prune` tool that the AI can call to manually trigger pruning when it determines context cleanup is needed.

Your session history is never modified. DCP replaces pruned outputs with a placeholder before sending requests to your LLM.

Expand All @@ -49,35 +45,67 @@ LLM providers like Anthropic and OpenAI cache prompts based on exact prefix matc

DCP uses its own config file (`~/.config/opencode/dcp.jsonc` or `.opencode/dcp.jsonc`), created automatically on first run.

### Options

| Option | Default | Description |
|--------|---------|-------------|
| `enabled` | `true` | Enable/disable the plugin |
| `debug` | `false` | Log to `~/.config/opencode/logs/dcp/` |
| `model` | (session) | Model for analysis (e.g., `"anthropic/claude-haiku-4-5"`) |
| `showModelErrorToasts` | `true` | Show notifications on model fallback |
| `showUpdateToasts` | `true` | Show notifications when a new version is available |
| `strictModelSelection` | `false` | Only run AI analysis with session or configured model (disables fallback models) |
| `pruningSummary` | `"detailed"` | `"off"`, `"minimal"`, or `"detailed"` |
| `nudgeFreq` | `10` | How often to remind AI to prune (lower = more frequent) |
| `protectedTools` | `["task", "todowrite", "todoread", "prune", "batch", "write", "edit"]` | Tools that are never pruned |
| `strategies.onIdle` | `["ai-analysis"]` | Strategies for automatic pruning |
| `strategies.onTool` | `["ai-analysis"]` | Strategies when AI calls `prune` |

**Strategies:** `"ai-analysis"` uses LLM to identify prunable outputs. Empty array disables that trigger. Deduplication runs automatically on every request.
<details>
<summary><strong>Default Configuration</strong> (click to expand)</summary>

```jsonc
{
// Enable or disable the plugin
"enabled": true,
// Enable debug logging to ~/.config/opencode/logs/dcp/
"debug": false,
// Show toast notifications when a new version is available
"showUpdateToasts": true,
// Summary display: "off", "minimal", or "detailed"
"pruningSummary": "detailed",
// Strategies for pruning tokens from chat history
"strategies": {
"onIdle": ["ai-analysis"],
"onTool": ["ai-analysis"]
},
"protectedTools": ["task", "todowrite", "todoread", "prune", "batch", "write", "edit"]
// Remove duplicate tool calls (same tool with same arguments)
"deduplication": {
"enabled": true,
// Additional tools to protect from pruning
"protectedTools": []
},
// Remove thinking/reasoning LLM blocks
"pruneThinkingBlocks": {
"enabled": false
},
// (Legacy) Run an LLM to analyze what tool calls are no longer relevant on idle
"onIdle": {
"enabled": false,
// 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,
// Additional tools to protect from pruning
"protectedTools": []
},
// Exposes a prune tool to your LLM to call when it determines pruning is necessary
"pruneTool": {
"enabled": false,
// Additional tools to protect from pruning
"protectedTools": [],
// Nudge the LLM to use the prune tool (every <frequency> tool results)
"nudge": {
"enabled": true,
"frequency": 10
}
}
}
}
```

</details>

### Protected Tools

By default, these tools are always protected from pruning across all strategies:
`task`, `todowrite`, `todoread`, `prune`, `batch`, `write`, `edit`

The `protectedTools` arrays in each strategy add to this default list.

### Config Precedence

Settings are merged in order: **Defaults** → **Global** (`~/.config/opencode/dcp.jsonc`) → **Project** (`.opencode/dcp.jsonc`). Each level overrides the previous, so project settings take priority over global, which takes priority over defaults.
Expand Down
50 changes: 36 additions & 14 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,15 @@ export interface OnIdle {
protectedTools: string[]
}

export interface PruneToolNudge {
enabled: boolean
frequency: number
}

export interface PruneTool {
enabled: boolean
protectedTools: string[]
nudgeFrequency: number
nudge: PruneToolNudge
}

export interface PluginConfig {
Expand Down Expand Up @@ -68,7 +73,9 @@ export const VALID_CONFIG_KEYS = new Set([
'strategies.pruneTool',
'strategies.pruneTool.enabled',
'strategies.pruneTool.protectedTools',
'strategies.pruneTool.nudgeFrequency',
'strategies.pruneTool.nudge',
'strategies.pruneTool.nudge.enabled',
'strategies.pruneTool.nudge.frequency',
])

// Extract all key paths from a config object for validation
Expand Down Expand Up @@ -160,8 +167,13 @@ function validateConfigTypes(config: Record<string, any>): ValidationError[] {
if (strategies.pruneTool.protectedTools !== undefined && !Array.isArray(strategies.pruneTool.protectedTools)) {
errors.push({ key: 'strategies.pruneTool.protectedTools', expected: 'string[]', actual: typeof strategies.pruneTool.protectedTools })
}
if (strategies.pruneTool.nudgeFrequency !== undefined && typeof strategies.pruneTool.nudgeFrequency !== 'number') {
errors.push({ key: 'strategies.pruneTool.nudgeFrequency', expected: 'number', actual: typeof strategies.pruneTool.nudgeFrequency })
if (strategies.pruneTool.nudge) {
if (strategies.pruneTool.nudge.enabled !== undefined && typeof strategies.pruneTool.nudge.enabled !== 'boolean') {
errors.push({ key: 'strategies.pruneTool.nudge.enabled', expected: 'boolean', actual: typeof strategies.pruneTool.nudge.enabled })
}
if (strategies.pruneTool.nudge.frequency !== undefined && typeof strategies.pruneTool.nudge.frequency !== 'number') {
errors.push({ key: 'strategies.pruneTool.nudge.frequency', expected: 'number', actual: typeof strategies.pruneTool.nudge.frequency })
}
}
}
}
Expand Down Expand Up @@ -226,18 +238,21 @@ const defaultConfig: PluginConfig = {
protectedTools: [...DEFAULT_PROTECTED_TOOLS]
},
pruneThinkingBlocks: {
enabled: true
enabled: false
},
onIdle: {
enabled: true,
enabled: false,
showModelErrorToasts: true,
strictModelSelection: false,
protectedTools: [...DEFAULT_PROTECTED_TOOLS]
},
pruneTool: {
enabled: false,
protectedTools: [...DEFAULT_PROTECTED_TOOLS],
nudgeFrequency: 10
nudge: {
enabled: true,
frequency: 10
}
}
}
}
Expand Down Expand Up @@ -309,11 +324,11 @@ function createDefaultConfig(): void {
},
// Remove thinking/reasoning LLM blocks
"pruneThinkingBlocks": {
"enabled": true
"enabled": false
},
// Run an LLM to analyze what tool calls are no longer relevant on idle
// (Legacy) Run an LLM to analyze what tool calls are no longer relevant on idle
"onIdle": {
"enabled": true,
"enabled": false,
// Override model for analysis (format: "provider/model")
// "model": "anthropic/claude-haiku-4-5",
// Show toast notifications when model selection fails
Expand All @@ -328,8 +343,11 @@ function createDefaultConfig(): void {
"enabled": false,
// Additional tools to protect from pruning
"protectedTools": [],
// How often to nudge the AI to prune (every N tool results, 0 = disabled)
"nudgeFrequency": 10
// Nudge the LLM to use the prune tool (every <frequency> tool results)
"nudge": {
"enabled": true,
"frequency": 10
}
}
}
}
Expand Down Expand Up @@ -401,7 +419,10 @@ function mergeStrategies(
...(override.pruneTool?.protectedTools ?? [])
])
],
nudgeFrequency: override.pruneTool?.nudgeFrequency ?? base.pruneTool.nudgeFrequency
nudge: {
enabled: override.pruneTool?.nudge?.enabled ?? base.pruneTool.nudge.enabled,
frequency: override.pruneTool?.nudge?.frequency ?? base.pruneTool.nudge.frequency
}
}
}
}
Expand All @@ -421,7 +442,8 @@ function deepCloneConfig(config: PluginConfig): PluginConfig {
},
pruneTool: {
...config.strategies.pruneTool,
protectedTools: [...config.strategies.pruneTool.protectedTools]
protectedTools: [...config.strategies.pruneTool.protectedTools],
nudge: { ...config.strategies.pruneTool.nudge }
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export function createChatMessageTransformHandler(
output: { messages: WithParts[] }
) => {
checkSession(state, logger, output.messages);
syncToolCache(state, logger, output.messages);
syncToolCache(state, config, logger, output.messages);


deduplicate(state, logger, config, output.messages)

Expand Down
10 changes: 9 additions & 1 deletion lib/messages/prune.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import type { Logger } from "../logger"
import type { PluginConfig } from "../config"
import { buildToolIdList } from "../utils"
import { getLastUserMessage, extractParameterKey } from "./utils"
import { loadPrompt } from "../prompt"

const PRUNED_TOOL_OUTPUT_REPLACEMENT = '[Output removed to save context - information superseded or no longer needed]'
const NUDGE_STRING = loadPrompt("nudge")

const buildPrunableToolsList = (
state: SessionState,
Expand Down Expand Up @@ -45,6 +47,12 @@ export const insertPruneToolContext = (

const prunableToolsList = buildPrunableToolsList(state, config, logger, messages)

let nudgeString = ""
if (config.strategies.pruneTool.nudge.enabled && state.nudgeCounter >= config.strategies.pruneTool.nudge.frequency) {
logger.info("Inserting prune nudge message")
nudgeString = "\n" + NUDGE_STRING
}

const userMessage: WithParts = {
info: {
id: "msg_01234567890123456789012345",
Expand All @@ -63,7 +71,7 @@ export const insertPruneToolContext = (
sessionID: lastUserMessage.info.sessionID,
messageID: "msg_01234567890123456789012345",
type: "text",
text: prunableToolsList,
text: prunableToolsList + nudgeString,
}
]
}
Expand Down
4 changes: 3 additions & 1 deletion lib/state/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export function createSessionState(): SessionState {
pruneTokenCounter: 0,
totalPruneTokens: 0,
},
toolParameters: new Map<string, ToolParameterEntry>()
toolParameters: new Map<string, ToolParameterEntry>(),
nudgeCounter: 0
}
}

Expand All @@ -52,6 +53,7 @@ export function resetSessionState(state: SessionState): void {
totalPruneTokens: 0,
}
state.toolParameters.clear()
state.nudgeCounter = 0
}

export async function ensureSessionInitialized(
Expand Down
6 changes: 6 additions & 0 deletions lib/state/tool-cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { SessionState, ToolStatus, WithParts } from "./index"
import type { Logger } from "../logger"
import { PluginConfig } from "../config"

const MAX_TOOL_CACHE_SIZE = 500

Expand All @@ -10,6 +11,7 @@ const MAX_TOOL_CACHE_SIZE = 500
*/
export async function syncToolCache(
state: SessionState,
config: PluginConfig,
logger: Logger,
messages: WithParts[],
): Promise<void> {
Expand All @@ -30,6 +32,10 @@ export async function syncToolCache(
error: part.state.status === "error" ? part.state.error : undefined,
}
)

if (!config.strategies.pruneTool.protectedTools.includes(part.tool)) {
state.nudgeCounter++
}
}
}

Expand Down
1 change: 1 addition & 0 deletions lib/state/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ export interface SessionState {
prune: Prune
stats: SessionStats
toolParameters: Map<string, ToolParameterEntry>
nudgeCounter: number
}
1 change: 1 addition & 0 deletions lib/strategies/prune-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export function createPruneTool(
)
state.stats.totalPruneTokens += state.stats.pruneTokenCounter
state.stats.pruneTokenCounter = 0
state.nudgeCounter = 0

return formatPruningResultForTool(
pruneToolIds,
Expand Down