Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
db09411
Merge pull request #276 from Opencode-DCP/master
Tarquinen Jan 16, 2026
6239897
fix: throw errors on prune tool failures instead of returning strings
Tarquinen Jan 17, 2026
9170831
Merge pull request #277 from Opencode-DCP/fix/prune-tool-error-handling
Tarquinen Jan 17, 2026
b90f5fb
docs: add cache hit rate statistics to README
Tarquinen Jan 18, 2026
660d486
Merge pull request #281 from Opencode-DCP/docs/add-cache-hit-rate-stats
Tarquinen Jan 18, 2026
9810711
feat: add /dcp-stats and /dcp-context commands
Tarquinen Jan 19, 2026
aa10b8e
cleanup
Tarquinen Jan 19, 2026
3312e9a
Merge pull request #282 from Opencode-DCP/feat/dcp-commands
Tarquinen Jan 19, 2026
7ebe524
docs: clarify context_info tool injection mechanism
Tarquinen Jan 19, 2026
11b6843
fix: remove redundant sentence about tool confirmation messages
Tarquinen Jan 19, 2026
095b748
feat: add commands config for enabling/disabling slash commands
Tarquinen Jan 19, 2026
9d2309a
chore: add commands section to JSON schema
Tarquinen Jan 19, 2026
09df074
Merge pull request #284 from Opencode-DCP/feat/commands-config
Tarquinen Jan 19, 2026
31cc864
Merge pull request #283 from Opencode-DCP/docs/clarify-context-info-i…
Tarquinen Jan 19, 2026
0fb9680
refactor: simplify context command UI
Tarquinen Jan 20, 2026
bd042f0
Merge pull request #285 from Opencode-DCP/feat/context-ui-cleanup
Tarquinen Jan 20, 2026
8ea93cc
refactor: unify commands into single /dcp with subcommands
Tarquinen Jan 20, 2026
2cd7c21
Merge pull request #286 from Opencode-DCP/refactor/unified-dcp-command
Tarquinen Jan 20, 2026
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
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

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

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

## Installation

Expand All @@ -19,8 +19,6 @@ Add to your OpenCode config:

Using `@latest` ensures you always get the newest version automatically when OpenCode starts.

> **Note:** If you use OAuth plugins (e.g., for Google or other services), place this plugin last in your `plugin` array to avoid interfering with their authentication flows.

Restart OpenCode. The plugin will automatically start optimizing your sessions.

## How Pruning Works
Expand Down Expand Up @@ -49,6 +47,8 @@ LLM providers like Anthropic and OpenAI cache prompts based on exact prefix matc

**Trade-off:** You lose some cache read benefits but gain larger token savings from reduced context size and performance improvements through reduced context poisoning. In most cases, the token savings outweigh the cache miss cost—especially in long sessions where context bloat becomes significant.

> **Note:** In testing, cache hit rates were approximately 65% with DCP enabled vs 85% without.

**Best use case:** Providers that count usage in requests, such as Github Copilot and Google Antigravity have no negative price impact.

## Configuration
Expand All @@ -71,6 +71,8 @@ DCP uses its own config file:
"debug": false,
// Notification display: "off", "minimal", or "detailed"
"pruneNotification": "detailed",
// Enable or disable slash commands (/dcp)
"commands": true,
// Protect from pruning for <turns> message turns
"turnProtection": {
"enabled": false,
Expand Down Expand Up @@ -126,6 +128,14 @@ DCP uses its own config file:

</details>

### Commands

DCP provides a `/dcp` slash command:

- `/dcp` — Shows available DCP commands
- `/dcp context` — Shows a breakdown of your current session's token usage by category (system, user, assistant, tools, etc.) and how much has been saved through pruning.
- `/dcp stats` — Shows cumulative pruning statistics across all sessions.

### Turn Protection

When enabled, turn protection prevents tool outputs from being pruned for a configurable number of message turns. This gives the AI time to reference recent tool outputs before they become prunable. Applies to both `discard` and `extract` tools, as well as automatic strategies.
Expand Down
Binary file added dcp-demo4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added dcp-demo5.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions dcp.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
"default": "detailed",
"description": "Level of notification shown when pruning occurs"
},
"commands": {
"type": "boolean",
"default": true,
"description": "Enable DCP slash commands (/dcp)"
},
"turnProtection": {
"type": "object",
"description": "Protect recent tool outputs from being pruned",
Expand Down
17 changes: 14 additions & 3 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { getConfig } from "./lib/config"
import { Logger } from "./lib/logger"
import { createSessionState } from "./lib/state"
import { createDiscardTool, createExtractTool } from "./lib/strategies"
import { createChatMessageTransformHandler, createSystemPromptHandler } from "./lib/hooks"
import {
createChatMessageTransformHandler,
createCommandExecuteHandler,
createSystemPromptHandler,
} from "./lib/hooks"

const plugin: Plugin = (async (ctx) => {
const config = getConfig(ctx)
Expand Down Expand Up @@ -64,8 +68,14 @@ const plugin: Plugin = (async (ctx) => {
}),
},
config: async (opencodeConfig) => {
// Add enabled tools to primary_tools by mutating the opencode config
// This works because config is cached and passed by reference
if (config.commands) {
opencodeConfig.command ??= {}
opencodeConfig.command["dcp"] = {
template: "",
description: "Show available DCP commands",
}
}

const toolsToAdd: string[] = []
if (config.tools.discard.enabled) toolsToAdd.push("discard")
if (config.tools.extract.enabled) toolsToAdd.push("extract")
Expand All @@ -81,6 +91,7 @@ const plugin: Plugin = (async (ctx) => {
)
}
},
"command.execute.before": createCommandExecuteHandler(ctx.client, state, logger, config),
}
}) satisfies Plugin

Expand Down
232 changes: 232 additions & 0 deletions lib/commands/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/**
* DCP Context command handler.
* Shows a visual breakdown of token usage in the current session.
*/

import type { Logger } from "../logger"
import type { SessionState, WithParts } from "../state"
import { sendIgnoredMessage } from "../ui/notification"
import { formatTokenCount } from "../ui/utils"
import { isMessageCompacted } from "../shared-utils"
import { isIgnoredUserMessage } from "../messages/utils"
import { countTokens, getCurrentParams } from "../strategies/utils"
import type { AssistantMessage, TextPart, ToolPart } from "@opencode-ai/sdk/v2"

export interface ContextCommandContext {
client: any
state: SessionState
logger: Logger
sessionId: string
messages: WithParts[]
}

interface TokenBreakdown {
system: number
user: number
assistant: number
reasoning: number
tools: number
pruned: number
total: number
}

function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdown {
const breakdown: TokenBreakdown = {
system: 0,
user: 0,
assistant: 0,
reasoning: 0,
tools: 0,
pruned: state.stats.totalPruneTokens,
total: 0,
}

let firstAssistant: AssistantMessage | undefined
for (const msg of messages) {
if (msg.info.role === "assistant") {
const assistantInfo = msg.info as AssistantMessage
if (assistantInfo.tokens?.input > 0 || assistantInfo.tokens?.cache?.read > 0) {
firstAssistant = assistantInfo
break
}
}
}

let firstUserTokens = 0
for (const msg of messages) {
if (msg.info.role === "user" && !isIgnoredUserMessage(msg)) {
for (const part of msg.parts) {
if (part.type === "text") {
const textPart = part as TextPart
firstUserTokens += countTokens(textPart.text || "")
}
}
break
}
}

// Calculate system tokens: first response's total input minus first user message
if (firstAssistant) {
const firstInput =
(firstAssistant.tokens?.input || 0) + (firstAssistant.tokens?.cache?.read || 0)
breakdown.system = Math.max(0, firstInput - firstUserTokens)
}

let lastAssistant: AssistantMessage | undefined
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.info.role === "assistant") {
const assistantInfo = msg.info as AssistantMessage
if (assistantInfo.tokens?.output > 0) {
lastAssistant = assistantInfo
break
}
}
}

// Get total from API
// Total = input + output + reasoning + cache.read + cache.write
const apiInput = lastAssistant?.tokens?.input || 0
const apiOutput = lastAssistant?.tokens?.output || 0
const apiReasoning = lastAssistant?.tokens?.reasoning || 0
const apiCacheRead = lastAssistant?.tokens?.cache?.read || 0
const apiCacheWrite = lastAssistant?.tokens?.cache?.write || 0
const apiTotal = apiInput + apiOutput + apiReasoning + apiCacheRead + apiCacheWrite

for (const msg of messages) {
if (isMessageCompacted(state, msg)) {
continue
}

if (msg.info.role === "user" && isIgnoredUserMessage(msg)) {
continue
}

const info = msg.info
const role = info.role

for (const part of msg.parts) {
switch (part.type) {
case "text": {
const textPart = part as TextPart
const tokens = countTokens(textPart.text || "")
if (role === "user") {
breakdown.user += tokens
} else {
breakdown.assistant += tokens
}
break
}
case "tool": {
const toolPart = part as ToolPart

if (toolPart.state?.input) {
const inputStr =
typeof toolPart.state.input === "string"
? toolPart.state.input
: JSON.stringify(toolPart.state.input)
breakdown.tools += countTokens(inputStr)
}

if (toolPart.state?.status === "completed" && toolPart.state?.output) {
const outputStr =
typeof toolPart.state.output === "string"
? toolPart.state.output
: JSON.stringify(toolPart.state.output)
breakdown.tools += countTokens(outputStr)
}
break
}
}
}
}

breakdown.tools = Math.max(0, breakdown.tools - breakdown.pruned)

// Calculate reasoning as the difference between API total and our counted parts
// This handles both interleaved thinking and non-interleaved models correctly
const countedParts = breakdown.system + breakdown.user + breakdown.assistant + breakdown.tools
breakdown.reasoning = Math.max(0, apiTotal - countedParts)

breakdown.total = apiTotal

return breakdown
}

function createBar(value: number, maxValue: number, width: number, char: string = "█"): string {
if (maxValue === 0) return ""
const filled = Math.round((value / maxValue) * width)
const bar = char.repeat(Math.max(0, filled))
return bar
}

function formatContextMessage(breakdown: TokenBreakdown): string {
const lines: string[] = []
const barWidth = 30

const values = [
breakdown.system,
breakdown.user,
breakdown.assistant,
breakdown.reasoning,
breakdown.tools,
]
const maxValue = Math.max(...values)

const categories = [
{ label: "System", value: breakdown.system, char: "█" },
{ label: "User", value: breakdown.user, char: "▓" },
{ label: "Assistant", value: breakdown.assistant, char: "▒" },
{ label: "Reasoning", value: breakdown.reasoning, char: "░" },
{ label: "Tools", value: breakdown.tools, char: "⣿" },
] as const

lines.push("╭───────────────────────────────────────────────────────────╮")
lines.push("│ DCP Context Analysis │")
lines.push("╰───────────────────────────────────────────────────────────╯")
lines.push("")
lines.push("Session Context Breakdown:")
lines.push("─".repeat(60))
lines.push("")

for (const cat of categories) {
const bar = createBar(cat.value, maxValue, barWidth, cat.char)
const percentage =
breakdown.total > 0 ? ((cat.value / breakdown.total) * 100).toFixed(1) : "0.0"
const labelWithPct = `${cat.label.padEnd(9)} ${percentage.padStart(5)}% `
const valueStr = formatTokenCount(cat.value).padStart(13)
lines.push(`${labelWithPct}│${bar.padEnd(barWidth)}│${valueStr}`)
}

lines.push("")
lines.push("─".repeat(60))
lines.push("")

lines.push("Summary:")

if (breakdown.pruned > 0) {
const withoutPruning = breakdown.total + breakdown.pruned
const savingsPercent = ((breakdown.pruned / withoutPruning) * 100).toFixed(1)
lines.push(
` Current context: ~${formatTokenCount(breakdown.total)} (${savingsPercent}% saved)`,
)
lines.push(` Without DCP: ~${formatTokenCount(withoutPruning)}`)
} else {
lines.push(` Current context: ~${formatTokenCount(breakdown.total)}`)
}

lines.push("")

return lines.join("\n")
}

export async function handleContextCommand(ctx: ContextCommandContext): Promise<void> {
const { client, state, logger, sessionId, messages } = ctx

const breakdown = analyzeTokens(state, messages)

const message = formatContextMessage(breakdown)

const params = getCurrentParams(state, messages, logger)
await sendIgnoredMessage(client, sessionId, message, params, logger)
}
47 changes: 47 additions & 0 deletions lib/commands/help.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* DCP Help command handler.
* Shows available DCP commands and their descriptions.
*/

import type { Logger } from "../logger"
import type { SessionState, WithParts } from "../state"
import { sendIgnoredMessage } from "../ui/notification"
import { getCurrentParams } from "../strategies/utils"

export interface HelpCommandContext {
client: any
state: SessionState
logger: Logger
sessionId: string
messages: WithParts[]
}

function formatHelpMessage(): string {
const lines: string[] = []

lines.push("╭───────────────────────────────────────────────────────────╮")
lines.push("│ DCP Commands │")
lines.push("╰───────────────────────────────────────────────────────────╯")
lines.push("")
lines.push("Available commands:")
lines.push(" context - Show token usage breakdown for current session")
lines.push(" stats - Show DCP pruning statistics")
lines.push("")
lines.push("Examples:")
lines.push(" /dcp context")
lines.push(" /dcp stats")
lines.push("")

return lines.join("\n")
}

export async function handleHelpCommand(ctx: HelpCommandContext): Promise<void> {
const { client, state, logger, sessionId, messages } = ctx

const message = formatHelpMessage()

const params = getCurrentParams(state, messages, logger)
await sendIgnoredMessage(client, sessionId, message, params, logger)

logger.info("Help command executed")
}
Loading