Skip to content

Commit 6d32c95

Browse files
authored
Merge pull request #454 from Opencode-DCP/dev
merge dev into master
2 parents fbd75bc + a2fcf64 commit 6d32c95

56 files changed

Lines changed: 5680 additions & 1729 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,14 @@ DCP reduces context size through a compress tool and automatic cleanup. Your ses
2828

2929
### Compress
3030

31-
Compress is a tool exposed to your model that selects a conversation range and replaces it with a technical summary. You can think of this as a much smarter version of Opencode's compaction process. Instead of triggering statically when your session reaches its maximum context and on the entire coding session, Compress allows the model to pick when to activate based on task completion, and to only compress a subset of messages containing the completed task. This allows the summaries replacing the session content to be much more focused and precise than Opencode's native compaction.
31+
Compress is a tool exposed to your model that replaces closed, stale conversation content with high-fidelity technical summaries. You can think of this as a much smarter version of Opencode's compaction process. Instead of triggering statically when your session reaches its maximum context and on the entire coding session, Compress allows the model to pick when to activate based on task completion, and to only compress the specific messages that are no longer needed verbatim.
3232

33-
When a new compression overlaps an earlier one, the earlier summary is nested inside the new one — so information is preserved through layers of compression rather than diluted away. Additionally, protected tool outputs (such as subagents and skills) and protected file patterns are always kept in compression summaries, ensuring that the most important information is never lost. You can also enable `protectUserMessages` to preserve your messages verbatim during compression, though note that large prompts (e.g. copy-pasting log files in the prompt) will then never be compressed away.
33+
DCP supports two compression modes:
34+
35+
- `range` mode compresses contiguous spans of conversation into one or more summaries.
36+
- `message` mode (experimental) compresses individual raw messages independently, letting the model manage context much more surgically.
37+
38+
In `range` mode, when a new compression overlaps an earlier one, the earlier summary is nested inside the new one so information is preserved through layers of compression rather than diluted away. In both modes, protected tool outputs (such as subagents and skills) and protected file patterns are kept in compression summaries, ensuring that the most important information is never lost. You can also enable `protectUserMessages` to preserve your messages verbatim during compression, though note that large prompts (e.g. copy-pasting log files in the prompt) will then never be compressed away.
3439

3540
### Deduplication
3641

@@ -50,6 +55,9 @@ DCP uses its own config file, searched in order:
5055

5156
Each level overrides the previous, so project settings take priority over global. Restart OpenCode after making config changes.
5257

58+
> [!NOTE]
59+
> If you use models with smaller context windows, such as GitHub Copilot models or local models, lower `compress.minContextLimit` and `compress.maxContextLimit` in your configuration to match the available context.
60+
5361
> [!IMPORTANT]
5462
> Defaults are applied automatically. Expand this if you want to review or override settings.
5563
@@ -99,14 +107,19 @@ Each level overrides the previous, so project settings take priority over global
99107
"protectedFilePatterns": [],
100108
// Unified context compression tool and behavior settings
101109
"compress": {
110+
// Compression mode: "range" (compress spans into block summaries)
111+
// or experimental "message" (compress individual raw messages)
112+
"mode": "range",
102113
// Permission mode: "allow" (no prompt), "ask" (prompt), "deny" (tool not registered)
103114
"permission": "allow",
104115
// Show compression content in a chat notification
105116
"showCompression": false,
117+
// Let active summary tokens extend the effective maxContextLimit
118+
"summaryBuffer": true,
106119
// Soft upper threshold: above this, DCP keeps injecting strong
107120
// compression nudges (based on nudgeFrequency), so compression is
108121
// much more likely. Accepts: number or "X%" of model context window.
109-
"maxContextLimit": 150000,
122+
"maxContextLimit": 100000,
110123
// Soft lower threshold for reminder nudges: below this, turn/iteration
111124
// reminders are off (compression less likely). At/above this, reminders
112125
// are on. Accepts: number or "X%" of model context window.
@@ -133,8 +146,6 @@ Each level overrides the previous, so project settings take priority over global
133146
// Controls how likely compression is after user messages
134147
// ("strong" = more likely, "soft" = less likely)
135148
"nudgeForce": "soft",
136-
// Flat tool schema: improves tool call reliability but uglier in the TUI
137-
"flatSchema": false,
138149
// Tool names whose completed outputs are appended to the compression
139150
"protectedTools": [],
140151
// Preserve your messages during compression.
@@ -149,10 +160,6 @@ Each level overrides the previous, so project settings take priority over global
149160
// Additional tools to protect from pruning
150161
"protectedTools": [],
151162
},
152-
// Prune write tool inputs when the file has been subsequently read
153-
"supersedeWrites": {
154-
"enabled": true,
155-
},
156163
// Prune tool inputs for errored tools after X turns
157164
"purgeErrors": {
158165
"enabled": true,
@@ -176,16 +183,17 @@ DCP provides a `/dcp` slash command:
176183
- `/dcp stats` — Shows cumulative pruning statistics across all sessions.
177184
- `/dcp sweep` — Prunes all tools since the last user message. Accepts an optional count: `/dcp sweep 10` prunes the last 10 tools. Respects `commands.protectedTools`.
178185
- `/dcp manual [on|off]` — Toggle manual mode or set explicit state. When on, the AI will not autonomously use context management tools.
179-
- `/dcp compress [focus]` — Trigger a single compress tool execution. Optional focus text directs what range to compress.
186+
- `/dcp compress [focus]` — Trigger a single compress tool execution. Optional focus text directs what content to compress, following the active `compress.mode`.
180187
- `/dcp decompress <n>` — Restore a specific active compression by ID (for example `/dcp decompress 2`). Running without an argument shows available compression IDs, token sizes, and topics.
181188
- `/dcp recompress <n>` — Re-apply a user-decompressed compression by ID (for example `/dcp recompress 2`). Running without an argument shows recompressible IDs, token sizes, and topics.
182189

183190
### Prompt Overrides
184191

185-
DCP exposes five editable prompts:
192+
DCP exposes six editable prompts:
186193

187194
- `system`
188-
- `compress`
195+
- `compress-range`
196+
- `compress-message`
189197
- `context-limit-nudge`
190198
- `turn-nudge`
191199
- `iteration-nudge`
@@ -198,17 +206,14 @@ To customize behavior, add a file with the same name under an overrides director
198206

199207
To reset an override, delete the matching file from your overrides directory.
200208

201-
> [!NOTE]
202-
> `compress` prompt changes apply after plugin restart because tool descriptions are registered at startup.
203-
204209
### Protected Tools
205210

206211
By default, these tools are always protected from pruning:
207-
`task`, `skill`, `todowrite`, `todoread`, `compress`, `batch`, `plan_enter`, `plan_exit`
212+
`task`, `skill`, `todowrite`, `todoread`, `compress`, `batch`, `plan_enter`, `plan_exit`, `write`, `edit`
208213

209214
The `protectedTools` arrays in `commands` and `strategies` add to this default list.
210215

211-
For the `compress` tool, `compress.protectedTools` ensures specific tool outputs are appended to the compressed summary. It defaults to an empty array `[]` but always inherently protects `task`, `skill`, `todowrite`, and `todoread`.
216+
For the `compress` tool, `compress.protectedTools` ensures specific tool outputs are appended to the compressed summary. By default it includes `task`, `skill`, `todowrite`, and `todoread`.
212217

213218
## Impact on Prompt Caching
214219

dcp.schema.json

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
"automaticStrategies": {
7070
"type": "boolean",
7171
"default": true,
72-
"description": "When manual mode is enabled, keep automatic deduplication/supersede/purge strategies running"
72+
"description": "When manual mode is enabled, keep automatic deduplication/purge strategies running"
7373
}
7474
},
7575
"default": {
@@ -128,6 +128,12 @@
128128
"description": "Configuration for the unified compress tool",
129129
"additionalProperties": false,
130130
"properties": {
131+
"mode": {
132+
"type": "string",
133+
"enum": ["range", "message"],
134+
"default": "range",
135+
"description": "Compression mode. 'range' compresses spans into block summaries, 'message' compresses individual raw messages."
136+
},
131137
"permission": {
132138
"type": "string",
133139
"enum": ["ask", "allow", "deny"],
@@ -139,9 +145,14 @@
139145
"default": false,
140146
"description": "Show compression summaries in notifications"
141147
},
148+
"summaryBuffer": {
149+
"type": "boolean",
150+
"default": true,
151+
"description": "When enabled, active summary tokens extend the effective maxContextLimit used for context-limit nudges."
152+
},
142153
"maxContextLimit": {
143154
"description": "Soft upper threshold. Above this, DCP keeps sending strong compression nudges (based on nudgeFrequency), so the model is pushed to compress. Accepts number or \"X%\" of the model context window.",
144-
"default": 150000,
155+
"default": 100000,
145156
"oneOf": [
146157
{
147158
"type": "number"
@@ -213,11 +224,6 @@
213224
"default": "soft",
214225
"description": "Controls how likely compression is after user messages. 'strong' is more likely, 'soft' is less likely."
215226
},
216-
"flatSchema": {
217-
"type": "boolean",
218-
"default": false,
219-
"description": "When true, the compress tool schema uses 4 flat string parameters (topic, startId, endId, summary) instead of the nested content object. This simplifies tool calls but changes TUI display."
220-
},
221227
"protectedTools": {
222228
"type": "array",
223229
"items": {
@@ -231,6 +237,19 @@
231237
"default": false,
232238
"description": "When enabled, your messages are never lost during compression"
233239
}
240+
},
241+
"default": {
242+
"mode": "range",
243+
"permission": "allow",
244+
"showCompression": false,
245+
"summaryBuffer": true,
246+
"maxContextLimit": 100000,
247+
"minContextLimit": 50000,
248+
"nudgeFrequency": 5,
249+
"iterationNudgeThreshold": 15,
250+
"nudgeForce": "soft",
251+
"protectedTools": [],
252+
"protectUserMessages": false
234253
}
235254
},
236255
"strategies": {
@@ -258,18 +277,6 @@
258277
}
259278
}
260279
},
261-
"supersedeWrites": {
262-
"type": "object",
263-
"description": "Replace older write/edit outputs when new ones target the same file",
264-
"additionalProperties": false,
265-
"properties": {
266-
"enabled": {
267-
"type": "boolean",
268-
"default": true,
269-
"description": "Enable supersede writes strategy"
270-
}
271-
}
272-
},
273280
"purgeErrors": {
274281
"type": "object",
275282
"description": "Remove tool outputs that resulted in errors",

index.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import type { Plugin } from "@opencode-ai/plugin"
22
import { getConfig } from "./lib/config"
3+
import { createCompressMessageTool, createCompressRangeTool } from "./lib/compress"
34
import {
45
compressDisabledByOpencode,
56
hasExplicitToolPermission,
67
type HostPermissionSnapshot,
78
} from "./lib/host-permissions"
89
import { Logger } from "./lib/logger"
910
import { createSessionState } from "./lib/state"
10-
import { createCompressTool } from "./lib/tools"
1111
import { PromptStore } from "./lib/prompts/store"
1212
import {
1313
createChatMessageTransformHandler,
@@ -41,6 +41,14 @@ const plugin: Plugin = (async (ctx) => {
4141
strategies: config.strategies,
4242
})
4343

44+
const compressToolContext = {
45+
client: ctx.client,
46+
state,
47+
logger,
48+
config,
49+
prompts,
50+
}
51+
4452
return {
4553
"experimental.chat.system.transform": createSystemPromptHandler(
4654
state,
@@ -81,14 +89,10 @@ const plugin: Plugin = (async (ctx) => {
8189
),
8290
tool: {
8391
...(config.compress.permission !== "deny" && {
84-
compress: createCompressTool({
85-
client: ctx.client,
86-
state,
87-
logger,
88-
config,
89-
workingDirectory: ctx.directory,
90-
prompts,
91-
}),
92+
compress:
93+
config.compress.mode === "message"
94+
? createCompressMessageTool(compressToolContext)
95+
: createCompressRangeTool(compressToolContext),
9296
}),
9397
},
9498
config: async (opencodeConfig) => {
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import type { CompressionBlock, PruneMessagesState } from "../state"
2+
3+
export interface CompressionTarget {
4+
displayId: number
5+
runId: number
6+
topic: string
7+
compressedTokens: number
8+
grouped: boolean
9+
blocks: CompressionBlock[]
10+
}
11+
12+
function byBlockId(a: CompressionBlock, b: CompressionBlock): number {
13+
return a.blockId - b.blockId
14+
}
15+
16+
function buildTarget(blocks: CompressionBlock[]): CompressionTarget {
17+
const ordered = [...blocks].sort(byBlockId)
18+
const first = ordered[0]
19+
if (!first) {
20+
throw new Error("Cannot build compression target from empty block list.")
21+
}
22+
23+
const grouped = first.mode === "message"
24+
return {
25+
displayId: first.blockId,
26+
runId: first.runId,
27+
topic: grouped ? first.batchTopic || first.topic : first.topic,
28+
compressedTokens: ordered.reduce((total, block) => total + block.compressedTokens, 0),
29+
grouped,
30+
blocks: ordered,
31+
}
32+
}
33+
34+
function groupMessageBlocks(blocks: CompressionBlock[]): CompressionTarget[] {
35+
const grouped = new Map<number, CompressionBlock[]>()
36+
37+
for (const block of blocks) {
38+
const existing = grouped.get(block.runId)
39+
if (existing) {
40+
existing.push(block)
41+
continue
42+
}
43+
grouped.set(block.runId, [block])
44+
}
45+
46+
return Array.from(grouped.values()).map(buildTarget)
47+
}
48+
49+
function splitTargets(blocks: CompressionBlock[]): CompressionTarget[] {
50+
const messageBlocks: CompressionBlock[] = []
51+
const singleBlocks: CompressionBlock[] = []
52+
53+
for (const block of blocks) {
54+
if (block.mode === "message") {
55+
messageBlocks.push(block)
56+
} else {
57+
singleBlocks.push(block)
58+
}
59+
}
60+
61+
const targets = [
62+
...singleBlocks.map((block) => buildTarget([block])),
63+
...groupMessageBlocks(messageBlocks),
64+
]
65+
return targets.sort((a, b) => a.displayId - b.displayId)
66+
}
67+
68+
export function getActiveCompressionTargets(
69+
messagesState: PruneMessagesState,
70+
): CompressionTarget[] {
71+
const activeBlocks = Array.from(messagesState.activeBlockIds)
72+
.map((blockId) => messagesState.blocksById.get(blockId))
73+
.filter((block): block is CompressionBlock => !!block && block.active)
74+
75+
return splitTargets(activeBlocks)
76+
}
77+
78+
export function getRecompressibleCompressionTargets(
79+
messagesState: PruneMessagesState,
80+
availableMessageIds: Set<string>,
81+
): CompressionTarget[] {
82+
const allBlocks = Array.from(messagesState.blocksById.values()).filter((block) => {
83+
return availableMessageIds.has(block.compressMessageId)
84+
})
85+
86+
const messageGroups = new Map<number, CompressionBlock[]>()
87+
const singleTargets: CompressionTarget[] = []
88+
89+
for (const block of allBlocks) {
90+
if (block.mode === "message") {
91+
const existing = messageGroups.get(block.runId)
92+
if (existing) {
93+
existing.push(block)
94+
} else {
95+
messageGroups.set(block.runId, [block])
96+
}
97+
continue
98+
}
99+
100+
if (block.deactivatedByUser && !block.active) {
101+
singleTargets.push(buildTarget([block]))
102+
}
103+
}
104+
105+
for (const blocks of messageGroups.values()) {
106+
if (blocks.some((block) => block.deactivatedByUser && !block.active)) {
107+
singleTargets.push(buildTarget(blocks))
108+
}
109+
}
110+
111+
return singleTargets.sort((a, b) => a.displayId - b.displayId)
112+
}
113+
114+
export function resolveCompressionTarget(
115+
messagesState: PruneMessagesState,
116+
blockId: number,
117+
): CompressionTarget | null {
118+
const block = messagesState.blocksById.get(blockId)
119+
if (!block) {
120+
return null
121+
}
122+
123+
if (block.mode !== "message") {
124+
return buildTarget([block])
125+
}
126+
127+
const blocks = Array.from(messagesState.blocksById.values()).filter(
128+
(candidate) => candidate.mode === "message" && candidate.runId === block.runId,
129+
)
130+
if (blocks.length === 0) {
131+
return null
132+
}
133+
134+
return buildTarget(blocks)
135+
}

lib/commands/context.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdo
134134
const isCompacted = isMessageCompacted(state, msg)
135135
const pruneEntry = state.prune.messages.byMessageId.get(msg.info.id)
136136
const isMessagePruned = !!pruneEntry && pruneEntry.activeBlockIds.length > 0
137-
const isIgnoredUser = msg.info.role === "user" && isIgnoredUserMessage(msg)
137+
const isIgnoredUser = isIgnoredUserMessage(msg)
138138

139139
for (const part of parts) {
140140
if (part.type === "tool") {

0 commit comments

Comments
 (0)