Skip to content

Commit 859933d

Browse files
authored
Merge pull request #278 from Opencode-DCP/dev
merge dev into master
2 parents 7b0e2e8 + 2cd7c21 commit 859933d

File tree

20 files changed

+596
-39
lines changed

20 files changed

+596
-39
lines changed

README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

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

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

99
## Installation
1010

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

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

22-
> **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.
23-
2422
Restart OpenCode. The plugin will automatically start optimizing your sessions.
2523

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

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

50+
> **Note:** In testing, cache hit rates were approximately 65% with DCP enabled vs 85% without.
51+
5252
**Best use case:** Providers that count usage in requests, such as Github Copilot and Google Antigravity have no negative price impact.
5353

5454
## Configuration
@@ -71,6 +71,8 @@ DCP uses its own config file:
7171
"debug": false,
7272
// Notification display: "off", "minimal", or "detailed"
7373
"pruneNotification": "detailed",
74+
// Enable or disable slash commands (/dcp)
75+
"commands": true,
7476
// Protect from pruning for <turns> message turns
7577
"turnProtection": {
7678
"enabled": false,
@@ -126,6 +128,14 @@ DCP uses its own config file:
126128

127129
</details>
128130

131+
### Commands
132+
133+
DCP provides a `/dcp` slash command:
134+
135+
- `/dcp` — Shows available DCP commands
136+
- `/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.
137+
- `/dcp stats` — Shows cumulative pruning statistics across all sessions.
138+
129139
### Turn Protection
130140

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

dcp-demo4.png

96 KB
Loading

dcp-demo5.png

94.9 KB
Loading

dcp.schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@
2626
"default": "detailed",
2727
"description": "Level of notification shown when pruning occurs"
2828
},
29+
"commands": {
30+
"type": "boolean",
31+
"default": true,
32+
"description": "Enable DCP slash commands (/dcp)"
33+
},
2934
"turnProtection": {
3035
"type": "object",
3136
"description": "Protect recent tool outputs from being pruned",

index.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { getConfig } from "./lib/config"
33
import { Logger } from "./lib/logger"
44
import { createSessionState } from "./lib/state"
55
import { createDiscardTool, createExtractTool } from "./lib/strategies"
6-
import { createChatMessageTransformHandler, createSystemPromptHandler } from "./lib/hooks"
6+
import {
7+
createChatMessageTransformHandler,
8+
createCommandExecuteHandler,
9+
createSystemPromptHandler,
10+
} from "./lib/hooks"
711

812
const plugin: Plugin = (async (ctx) => {
913
const config = getConfig(ctx)
@@ -64,8 +68,14 @@ const plugin: Plugin = (async (ctx) => {
6468
}),
6569
},
6670
config: async (opencodeConfig) => {
67-
// Add enabled tools to primary_tools by mutating the opencode config
68-
// This works because config is cached and passed by reference
71+
if (config.commands) {
72+
opencodeConfig.command ??= {}
73+
opencodeConfig.command["dcp"] = {
74+
template: "",
75+
description: "Show available DCP commands",
76+
}
77+
}
78+
6979
const toolsToAdd: string[] = []
7080
if (config.tools.discard.enabled) toolsToAdd.push("discard")
7181
if (config.tools.extract.enabled) toolsToAdd.push("extract")
@@ -81,6 +91,7 @@ const plugin: Plugin = (async (ctx) => {
8191
)
8292
}
8393
},
94+
"command.execute.before": createCommandExecuteHandler(ctx.client, state, logger, config),
8495
}
8596
}) satisfies Plugin
8697

lib/commands/context.ts

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/**
2+
* DCP Context command handler.
3+
* Shows a visual breakdown of token usage in the current session.
4+
*/
5+
6+
import type { Logger } from "../logger"
7+
import type { SessionState, WithParts } from "../state"
8+
import { sendIgnoredMessage } from "../ui/notification"
9+
import { formatTokenCount } from "../ui/utils"
10+
import { isMessageCompacted } from "../shared-utils"
11+
import { isIgnoredUserMessage } from "../messages/utils"
12+
import { countTokens, getCurrentParams } from "../strategies/utils"
13+
import type { AssistantMessage, TextPart, ToolPart } from "@opencode-ai/sdk/v2"
14+
15+
export interface ContextCommandContext {
16+
client: any
17+
state: SessionState
18+
logger: Logger
19+
sessionId: string
20+
messages: WithParts[]
21+
}
22+
23+
interface TokenBreakdown {
24+
system: number
25+
user: number
26+
assistant: number
27+
reasoning: number
28+
tools: number
29+
pruned: number
30+
total: number
31+
}
32+
33+
function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdown {
34+
const breakdown: TokenBreakdown = {
35+
system: 0,
36+
user: 0,
37+
assistant: 0,
38+
reasoning: 0,
39+
tools: 0,
40+
pruned: state.stats.totalPruneTokens,
41+
total: 0,
42+
}
43+
44+
let firstAssistant: AssistantMessage | undefined
45+
for (const msg of messages) {
46+
if (msg.info.role === "assistant") {
47+
const assistantInfo = msg.info as AssistantMessage
48+
if (assistantInfo.tokens?.input > 0 || assistantInfo.tokens?.cache?.read > 0) {
49+
firstAssistant = assistantInfo
50+
break
51+
}
52+
}
53+
}
54+
55+
let firstUserTokens = 0
56+
for (const msg of messages) {
57+
if (msg.info.role === "user" && !isIgnoredUserMessage(msg)) {
58+
for (const part of msg.parts) {
59+
if (part.type === "text") {
60+
const textPart = part as TextPart
61+
firstUserTokens += countTokens(textPart.text || "")
62+
}
63+
}
64+
break
65+
}
66+
}
67+
68+
// Calculate system tokens: first response's total input minus first user message
69+
if (firstAssistant) {
70+
const firstInput =
71+
(firstAssistant.tokens?.input || 0) + (firstAssistant.tokens?.cache?.read || 0)
72+
breakdown.system = Math.max(0, firstInput - firstUserTokens)
73+
}
74+
75+
let lastAssistant: AssistantMessage | undefined
76+
for (let i = messages.length - 1; i >= 0; i--) {
77+
const msg = messages[i]
78+
if (msg.info.role === "assistant") {
79+
const assistantInfo = msg.info as AssistantMessage
80+
if (assistantInfo.tokens?.output > 0) {
81+
lastAssistant = assistantInfo
82+
break
83+
}
84+
}
85+
}
86+
87+
// Get total from API
88+
// Total = input + output + reasoning + cache.read + cache.write
89+
const apiInput = lastAssistant?.tokens?.input || 0
90+
const apiOutput = lastAssistant?.tokens?.output || 0
91+
const apiReasoning = lastAssistant?.tokens?.reasoning || 0
92+
const apiCacheRead = lastAssistant?.tokens?.cache?.read || 0
93+
const apiCacheWrite = lastAssistant?.tokens?.cache?.write || 0
94+
const apiTotal = apiInput + apiOutput + apiReasoning + apiCacheRead + apiCacheWrite
95+
96+
for (const msg of messages) {
97+
if (isMessageCompacted(state, msg)) {
98+
continue
99+
}
100+
101+
if (msg.info.role === "user" && isIgnoredUserMessage(msg)) {
102+
continue
103+
}
104+
105+
const info = msg.info
106+
const role = info.role
107+
108+
for (const part of msg.parts) {
109+
switch (part.type) {
110+
case "text": {
111+
const textPart = part as TextPart
112+
const tokens = countTokens(textPart.text || "")
113+
if (role === "user") {
114+
breakdown.user += tokens
115+
} else {
116+
breakdown.assistant += tokens
117+
}
118+
break
119+
}
120+
case "tool": {
121+
const toolPart = part as ToolPart
122+
123+
if (toolPart.state?.input) {
124+
const inputStr =
125+
typeof toolPart.state.input === "string"
126+
? toolPart.state.input
127+
: JSON.stringify(toolPart.state.input)
128+
breakdown.tools += countTokens(inputStr)
129+
}
130+
131+
if (toolPart.state?.status === "completed" && toolPart.state?.output) {
132+
const outputStr =
133+
typeof toolPart.state.output === "string"
134+
? toolPart.state.output
135+
: JSON.stringify(toolPart.state.output)
136+
breakdown.tools += countTokens(outputStr)
137+
}
138+
break
139+
}
140+
}
141+
}
142+
}
143+
144+
breakdown.tools = Math.max(0, breakdown.tools - breakdown.pruned)
145+
146+
// Calculate reasoning as the difference between API total and our counted parts
147+
// This handles both interleaved thinking and non-interleaved models correctly
148+
const countedParts = breakdown.system + breakdown.user + breakdown.assistant + breakdown.tools
149+
breakdown.reasoning = Math.max(0, apiTotal - countedParts)
150+
151+
breakdown.total = apiTotal
152+
153+
return breakdown
154+
}
155+
156+
function createBar(value: number, maxValue: number, width: number, char: string = "█"): string {
157+
if (maxValue === 0) return ""
158+
const filled = Math.round((value / maxValue) * width)
159+
const bar = char.repeat(Math.max(0, filled))
160+
return bar
161+
}
162+
163+
function formatContextMessage(breakdown: TokenBreakdown): string {
164+
const lines: string[] = []
165+
const barWidth = 30
166+
167+
const values = [
168+
breakdown.system,
169+
breakdown.user,
170+
breakdown.assistant,
171+
breakdown.reasoning,
172+
breakdown.tools,
173+
]
174+
const maxValue = Math.max(...values)
175+
176+
const categories = [
177+
{ label: "System", value: breakdown.system, char: "█" },
178+
{ label: "User", value: breakdown.user, char: "▓" },
179+
{ label: "Assistant", value: breakdown.assistant, char: "▒" },
180+
{ label: "Reasoning", value: breakdown.reasoning, char: "░" },
181+
{ label: "Tools", value: breakdown.tools, char: "⣿" },
182+
] as const
183+
184+
lines.push("╭───────────────────────────────────────────────────────────╮")
185+
lines.push("│ DCP Context Analysis │")
186+
lines.push("╰───────────────────────────────────────────────────────────╯")
187+
lines.push("")
188+
lines.push("Session Context Breakdown:")
189+
lines.push("─".repeat(60))
190+
lines.push("")
191+
192+
for (const cat of categories) {
193+
const bar = createBar(cat.value, maxValue, barWidth, cat.char)
194+
const percentage =
195+
breakdown.total > 0 ? ((cat.value / breakdown.total) * 100).toFixed(1) : "0.0"
196+
const labelWithPct = `${cat.label.padEnd(9)} ${percentage.padStart(5)}% `
197+
const valueStr = formatTokenCount(cat.value).padStart(13)
198+
lines.push(`${labelWithPct}${bar.padEnd(barWidth)}${valueStr}`)
199+
}
200+
201+
lines.push("")
202+
lines.push("─".repeat(60))
203+
lines.push("")
204+
205+
lines.push("Summary:")
206+
207+
if (breakdown.pruned > 0) {
208+
const withoutPruning = breakdown.total + breakdown.pruned
209+
const savingsPercent = ((breakdown.pruned / withoutPruning) * 100).toFixed(1)
210+
lines.push(
211+
` Current context: ~${formatTokenCount(breakdown.total)} (${savingsPercent}% saved)`,
212+
)
213+
lines.push(` Without DCP: ~${formatTokenCount(withoutPruning)}`)
214+
} else {
215+
lines.push(` Current context: ~${formatTokenCount(breakdown.total)}`)
216+
}
217+
218+
lines.push("")
219+
220+
return lines.join("\n")
221+
}
222+
223+
export async function handleContextCommand(ctx: ContextCommandContext): Promise<void> {
224+
const { client, state, logger, sessionId, messages } = ctx
225+
226+
const breakdown = analyzeTokens(state, messages)
227+
228+
const message = formatContextMessage(breakdown)
229+
230+
const params = getCurrentParams(state, messages, logger)
231+
await sendIgnoredMessage(client, sessionId, message, params, logger)
232+
}

lib/commands/help.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* DCP Help command handler.
3+
* Shows available DCP commands and their descriptions.
4+
*/
5+
6+
import type { Logger } from "../logger"
7+
import type { SessionState, WithParts } from "../state"
8+
import { sendIgnoredMessage } from "../ui/notification"
9+
import { getCurrentParams } from "../strategies/utils"
10+
11+
export interface HelpCommandContext {
12+
client: any
13+
state: SessionState
14+
logger: Logger
15+
sessionId: string
16+
messages: WithParts[]
17+
}
18+
19+
function formatHelpMessage(): string {
20+
const lines: string[] = []
21+
22+
lines.push("╭───────────────────────────────────────────────────────────╮")
23+
lines.push("│ DCP Commands │")
24+
lines.push("╰───────────────────────────────────────────────────────────╯")
25+
lines.push("")
26+
lines.push("Available commands:")
27+
lines.push(" context - Show token usage breakdown for current session")
28+
lines.push(" stats - Show DCP pruning statistics")
29+
lines.push("")
30+
lines.push("Examples:")
31+
lines.push(" /dcp context")
32+
lines.push(" /dcp stats")
33+
lines.push("")
34+
35+
return lines.join("\n")
36+
}
37+
38+
export async function handleHelpCommand(ctx: HelpCommandContext): Promise<void> {
39+
const { client, state, logger, sessionId, messages } = ctx
40+
41+
const message = formatHelpMessage()
42+
43+
const params = getCurrentParams(state, messages, logger)
44+
await sendIgnoredMessage(client, sessionId, message, params, logger)
45+
46+
logger.info("Help command executed")
47+
}

0 commit comments

Comments
 (0)