Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![npm version](https://img.shields.io/npm/v/@tarquinen/opencode-dcp.svg)](https://www.npmjs.com/package/@tarquinen/opencode-dcp)

Automatically reduces token usage in OpenCode by removing obsolete tool outputs from conversation history.
Automatically reduces token usage in OpenCode by removing obsolete tools from conversation history.

![DCP in action](dcp-demo5.png)

Expand Down
3 changes: 2 additions & 1 deletion lib/commands/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdo
if (isMessageCompacted(state, msg)) continue
if (msg.info.role === "user" && isIgnoredUserMessage(msg)) continue

for (const part of msg.parts) {
const parts = Array.isArray(msg.parts) ? msg.parts : []
for (const part of parts) {
if (part.type === "text" && msg.info.role === "user") {
const textPart = part as TextPart
const text = textPart.text || ""
Expand Down
5 changes: 3 additions & 2 deletions lib/commands/sweep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ function collectToolIdsAfterIndex(
if (isMessageCompacted(state, msg)) {
continue
}
if (msg.parts) {
for (const part of msg.parts) {
const parts = Array.isArray(msg.parts) ? msg.parts : []
if (parts.length > 0) {
for (const part of parts) {
if (part.type === "tool" && part.callID && part.tool) {
toolIds.push(part.callID)
}
Expand Down
64 changes: 1 addition & 63 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,69 +531,7 @@ function createDefaultConfig(): void {
}

const configContent = `{
"$schema": "https://raw.githubusercontent.com/Opencode-DCP/opencode-dynamic-context-pruning/master/dcp.schema.json",
// Enable or disable the plugin
"enabled": true,
// Enable debug logging to ~/.config/opencode/logs/dcp/
"debug": false,
// Notification display: "off", "minimal", or "detailed"
"pruneNotification": "detailed",
// Slash commands (/dcp) configuration
"commands": {
"enabled": true,
// Additional tools to protect from pruning via commands
"protectedTools": []
},
// Protect from pruning for <turns> message turns
"turnProtection": {
"enabled": false,
"turns": 4
},
// Protect file operations from pruning via glob patterns
// Patterns match tool parameters.filePath (e.g. read/write/edit)
"protectedFilePatterns": [],
// LLM-driven context pruning tools
"tools": {
// Shared settings for all prune tools
"settings": {
// Nudge the LLM to use prune tools (every <nudgeFrequency> tool results)
"nudgeEnabled": true,
"nudgeFrequency": 10,
// Additional tools to protect from pruning
"protectedTools": []
},
// Removes tool content from context without preservation (for completed tasks or noise)
"discard": {
"enabled": true
},
// Distills key findings into preserved knowledge before removing raw content
"extract": {
"enabled": true,
// Show distillation content as an ignored message notification
"showDistillation": false
}
},
// Automatic pruning strategies
"strategies": {
// Remove duplicate tool calls (same tool with same arguments)
"deduplication": {
"enabled": true,
// Additional tools to protect from pruning
"protectedTools": []
},
// Prune write tool inputs when the file has been subsequently read
"supersedeWrites": {
"enabled": false
},
// Prune tool inputs for errored tools after X turns
"purgeErrors": {
"enabled": true,
// Number of turns before errored tool inputs are pruned
"turns": 4,
// Additional tools to protect from pruning
"protectedTools": []
}
}
"$schema": "https://raw.githubusercontent.com/Opencode-DCP/opencode-dynamic-context-pruning/master/dcp.schema.json"
}
`
writeFileSync(GLOBAL_CONFIG_PATH_JSONC, configContent, "utf-8")
Expand Down
23 changes: 13 additions & 10 deletions lib/messages/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
extractParameterKey,
buildToolIdList,
createSyntheticAssistantMessage,
createSyntheticUserMessage,
isIgnoredUserMessage,
} from "./utils"
import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns"
Expand Down Expand Up @@ -138,16 +139,18 @@ export const insertPruneToolContext = (
return
}

// Never inject immediately following a user message - wait until assistant has started its turn
// This avoids interfering with model reasoning/thinking phases
// TODO: This can be skipped if there is a good way to check if the model has reasoning,
// can't find a good way to do this yet
const lastMessage = messages[messages.length - 1]
if (lastMessage?.info?.role === "user" && !isIgnoredUserMessage(lastMessage)) {
return
}

const userInfo = lastUserMessage.info as UserMessage
const variant = state.variant ?? userInfo.variant
messages.push(createSyntheticAssistantMessage(lastUserMessage, prunableToolsContent, variant))

const lastMessage = messages[messages.length - 1]
const isLastMessageUser =
lastMessage?.info?.role === "user" && !isIgnoredUserMessage(lastMessage)

if (isLastMessageUser) {
messages.push(createSyntheticUserMessage(lastUserMessage, prunableToolsContent, variant))
} else {
messages.push(
createSyntheticAssistantMessage(lastUserMessage, prunableToolsContent, variant),
)
}
}
9 changes: 6 additions & 3 deletions lib/messages/prune.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ const pruneToolOutputs = (state: SessionState, logger: Logger, messages: WithPar
continue
}

for (const part of msg.parts) {
const parts = Array.isArray(msg.parts) ? msg.parts : []
for (const part of parts) {
if (part.type !== "tool") {
continue
}
Expand All @@ -50,7 +51,8 @@ const pruneToolInputs = (state: SessionState, logger: Logger, messages: WithPart
continue
}

for (const part of msg.parts) {
const parts = Array.isArray(msg.parts) ? msg.parts : []
for (const part of parts) {
if (part.type !== "tool") {
continue
}
Expand All @@ -77,7 +79,8 @@ const pruneToolErrors = (state: SessionState, logger: Logger, messages: WithPart
continue
}

for (const part of msg.parts) {
const parts = Array.isArray(msg.parts) ? msg.parts : []
for (const part of parts) {
if (part.type !== "tool") {
continue
}
Expand Down
40 changes: 36 additions & 4 deletions lib/messages/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,36 @@ const isGeminiModel = (modelID: string): boolean => {
return lowerModelID.includes("gemini")
}

export const createSyntheticUserMessage = (
baseMessage: WithParts,
content: string,
variant?: string,
): WithParts => {
const userInfo = baseMessage.info as UserMessage
const now = Date.now()

return {
info: {
id: SYNTHETIC_MESSAGE_ID,
sessionID: userInfo.sessionID,
role: "user" as const,
agent: userInfo.agent || "code",
model: userInfo.model,
time: { created: now },
...(variant !== undefined && { variant }),
},
parts: [
{
id: SYNTHETIC_PART_ID,
sessionID: userInfo.sessionID,
messageID: SYNTHETIC_MESSAGE_ID,
type: "text",
text: content,
},
],
}
}

export const createSyntheticAssistantMessage = (
baseMessage: WithParts,
content: string,
Expand Down Expand Up @@ -197,8 +227,9 @@ export function buildToolIdList(
if (isMessageCompacted(state, msg)) {
continue
}
if (msg.parts) {
for (const part of msg.parts) {
const parts = Array.isArray(msg.parts) ? msg.parts : []
if (parts.length > 0) {
for (const part of parts) {
if (part.type === "tool" && part.callID && part.tool) {
toolIds.push(part.callID)
}
Expand All @@ -209,11 +240,12 @@ export function buildToolIdList(
}

export const isIgnoredUserMessage = (message: WithParts): boolean => {
if (!message.parts || message.parts.length === 0) {
const parts = Array.isArray(message.parts) ? message.parts : []
if (parts.length === 0) {
return true
}

for (const part of message.parts) {
for (const part of parts) {
if (!(part as any).ignored) {
return false
}
Expand Down
2 changes: 1 addition & 1 deletion lib/prompts/discard-tool-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Use \`discard\` for removing tool content that is no longer needed

## When NOT to Use This Tool

- **If the output contains useful information:** Use \`extract\` instead to preserve key findings.
- **If the output contains useful information:** Keep it in context rather than discarding.
- **If you'll need the output later:** Don't discard files you plan to edit or context you'll need for implementation.

## Best Practices
Expand Down
4 changes: 2 additions & 2 deletions lib/prompts/system/both.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export const SYSTEM_PROMPT_BOTH = `<system-reminder>
<instruction name=context_management_protocol policy_level=critical>

ENVIRONMENT
You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` and \`extract\` tools. The environment calls the \`context_info\` tool to provide an up-to-date <prunable-tools> list after each assistant turn. Use this information when deciding what to prune.
You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` and \`extract\` tools. The environment calls the \`context_info\` tool to provide an up-to-date <prunable-tools> list after each turn. Use this information when deciding what to prune.

IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it.

Expand Down Expand Up @@ -44,7 +44,7 @@ There may be tools in session context that do not appear in the <prunable-tools>
</instruction>

<instruction name=injected_context_handling policy_level=critical>
After each assistant turn, the environment calls the \`context_info\` tool to inject an assistant message containing a <prunable-tools> list and optional nudge instruction. This tool is only available to the environment - you do not have access to it.
After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a <prunable-tools> list and optional nudge instruction. This tool is only available to the environment - you do not have access to it.

CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE:
- NEVER reference the prune encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the prune encouragement appears.
Expand Down
4 changes: 2 additions & 2 deletions lib/prompts/system/discard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export const SYSTEM_PROMPT_DISCARD = `<system-reminder>
<instruction name=context_management_protocol policy_level=critical>

ENVIRONMENT
You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` tool. The environment calls the \`context_info\` tool to provide an up-to-date <prunable-tools> list after each assistant turn. Use this information when deciding what to discard.
You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` tool. The environment calls the \`context_info\` tool to provide an up-to-date <prunable-tools> list after each turn. Use this information when deciding what to discard.

IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it.

Expand Down Expand Up @@ -35,7 +35,7 @@ There may be tools in session context that do not appear in the <prunable-tools>
</instruction>

<instruction name=injected_context_handling policy_level=critical>
After each assistant turn, the environment calls the \`context_info\` tool to inject an assistant message containing a <prunable-tools> list and optional nudge instruction. This tool is only available to the environment - you do not have access to it.
After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a <prunable-tools> list and optional nudge instruction. This tool is only available to the environment - you do not have access to it.

CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE:
- NEVER reference the discard encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the discard encouragement appears.
Expand Down
4 changes: 2 additions & 2 deletions lib/prompts/system/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export const SYSTEM_PROMPT_EXTRACT = `<system-reminder>
<instruction name=context_management_protocol policy_level=critical>

ENVIRONMENT
You are operating in a context-constrained environment and thus must proactively manage your context window using the \`extract\` tool. The environment calls the \`context_info\` tool to provide an up-to-date <prunable-tools> list after each assistant turn. Use this information when deciding what to extract.
You are operating in a context-constrained environment and thus must proactively manage your context window using the \`extract\` tool. The environment calls the \`context_info\` tool to provide an up-to-date <prunable-tools> list after each turn. Use this information when deciding what to extract.

IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it.

Expand Down Expand Up @@ -35,7 +35,7 @@ There may be tools in session context that do not appear in the <prunable-tools>
</instruction>

<instruction name=injected_context_handling policy_level=critical>
After each assistant turn, the environment calls the \`context_info\` tool to inject an assistant message containing a <prunable-tools> list and optional nudge instruction. This tool is only available to the environment - you do not have access to it.
After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a <prunable-tools> list and optional nudge instruction. This tool is only available to the environment - you do not have access to it.

CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE:
- NEVER reference the extract encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the extract encouragement appears.
Expand Down
3 changes: 2 additions & 1 deletion lib/state/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ export function countTurns(state: SessionState, messages: WithParts[]): number {
if (isMessageCompacted(state, msg)) {
continue
}
for (const part of msg.parts) {
const parts = Array.isArray(msg.parts) ? msg.parts : []
for (const part of parts) {
if (part.type === "step-start") {
turnCount++
}
Expand Down
3 changes: 2 additions & 1 deletion lib/state/tool-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ export async function syncToolCache(
continue
}

for (const part of msg.parts) {
const parts = Array.isArray(msg.parts) ? msg.parts : []
for (const part of parts) {
if (part.type === "step-start") {
turnCounter++
continue
Expand Down
3 changes: 2 additions & 1 deletion lib/strategies/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ export const calculateTokensSaved = (
if (isMessageCompacted(state, msg)) {
continue
}
for (const part of msg.parts) {
const parts = Array.isArray(msg.parts) ? msg.parts : []
for (const part of parts) {
if (part.type !== "tool" || !pruneToolIds.includes(part.callID)) {
continue
}
Expand Down
Loading