Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b75aad8
compress: add message mode
Tarquinen Mar 17, 2026
baf6e1d
simplify compress mode handling
Tarquinen Mar 18, 2026
111ca2a
remove message arg normalization
Tarquinen Mar 18, 2026
8d04298
simplify compress notifications
Tarquinen Mar 18, 2026
acb6f51
sync config docs and schema
Tarquinen Mar 18, 2026
08c7fcd
remove stale compress mode text
Tarquinen Mar 18, 2026
9d1f053
batch compress tool inputs
Tarquinen Mar 20, 2026
d480b17
clean up compress config leftovers
Tarquinen Mar 20, 2026
7e850dc
refactor compress module layout
Tarquinen Mar 22, 2026
7116280
group compression runs by tool call
Tarquinen Mar 22, 2026
9c8aa18
document experimental message mode
Tarquinen Mar 22, 2026
41d1aea
prioritize high-cost message compression
Tarquinen Mar 23, 2026
6ddb0f2
add message token count inspector
Tarquinen Mar 23, 2026
fcfebbe
separate prompt responsibilities between system and tool specs
Tarquinen Mar 23, 2026
3da14ff
append injections to text parts
Tarquinen Mar 23, 2026
aa4f76a
mark blocked refs in message mode
Tarquinen Mar 23, 2026
9ca33e4
prefer tool output for assistant ids
Tarquinen Mar 23, 2026
5a13480
clean up unused compression types
Tarquinen Mar 23, 2026
5d5b8c2
Merge pull request #446 from Opencode-DCP/feat/compress-message-mode
Tarquinen Mar 23, 2026
90d4089
fix stale tokens after /compact (#450)
Tarquinen Mar 23, 2026
425339f
reset message ids after /compact
Tarquinen Mar 24, 2026
592a5dd
inject ids into all parts
Tarquinen Mar 24, 2026
2dc15e1
prioritize compress tool messages
Tarquinen Mar 24, 2026
ee9ac10
count all tool inputs
Tarquinen Mar 24, 2026
8562ccc
refactor isIgnoredUserMessage contract
Tarquinen Mar 24, 2026
187b9ba
add summary buffer config
Tarquinen Mar 25, 2026
a2fcf64
Merge branch 'master' into dev
Tarquinen Mar 25, 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
39 changes: 22 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,14 @@ DCP reduces context size through a compress tool and automatic cleanup. Your ses

### Compress

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

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.
DCP supports two compression modes:

- `range` mode compresses contiguous spans of conversation into one or more summaries.
- `message` mode (experimental) compresses individual raw messages independently, letting the model manage context much more surgically.

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.

### Deduplication

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

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

> [!NOTE]
> 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.
> [!IMPORTANT]
> Defaults are applied automatically. Expand this if you want to review or override settings.
Expand Down Expand Up @@ -99,14 +107,19 @@ Each level overrides the previous, so project settings take priority over global
"protectedFilePatterns": [],
// Unified context compression tool and behavior settings
"compress": {
// Compression mode: "range" (compress spans into block summaries)
// or experimental "message" (compress individual raw messages)
"mode": "range",
// Permission mode: "allow" (no prompt), "ask" (prompt), "deny" (tool not registered)
"permission": "allow",
// Show compression content in a chat notification
"showCompression": false,
// Let active summary tokens extend the effective maxContextLimit
"summaryBuffer": true,
// Soft upper threshold: above this, DCP keeps injecting strong
// compression nudges (based on nudgeFrequency), so compression is
// much more likely. Accepts: number or "X%" of model context window.
"maxContextLimit": 150000,
"maxContextLimit": 100000,
// Soft lower threshold for reminder nudges: below this, turn/iteration
// reminders are off (compression less likely). At/above this, reminders
// are on. Accepts: number or "X%" of model context window.
Expand All @@ -133,8 +146,6 @@ Each level overrides the previous, so project settings take priority over global
// Controls how likely compression is after user messages
// ("strong" = more likely, "soft" = less likely)
"nudgeForce": "soft",
// Flat tool schema: improves tool call reliability but uglier in the TUI
"flatSchema": false,
// Tool names whose completed outputs are appended to the compression
"protectedTools": [],
// Preserve your messages during compression.
Expand All @@ -149,10 +160,6 @@ Each level overrides the previous, so project settings take priority over global
// Additional tools to protect from pruning
"protectedTools": [],
},
// Prune write tool inputs when the file has been subsequently read
"supersedeWrites": {
"enabled": true,
},
// Prune tool inputs for errored tools after X turns
"purgeErrors": {
"enabled": true,
Expand All @@ -176,16 +183,17 @@ DCP provides a `/dcp` slash command:
- `/dcp stats` — Shows cumulative pruning statistics across all sessions.
- `/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`.
- `/dcp manual [on|off]` — Toggle manual mode or set explicit state. When on, the AI will not autonomously use context management tools.
- `/dcp compress [focus]` — Trigger a single compress tool execution. Optional focus text directs what range to compress.
- `/dcp compress [focus]` — Trigger a single compress tool execution. Optional focus text directs what content to compress, following the active `compress.mode`.
- `/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.
- `/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.

### Prompt Overrides

DCP exposes five editable prompts:
DCP exposes six editable prompts:

- `system`
- `compress`
- `compress-range`
- `compress-message`
- `context-limit-nudge`
- `turn-nudge`
- `iteration-nudge`
Expand All @@ -198,17 +206,14 @@ To customize behavior, add a file with the same name under an overrides director

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

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

By default, these tools are always protected from pruning:
`task`, `skill`, `todowrite`, `todoread`, `compress`, `batch`, `plan_enter`, `plan_exit`
`task`, `skill`, `todowrite`, `todoread`, `compress`, `batch`, `plan_enter`, `plan_exit`, `write`, `edit`

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

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

## Impact on Prompt Caching

Expand Down
45 changes: 26 additions & 19 deletions dcp.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
"automaticStrategies": {
"type": "boolean",
"default": true,
"description": "When manual mode is enabled, keep automatic deduplication/supersede/purge strategies running"
"description": "When manual mode is enabled, keep automatic deduplication/purge strategies running"
}
},
"default": {
Expand Down Expand Up @@ -128,6 +128,12 @@
"description": "Configuration for the unified compress tool",
"additionalProperties": false,
"properties": {
"mode": {
"type": "string",
"enum": ["range", "message"],
"default": "range",
"description": "Compression mode. 'range' compresses spans into block summaries, 'message' compresses individual raw messages."
},
"permission": {
"type": "string",
"enum": ["ask", "allow", "deny"],
Expand All @@ -139,9 +145,14 @@
"default": false,
"description": "Show compression summaries in notifications"
},
"summaryBuffer": {
"type": "boolean",
"default": true,
"description": "When enabled, active summary tokens extend the effective maxContextLimit used for context-limit nudges."
},
"maxContextLimit": {
"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.",
"default": 150000,
"default": 100000,
"oneOf": [
{
"type": "number"
Expand Down Expand Up @@ -213,11 +224,6 @@
"default": "soft",
"description": "Controls how likely compression is after user messages. 'strong' is more likely, 'soft' is less likely."
},
"flatSchema": {
"type": "boolean",
"default": false,
"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."
},
"protectedTools": {
"type": "array",
"items": {
Expand All @@ -231,6 +237,19 @@
"default": false,
"description": "When enabled, your messages are never lost during compression"
}
},
"default": {
"mode": "range",
"permission": "allow",
"showCompression": false,
"summaryBuffer": true,
"maxContextLimit": 100000,
"minContextLimit": 50000,
"nudgeFrequency": 5,
"iterationNudgeThreshold": 15,
"nudgeForce": "soft",
"protectedTools": [],
"protectUserMessages": false
}
},
"strategies": {
Expand Down Expand Up @@ -258,18 +277,6 @@
}
}
},
"supersedeWrites": {
"type": "object",
"description": "Replace older write/edit outputs when new ones target the same file",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": true,
"description": "Enable supersede writes strategy"
}
}
},
"purgeErrors": {
"type": "object",
"description": "Remove tool outputs that resulted in errors",
Expand Down
22 changes: 13 additions & 9 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { Plugin } from "@opencode-ai/plugin"
import { getConfig } from "./lib/config"
import { createCompressMessageTool, createCompressRangeTool } from "./lib/compress"
import {
compressDisabledByOpencode,
hasExplicitToolPermission,
type HostPermissionSnapshot,
} from "./lib/host-permissions"
import { Logger } from "./lib/logger"
import { createSessionState } from "./lib/state"
import { createCompressTool } from "./lib/tools"
import { PromptStore } from "./lib/prompts/store"
import {
createChatMessageTransformHandler,
Expand Down Expand Up @@ -41,6 +41,14 @@ const plugin: Plugin = (async (ctx) => {
strategies: config.strategies,
})

const compressToolContext = {
client: ctx.client,
state,
logger,
config,
prompts,
}

return {
"experimental.chat.system.transform": createSystemPromptHandler(
state,
Expand Down Expand Up @@ -81,14 +89,10 @@ const plugin: Plugin = (async (ctx) => {
),
tool: {
...(config.compress.permission !== "deny" && {
compress: createCompressTool({
client: ctx.client,
state,
logger,
config,
workingDirectory: ctx.directory,
prompts,
}),
compress:
config.compress.mode === "message"
? createCompressMessageTool(compressToolContext)
: createCompressRangeTool(compressToolContext),
}),
},
config: async (opencodeConfig) => {
Expand Down
135 changes: 135 additions & 0 deletions lib/commands/compression-targets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { CompressionBlock, PruneMessagesState } from "../state"

export interface CompressionTarget {
displayId: number
runId: number
topic: string
compressedTokens: number
grouped: boolean
blocks: CompressionBlock[]
}

function byBlockId(a: CompressionBlock, b: CompressionBlock): number {
return a.blockId - b.blockId
}

function buildTarget(blocks: CompressionBlock[]): CompressionTarget {
const ordered = [...blocks].sort(byBlockId)
const first = ordered[0]
if (!first) {
throw new Error("Cannot build compression target from empty block list.")
}

const grouped = first.mode === "message"
return {
displayId: first.blockId,
runId: first.runId,
topic: grouped ? first.batchTopic || first.topic : first.topic,
compressedTokens: ordered.reduce((total, block) => total + block.compressedTokens, 0),
grouped,
blocks: ordered,
}
}

function groupMessageBlocks(blocks: CompressionBlock[]): CompressionTarget[] {
const grouped = new Map<number, CompressionBlock[]>()

for (const block of blocks) {
const existing = grouped.get(block.runId)
if (existing) {
existing.push(block)
continue
}
grouped.set(block.runId, [block])
}

return Array.from(grouped.values()).map(buildTarget)
}

function splitTargets(blocks: CompressionBlock[]): CompressionTarget[] {
const messageBlocks: CompressionBlock[] = []
const singleBlocks: CompressionBlock[] = []

for (const block of blocks) {
if (block.mode === "message") {
messageBlocks.push(block)
} else {
singleBlocks.push(block)
}
}

const targets = [
...singleBlocks.map((block) => buildTarget([block])),
...groupMessageBlocks(messageBlocks),
]
return targets.sort((a, b) => a.displayId - b.displayId)
}

export function getActiveCompressionTargets(
messagesState: PruneMessagesState,
): CompressionTarget[] {
const activeBlocks = Array.from(messagesState.activeBlockIds)
.map((blockId) => messagesState.blocksById.get(blockId))
.filter((block): block is CompressionBlock => !!block && block.active)

return splitTargets(activeBlocks)
}

export function getRecompressibleCompressionTargets(
messagesState: PruneMessagesState,
availableMessageIds: Set<string>,
): CompressionTarget[] {
const allBlocks = Array.from(messagesState.blocksById.values()).filter((block) => {
return availableMessageIds.has(block.compressMessageId)
})

const messageGroups = new Map<number, CompressionBlock[]>()
const singleTargets: CompressionTarget[] = []

for (const block of allBlocks) {
if (block.mode === "message") {
const existing = messageGroups.get(block.runId)
if (existing) {
existing.push(block)
} else {
messageGroups.set(block.runId, [block])
}
continue
}

if (block.deactivatedByUser && !block.active) {
singleTargets.push(buildTarget([block]))
}
}

for (const blocks of messageGroups.values()) {
if (blocks.some((block) => block.deactivatedByUser && !block.active)) {
singleTargets.push(buildTarget(blocks))
}
}

return singleTargets.sort((a, b) => a.displayId - b.displayId)
}

export function resolveCompressionTarget(
messagesState: PruneMessagesState,
blockId: number,
): CompressionTarget | null {
const block = messagesState.blocksById.get(blockId)
if (!block) {
return null
}

if (block.mode !== "message") {
return buildTarget([block])
}

const blocks = Array.from(messagesState.blocksById.values()).filter(
(candidate) => candidate.mode === "message" && candidate.runId === block.runId,
)
if (blocks.length === 0) {
return null
}

return buildTarget(blocks)
}
2 changes: 1 addition & 1 deletion lib/commands/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdo
const isCompacted = isMessageCompacted(state, msg)
const pruneEntry = state.prune.messages.byMessageId.get(msg.info.id)
const isMessagePruned = !!pruneEntry && pruneEntry.activeBlockIds.length > 0
const isIgnoredUser = msg.info.role === "user" && isIgnoredUserMessage(msg)
const isIgnoredUser = isIgnoredUserMessage(msg)

for (const part of parts) {
if (part.type === "tool") {
Expand Down
Loading
Loading