Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
fb25d66
WIP: dev stash changes
Tarquinen Jan 24, 2026
2d96c82
feat: add squash tool and combo prompt variants
Tarquinen Jan 25, 2026
dfea1bd
refactor: use ulid for synthetic message ID generation
Tarquinen Jan 26, 2026
44ca085
refactor: move squashSummaries to top-level state
Tarquinen Jan 26, 2026
1c64538
refactor: move squash utility functions to tools/utils.ts
Tarquinen Jan 26, 2026
e8f12f6
fix: add secure mode authentication support
Tarquinen Jan 27, 2026
bed3e42
Merge pull request #318 from Opencode-DCP/fix/secure-mode-auth
Tarquinen Jan 27, 2026
8c7e318
refactor: append context info to existing assistant messages instead …
Tarquinen Jan 28, 2026
ededffc
feat: support toast notifications via notificationType config option
essinghigh Jan 28, 2026
cb5436b
DRY
essinghigh Jan 28, 2026
f0992f7
fix: improve discard/extract robustness and fix missing cache sync (D…
Tarquinen Jan 28, 2026
a105f80
Merge branch 'beta' into notification-config
Tarquinen Jan 28, 2026
a1dd897
v1.3.0-beta.1 - Bump beta version
Tarquinen Jan 28, 2026
c91d34e
feat(ui): add toast notification support for prune and squash
essinghigh Jan 28, 2026
83109bd
docs: sync schema and README with implementation of protected tools
Tarquinen Jan 28, 2026
7be6882
docs: add ko-fi badge to README
Tarquinen Jan 28, 2026
96c5d52
swap readme buttons
Tarquinen Jan 28, 2026
fed76ff
validate extraction is array
Tarquinen Jan 28, 2026
6b44c0d
improve squash tool error messages to specify which boundary string f…
Tarquinen Jan 28, 2026
58e450b
revert prompt style to dev branch format with semantic improvements
Tarquinen Jan 28, 2026
a8c07b3
v1.3.1-beta.0 - Bump version
Tarquinen Jan 28, 2026
3f3439d
docs: add Ko-fi sponsorship link
Tarquinen Jan 28, 2026
3b867c8
docs: move demo images to assets/images directory
Tarquinen Jan 28, 2026
5f44129
fix: ensure tool count accuracy in context breakdown using unique cal…
Tarquinen Jan 29, 2026
ecea166
cleanup
Tarquinen Jan 29, 2026
3e86cc9
refactor: inject assistant text parts instead of tool parts
Tarquinen Jan 29, 2026
df0e534
refactor: hybrid injection strategy for DeepSeek/Kimi models
Tarquinen Jan 29, 2026
6b1b06f
injection guide comments
Tarquinen Jan 29, 2026
1f9176e
truncate toast notifications (600char / 9 items)
essinghigh Jan 29, 2026
2550bd4
Merge branch 'beta' into notification-config
essinghigh Jan 29, 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
1 change: 1 addition & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ko_fi: dansmolsky
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Dynamic Context Pruning Plugin

[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/dansmolsky)
[![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 tools from conversation history.

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

## Installation

Expand All @@ -31,6 +32,8 @@ DCP uses multiple tools and strategies to reduce context size:

**Extract** — Exposes an `extract` tool that the AI can call to distill valuable context into concise summaries before removing the tool content.

**Squash** — Exposes a `squash` tool that the AI can call to collapse a large section of conversation (messages and tools) into a single summary.

### Strategies

**Deduplication** — Identifies repeated tool calls (e.g., reading the same file multiple times) and keeps only the most recent output. Runs automatically on every request with zero LLM cost.
Expand Down Expand Up @@ -105,6 +108,12 @@ DCP uses its own config file:
// Show distillation content as an ignored message notification
"showDistillation": false,
},
// Collapses a range of conversation content into a single summary
"squash": {
"enabled": true,
// Show summary content as an ignored message notification
"showSummary": true,
},
},
// Automatic pruning strategies
"strategies": {
Expand Down Expand Up @@ -148,7 +157,7 @@ When enabled, turn protection prevents tool outputs from being pruned for a conf
### Protected Tools

By default, these tools are always protected from pruning across all strategies:
`task`, `todowrite`, `todoread`, `discard`, `extract`, `batch`, `write`, `edit`
`task`, `todowrite`, `todoread`, `discard`, `extract`, `squash`, `batch`, `write`, `edit`, `plan_enter`, `plan_exit`

The `protectedTools` arrays in each section add to this default list.

Expand Down
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
56 changes: 26 additions & 30 deletions dcp.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
"default": "detailed",
"description": "Level of notification shown when pruning occurs"
},
"notificationType": {
"type": "string",
"enum": ["chat", "toast"],
"default": "chat",
"description": "Where to display prune notifications (chat message or toast notification)"
},
"commands": {
"type": "object",
"description": "Configuration for DCP slash commands (/dcp)",
Expand Down Expand Up @@ -100,16 +106,7 @@
"items": {
"type": "string"
},
"default": [
"task",
"todowrite",
"todoread",
"discard",
"extract",
"batch",
"write",
"edit"
],
"default": [],
"description": "Tool names that should be protected from automatic pruning"
}
}
Expand Down Expand Up @@ -142,6 +139,23 @@
"description": "Show distillation output in the UI"
}
}
},
"squash": {
"type": "object",
"description": "Configuration for the squash tool",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": true,
"description": "Enable the squash tool"
},
"showSummary": {
"type": "boolean",
"default": true,
"description": "Show summary output in the UI"
}
}
}
}
},
Expand All @@ -165,16 +179,7 @@
"items": {
"type": "string"
},
"default": [
"task",
"todowrite",
"todoread",
"discard",
"extract",
"batch",
"write",
"edit"
],
"default": [],
"description": "Tool names excluded from deduplication"
}
}
Expand Down Expand Up @@ -211,16 +216,7 @@
"items": {
"type": "string"
},
"default": [
"task",
"todowrite",
"todoread",
"discard",
"extract",
"batch",
"write",
"edit"
],
"default": [],
"description": "Tool names excluded from error purging"
}
}
Expand Down
18 changes: 17 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import type { Plugin } from "@opencode-ai/plugin"
import { getConfig } from "./lib/config"
import { Logger } from "./lib/logger"
import { createSessionState } from "./lib/state"
import { createDiscardTool, createExtractTool } from "./lib/strategies"
import { createDiscardTool, createExtractTool, createSquashTool } from "./lib/strategies"
import {
createChatMessageTransformHandler,
createCommandExecuteHandler,
createSystemPromptHandler,
} from "./lib/hooks"
import { configureClientAuth, isSecureMode } from "./lib/auth"

const plugin: Plugin = (async (ctx) => {
const config = getConfig(ctx)
Expand All @@ -19,6 +20,11 @@ const plugin: Plugin = (async (ctx) => {
const logger = new Logger(config.debug)
const state = createSessionState()

if (isSecureMode()) {
configureClientAuth(ctx.client)
// logger.info("Secure mode detected, configured client authentication")
}

logger.info("DCP initialized", {
strategies: config.strategies,
})
Expand Down Expand Up @@ -73,6 +79,15 @@ const plugin: Plugin = (async (ctx) => {
workingDirectory: ctx.directory,
}),
}),
...(config.tools.squash.enabled && {
squash: createSquashTool({
client: ctx.client,
state,
logger,
config,
workingDirectory: ctx.directory,
}),
}),
},
config: async (opencodeConfig) => {
if (config.commands.enabled) {
Expand All @@ -86,6 +101,7 @@ const plugin: Plugin = (async (ctx) => {
const toolsToAdd: string[] = []
if (config.tools.discard.enabled) toolsToAdd.push("discard")
if (config.tools.extract.enabled) toolsToAdd.push("extract")
if (config.tools.squash.enabled) toolsToAdd.push("squash")

if (toolsToAdd.length > 0) {
const existingPrimaryTools = opencodeConfig.experimental?.primary_tools ?? []
Expand Down
37 changes: 37 additions & 0 deletions lib/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export function isSecureMode(): boolean {
return !!process.env.OPENCODE_SERVER_PASSWORD
}

export function getAuthorizationHeader(): string | undefined {
const password = process.env.OPENCODE_SERVER_PASSWORD
if (!password) return undefined

const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
// Use Buffer for Node.js base64 encoding (btoa may not be available in all Node versions)
const credentials = Buffer.from(`${username}:${password}`).toString("base64")
return `Basic ${credentials}`
}

export function configureClientAuth(client: any): any {
const authHeader = getAuthorizationHeader()

if (!authHeader) {
return client
}

// The SDK client has an internal client with request interceptors
// Access the underlying client to add the interceptor
const innerClient = client._client || client.client

if (innerClient?.interceptors?.request) {
innerClient.interceptors.request.use((request: Request) => {
// Only add auth header if not already present
if (!request.headers.has("Authorization")) {
request.headers.set("Authorization", authHeader)
}
return request
})
}

return client
}
65 changes: 40 additions & 25 deletions lib/commands/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ interface TokenBreakdown {
toolCount: number
prunedTokens: number
prunedCount: number
prunedMessageCount: number
total: number
}

Expand All @@ -74,6 +75,7 @@ function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdo
toolCount: 0,
prunedTokens: state.stats.totalPruneTokens,
prunedCount: state.prune.toolIds.length,
prunedMessageCount: state.prune.messageIds.length,
total: 0,
}

Expand Down Expand Up @@ -112,43 +114,54 @@ function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdo
const toolOutputParts: string[] = []
let firstUserText = ""
let foundFirstUser = false
const foundToolIds = new Set<string>()

for (const msg of messages) {
if (isMessageCompacted(state, msg)) continue
if (msg.info.role === "user" && isIgnoredUserMessage(msg)) continue

const parts = Array.isArray(msg.parts) ? msg.parts : []
const isCompacted = isMessageCompacted(state, msg)
const isIgnoredUser = msg.info.role === "user" && isIgnoredUserMessage(msg)

for (const part of parts) {
if (part.type === "text" && msg.info.role === "user") {
if (part.type === "tool") {
const toolPart = part as ToolPart
if (toolPart.callID && !foundToolIds.has(toolPart.callID)) {
breakdown.toolCount++
foundToolIds.add(toolPart.callID)
}

if (!isCompacted) {
if (toolPart.state?.input) {
const inputStr =
typeof toolPart.state.input === "string"
? toolPart.state.input
: JSON.stringify(toolPart.state.input)
toolInputParts.push(inputStr)
}

if (toolPart.state?.status === "completed" && toolPart.state?.output) {
const outputStr =
typeof toolPart.state.output === "string"
? toolPart.state.output
: JSON.stringify(toolPart.state.output)
toolOutputParts.push(outputStr)
}
}
} else if (
part.type === "text" &&
msg.info.role === "user" &&
!isCompacted &&
!isIgnoredUser
) {
const textPart = part as TextPart
const text = textPart.text || ""
userTextParts.push(text)
if (!foundFirstUser) {
firstUserText += text
}
} else if (part.type === "tool") {
const toolPart = part as ToolPart
breakdown.toolCount++

if (toolPart.state?.input) {
const inputStr =
typeof toolPart.state.input === "string"
? toolPart.state.input
: JSON.stringify(toolPart.state.input)
toolInputParts.push(inputStr)
}

if (toolPart.state?.status === "completed" && toolPart.state?.output) {
const outputStr =
typeof toolPart.state.output === "string"
? toolPart.state.output
: JSON.stringify(toolPart.state.output)
toolOutputParts.push(outputStr)
}
}
}

if (msg.info.role === "user" && !isIgnoredUserMessage(msg) && !foundFirstUser) {
if (msg.info.role === "user" && !isIgnoredUser && !foundFirstUser) {
foundFirstUser = true
}
}
Expand Down Expand Up @@ -221,8 +234,10 @@ function formatContextMessage(breakdown: TokenBreakdown): string {

if (breakdown.prunedTokens > 0) {
const withoutPruning = breakdown.total + breakdown.prunedTokens
const messagePrunePart =
breakdown.prunedMessageCount > 0 ? `, ${breakdown.prunedMessageCount} messages` : ""
lines.push(
` Pruned: ${breakdown.prunedCount} tools (~${formatTokenCount(breakdown.prunedTokens)})`,
` Pruned: ${breakdown.prunedCount} tools${messagePrunePart} (~${formatTokenCount(breakdown.prunedTokens)})`,
)
lines.push(` Current context: ~${formatTokenCount(breakdown.total)}`)
lines.push(` Without DCP: ~${formatTokenCount(withoutPruning)}`)
Expand Down
Loading