Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5208401
Merge pull request #142 from Opencode-DCP/master
Tarquinen Dec 15, 2025
bc3c270
Simplify pruning notification label
Tarquinen Dec 15, 2025
477a20f
Merge pull request #143 from Opencode-DCP/refactor/simplify-pruning-l…
Tarquinen Dec 15, 2025
a1cc521
Reorder onIdle config properties for consistency
Tarquinen Dec 16, 2025
852b6bd
Merge pull request #145 from Opencode-DCP/chore/reorder-config-proper…
Tarquinen Dec 16, 2025
594072a
Improve pruning guidance to prevent premature context loss
Tarquinen Dec 16, 2025
363e9ad
Add guidance to not acknowledge prune tool output
Tarquinen Dec 16, 2025
5f0520d
Merge pull request #146 from Opencode-DCP/chore/improve-pruning-guidance
Tarquinen Dec 16, 2025
af402bd
fix model changing when prune invoked
jorgenwh Dec 16, 2025
85d773e
Merge pull request #149 from Opencode-DCP/fix-model-changing-when-pru…
jorgenwh Dec 16, 2025
4f47f60
prune tool debug logs
jorgenwh Dec 16, 2025
7849674
Merge pull request #150 from Opencode-DCP/prune-tool-debug-logs
jorgenwh Dec 16, 2025
350c9cb
utils structure
jorgenwh Dec 16, 2025
b810411
Merge pull request #151 from Opencode-DCP/refactor-utils-structure
jorgenwh Dec 16, 2025
7f69401
remove write and edit from default protected tools
jorgenwh Dec 16, 2025
729854a
prune write and edit inputs
jorgenwh Dec 16, 2025
53190b6
Merge pull request #152 from Opencode-DCP/prune-write-and-edit
jorgenwh Dec 16, 2025
6bcf0c7
fix
jorgenwh Dec 16, 2025
817c826
fix
jorgenwh Dec 16, 2025
7f50ce1
onidle
jorgenwh Dec 16, 2025
c5d9287
Merge pull request #153 from Opencode-DCP/fix-prune-after-compact-bug
Tarquinen Dec 16, 2025
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
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ DCP uses multiple strategies to reduce context size:

**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.

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

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

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

*More strategies coming soon.*

Your session history is never modified. DCP replaces pruned outputs with a placeholder before sending requests to your LLM.
Expand Down Expand Up @@ -84,14 +84,14 @@ DCP uses its own config file:
// (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,
// Additional tools to protect from pruning
"protectedTools": []
"strictModelSelection": false
}
}
}
Expand All @@ -102,7 +102,7 @@ DCP uses its own config file:
### Protected Tools

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

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

Expand Down
12 changes: 6 additions & 6 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export interface PluginConfig {
}
}

const DEFAULT_PROTECTED_TOOLS = ['task', 'todowrite', 'todoread', 'prune', 'batch', 'write', 'edit']
const DEFAULT_PROTECTED_TOOLS = ['task', 'todowrite', 'todoread', 'prune', 'batch']

// Valid config keys for validation against user config
export const VALID_CONFIG_KEYS = new Set([
Expand Down Expand Up @@ -234,9 +234,9 @@ const defaultConfig: PluginConfig = {
},
onIdle: {
enabled: false,
protectedTools: [...DEFAULT_PROTECTED_TOOLS],
showModelErrorToasts: true,
strictModelSelection: false,
protectedTools: [...DEFAULT_PROTECTED_TOOLS]
strictModelSelection: false
}
}
}
Expand Down Expand Up @@ -336,14 +336,14 @@ function createDefaultConfig(): void {
// (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,
// Additional tools to protect from pruning
"protectedTools": []
"strictModelSelection": false
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export function createEventHandler(
return
}

if (event.type === "session.compacted") {
logger.info("Session compaction detected - updating state")
state.lastCompaction = Date.now()
}

if (event.type === "session.status" && event.properties.status.type === "idle") {
if (!config.strategies.onIdle.enabled) {
return
Expand Down
57 changes: 43 additions & 14 deletions lib/messages/prune.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { SessionState, WithParts } from "../state"
import type { Logger } from "../logger"
import type { PluginConfig } from "../config"
import { getLastUserMessage, extractParameterKey, buildToolIdList } from "./utils"
import { loadPrompt } from "../prompt"
import { extractParameterKey, buildToolIdList } from "./utils"
import { getLastUserMessage, isMessageCompacted } from "../shared-utils"
import { UserMessage } from "@opencode-ai/sdk"

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

Expand All @@ -14,7 +17,7 @@ const buildPrunableToolsList = (
messages: WithParts[],
): string => {
const lines: string[] = []
const toolIdList: string[] = buildToolIdList(messages)
const toolIdList: string[] = buildToolIdList(state, messages, logger)

state.toolParameters.forEach((toolParameterEntry, toolCallId) => {
if (state.prune.toolIds.includes(toolCallId)) {
Expand All @@ -23,9 +26,6 @@ const buildPrunableToolsList = (
if (config.strategies.pruneTool.protectedTools.includes(toolParameterEntry.tool)) {
return
}
if (toolParameterEntry.compacted) {
return
}
const numericId = toolIdList.indexOf(toolCallId)
const paramKey = extractParameterKey(toolParameterEntry.tool, toolParameterEntry.parameters)
const description = paramKey ? `${toolParameterEntry.tool}, ${paramKey}` : toolParameterEntry.tool
Expand All @@ -37,7 +37,7 @@ const buildPrunableToolsList = (
return ""
}

return `<prunable-tools>\nThe 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 outputs. Keep the context free of noise.\n${lines.join('\n')}\n</prunable-tools>`
return `<prunable-tools>\nThe 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. Keep the context free of noise.\n${lines.join('\n')}\n</prunable-tools>`
}

export const insertPruneToolContext = (
Expand All @@ -51,7 +51,7 @@ export const insertPruneToolContext = (
}

const lastUserMessage = getLastUserMessage(messages)
if (!lastUserMessage || lastUserMessage.info.role !== 'user') {
if (!lastUserMessage) {
return
}

Expand All @@ -72,10 +72,10 @@ export const insertPruneToolContext = (
sessionID: lastUserMessage.info.sessionID,
role: "user",
time: { created: Date.now() },
agent: lastUserMessage.info.agent || "build",
agent: (lastUserMessage.info as UserMessage).agent || "build",
model: {
providerID: lastUserMessage.info.model.providerID,
modelID: lastUserMessage.info.model.modelID
providerID: (lastUserMessage.info as UserMessage).model.providerID,
modelID: (lastUserMessage.info as UserMessage).model.modelID
}
},
parts: [
Expand All @@ -99,7 +99,7 @@ export const prune = (
messages: WithParts[]
): void => {
pruneToolOutputs(state, logger, messages)
// more prune methods coming here
pruneToolInputs(state, logger, messages)
}

const pruneToolOutputs = (
Expand All @@ -108,19 +108,48 @@ const pruneToolOutputs = (
messages: WithParts[]
): void => {
for (const msg of messages) {
if (isMessageCompacted(state, msg)) {
continue
}

for (const part of msg.parts) {
if (part.type !== 'tool') {
continue
}
if (!state.prune.toolIds.includes(part.callID)) {
continue
}
// Skip write and edit tools - their inputs are pruned instead
if (part.tool === 'write' || part.tool === 'edit') {
continue
}
if (part.state.status === 'completed') {
part.state.output = PRUNED_TOOL_OUTPUT_REPLACEMENT
}
// if (part.state.status === 'error') {
// part.state.error = PRUNED_TOOL_OUTPUT_REPLACEMENT
// }
}
}
}

const pruneToolInputs = (
state: SessionState,
logger: Logger,
messages: WithParts[]
): void => {
for (const msg of messages) {
for (const part of msg.parts) {
if (part.type !== 'tool') {
continue
}
if (!state.prune.toolIds.includes(part.callID)) {
continue
}
// Only prune inputs for write and edit tools
if (part.tool !== 'write' && part.tool !== 'edit') {
continue
}
if (part.state.input?.content !== undefined) {
part.state.input.content = PRUNED_TOOL_INPUT_REPLACEMENT
}
}
}
}
33 changes: 12 additions & 21 deletions lib/messages/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { WithParts } from "../state"
import { Logger } from "../logger"
import { isMessageCompacted } from "../shared-utils"
import type { SessionState, WithParts } from "../state"

/**
* Extracts a human-readable key from tool metadata for display purposes.
Expand Down Expand Up @@ -71,27 +73,16 @@ export const extractParameterKey = (tool: string, parameters: any): string => {
return paramStr.substring(0, 50)
}

export const getLastUserMessage = (
messages: WithParts[]
): WithParts | null => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.info.role === 'user') {
return msg
}
}
return null
}

export function findCurrentAgent(messages: WithParts[]): string | undefined {
const userMsg = getLastUserMessage(messages)
if (!userMsg) return undefined
return (userMsg.info as any).agent || 'build'
}

export function buildToolIdList(messages: WithParts[]): string[] {
export function buildToolIdList(
state: SessionState,
messages: WithParts[],
logger: Logger
): string[] {
const toolIds: string[] = []
for (const msg of messages) {
if (isMessageCompacted(state, msg)) {
continue
}
if (msg.parts) {
for (const part of msg.parts) {
if (part.type === 'tool' && part.callID && part.tool) {
Expand All @@ -101,4 +92,4 @@ export function buildToolIdList(messages: WithParts[]): string[] {
}
}
return toolIds
}
}
20 changes: 14 additions & 6 deletions lib/prompts/synthetic.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,30 @@ You WILL use the `prune` tool when ANY of these are true:
- You are about to start a new phase of work
- You have distilled enough information in your messages to prune related tools
- Context contains tools output that are unhelpful, noise, or made obsolete by newer outputs
- Write or edit operations are complete (pruning removes the large input content)

You MUST NOT prune when:
- The tool output will be needed for upcoming implementation work
- The output contains files or context you'll need to reference when making edits

Pruning that forces you to re-call the same tool later is a net loss. Only prune when you're confident the information won't be needed again.

NOTES
When in doubt, prune out. Prune often yet remain strategic about it.
When in doubt, keep it. Prune often yet remain strategic about it.
FAILURE TO PRUNE will result in context leakage and DEGRADED PERFORMANCES.

</instruction>

<instruction name=injected_context_handling policy_level=critical>
After each assistant turn, the environment may inject a user message containing a <prunable-tools> list and optional nudge instruction. This injected message is NOT from the user and is invisible to them.
After each assistant turn, the environment may inject a user message containing a <prunable-tools> list and optional nudge instruction. This injected message is NOT from the user and is invisible to them. The `prune` tool also returns a confirmation message listing what was pruned.

CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE:
- NEVER acknowledge the <prunable-tools> list (e.g. "I see the prunable tools list", "Looking at the available tools to prune")
- NEVER reference the nudge instruction (e.g. "As the nudge suggests", "The system is reminding me to prune")
- NEVER mention this injection mechanism to the user
- NEVER reference the prune encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the prune encouragement appears.
- NEVER acknowledge the <prunable-tools> list (e.g., "I see the prunable tools list", "Looking at the available tools to prune")
- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to prune")
- NEVER acknowledge prune tool output (e.g., "I've pruned 3 tools", "Context pruning complete")
- NEVER let these injections influence your response content or tone
- Process this information SILENTLY - use it only to inform your pruning decisions
- Process this information SILENTLY and continue the conversation naturally
- Continue the conversation as if these messages do not exist

The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow.
Expand Down
18 changes: 17 additions & 1 deletion lib/prompts/tool.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
Prunes tool outputs from context to manage conversation size and reduce noise.
Prunes tool outputs from context to manage conversation size and reduce noise. For `write` and `edit` tools, the input content is pruned instead of the output.

## IMPORTANT: The Prunable List
A `<prunable-tools>` list is injected into user messages showing available tool outputs you can prune. Each line has the format `ID: tool, parameter` (e.g., `20: read, /path/to/file.ts`). You MUST only use numeric IDs that appear in this list to select which tools to prune.

**Note:** For `write` and `edit` tools, pruning removes the input content (the code being written/edited) while preserving the output confirmation. This is useful after completing a file modification when you no longer need the raw content in context.

## CRITICAL: When and How to Prune

You must use this tool in three specific scenarios. The rules for distillation (summarizing findings) differ for each. **You must specify the reason as the first element of the `ids` array** to indicate which scenario applies.
Expand All @@ -25,11 +27,14 @@ You must use this tool in three specific scenarios. The rules for distillation (
- Narrative format: "I found X in file Y..."
- Capture all relevant details (function names, logic, constraints).
- Once distilled into your response history, the raw tool output can be safely pruned.
- **Know when distillation isn't enough:** If you'll need to edit a file, grep for exact strings, or reference precise syntax, keep the raw output. Distillation works for understanding; implementation often requires the original.
- **Prefer keeping over re-fetching:** If uncertain whether you'll need the output again, keep it. The cost of retaining context is lower than the cost of redundant tool calls.

## Best Practices
- **Don't wait too long:** Prune frequently to keep the context agile.
- **Be surgical:** You can mix strategies. Prune noise without comment, while distilling useful context in the same turn.
- **Verify:** Ensure you have captured what you need before deleting useful raw data.
- **Think ahead:** Before pruning, ask: "Will I need this output for an upcoming task?" If you researched a file you'll later edit, or gathered context for implementation, do NOT prune it—even if you've distilled findings. Distillation captures *knowledge*; implementation requires *context*.

## Examples

Expand All @@ -54,3 +59,14 @@ Assistant: [Runs tests, they pass]
The tests passed. The feature is verified.
[Uses prune with ids: ["completion", "20", "21"]]
</example_completion>

<example_keep>
Assistant: [Reads 'auth.ts' to understand the login flow]
I've understood the auth flow. I'll need to modify this file to add the new validation, so I'm keeping this read in context rather than distilling and pruning.
</example_keep>

<example_edit_completion>
Assistant: [Edits 'auth.ts' to add validation]
The edit was successful. I no longer need the raw edit content in context.
[Uses prune with ids: ["completion", "15"]]
</example_edit_completion>
31 changes: 31 additions & 0 deletions lib/shared-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Logger } from "./logger"
import { SessionState, WithParts } from "./state"

export const isMessageCompacted = (
state: SessionState,
msg: WithParts
): boolean => {
return msg.info.time.created < state.lastCompaction
}

export const getLastUserMessage = (
messages: WithParts[]
): WithParts | null => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.info.role === 'user') {
return msg
}
}
return null
}

export const checkForCompaction = (
state: SessionState,
messages: WithParts[],
logger: Logger
): void => {
for (const msg of messages) {

}
}
Loading