Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ DCP uses multiple strategies to reduce context size:

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

**Supersede Writes** — Prunes write tool inputs for files that have subsequently been read. When a file is written and later read, the original write content becomes redundant since the current file state is captured in the read result. Runs automatically on every request with zero LLM cost.

**Prune Tool** — Exposes a `prune` tool that the AI can call to manually trigger pruning when it determines context cleanup is needed.

**On Idle Analysis** — Uses a language model to semantically analyze conversation context during idle periods and identify tool outputs that are no longer relevant.

*More strategies coming soon.*

Your session history is never modified. DCP replaces pruned outputs with a placeholder before sending requests to your LLM.

## Impact on Prompt Caching
Expand Down Expand Up @@ -68,6 +68,10 @@ DCP uses its own config file:
// Additional tools to protect from pruning
"protectedTools": []
},
// Prune write tool inputs when the file has been subsequently read
"supersedeWrites": {
"enabled": true
},
// Exposes a prune tool to your LLM to call when it determines pruning is necessary
"pruneTool": {
"enabled": true,
Expand Down
35 changes: 31 additions & 4 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export interface PruneTool {
nudge: PruneToolNudge
}

export interface SupersedeWrites {
enabled: boolean
}

export interface PluginConfig {
enabled: boolean
debug: boolean
Expand All @@ -36,6 +40,7 @@ export interface PluginConfig {
deduplication: Deduplication
onIdle: OnIdle
pruneTool: PruneTool
supersedeWrites: SupersedeWrites
}
}

Expand All @@ -53,6 +58,9 @@ export const VALID_CONFIG_KEYS = new Set([
'strategies.deduplication',
'strategies.deduplication.enabled',
'strategies.deduplication.protectedTools',
// strategies.supersedeWrites
'strategies.supersedeWrites',
'strategies.supersedeWrites.enabled',
// strategies.onIdle
'strategies.onIdle',
'strategies.onIdle.enabled',
Expand All @@ -66,7 +74,7 @@ export const VALID_CONFIG_KEYS = new Set([
'strategies.pruneTool.protectedTools',
'strategies.pruneTool.nudge',
'strategies.pruneTool.nudge.enabled',
'strategies.pruneTool.nudge.frequency',
'strategies.pruneTool.nudge.frequency'
])

// Extract all key paths from a config object for validation
Expand Down Expand Up @@ -159,6 +167,13 @@ function validateConfigTypes(config: Record<string, any>): ValidationError[] {
}
}
}

// supersedeWrites
if (strategies.supersedeWrites) {
if (strategies.supersedeWrites.enabled !== undefined && typeof strategies.supersedeWrites.enabled !== 'boolean') {
errors.push({ key: 'strategies.supersedeWrites.enabled', expected: 'boolean', actual: typeof strategies.supersedeWrites.enabled })
}
}
}

return errors
Expand Down Expand Up @@ -219,6 +234,9 @@ const defaultConfig: PluginConfig = {
enabled: true,
protectedTools: [...DEFAULT_PROTECTED_TOOLS]
},
supersedeWrites: {
enabled: true
},
pruneTool: {
enabled: true,
protectedTools: [...DEFAULT_PROTECTED_TOOLS],
Expand Down Expand Up @@ -255,15 +273,14 @@ function findOpencodeDir(startDir: string): string | null {
}

function getConfigPaths(ctx?: PluginInput): { global: string | null, configDir: string | null, project: string | null} {

// Global: ~/.config/opencode/dcp.jsonc|json
let globalPath: string | null = null
if (existsSync(GLOBAL_CONFIG_PATH_JSONC)) {
globalPath = GLOBAL_CONFIG_PATH_JSONC
} else if (existsSync(GLOBAL_CONFIG_PATH_JSON)) {
globalPath = GLOBAL_CONFIG_PATH_JSON
}

// Custom config directory: $OPENCODE_CONFIG_DIR/dcp.jsonc|json
let configDirPath: string | null = null
const opencodeConfigDir = process.env.OPENCODE_CONFIG_DIR
Expand All @@ -276,7 +293,7 @@ function getConfigPaths(ctx?: PluginInput): { global: string | null, configDir:
configDirPath = configJson
}
}

// Project: <project>/.opencode/dcp.jsonc|json
let projectPath: string | null = null
if (ctx?.directory) {
Expand Down Expand Up @@ -315,6 +332,10 @@ function createDefaultConfig(): void {
// Additional tools to protect from pruning
"protectedTools": []
},
// Prune write tool inputs when the file has been subsequently read
"supersedeWrites": {
"enabled": true
},
// Exposes a prune tool to your LLM to call when it determines pruning is necessary
"pruneTool": {
"enabled": true,
Expand Down Expand Up @@ -409,6 +430,9 @@ function mergeStrategies(
enabled: override.pruneTool?.nudge?.enabled ?? base.pruneTool.nudge.enabled,
frequency: override.pruneTool?.nudge?.frequency ?? base.pruneTool.nudge.frequency
}
},
supersedeWrites: {
enabled: override.supersedeWrites?.enabled ?? base.supersedeWrites.enabled
}
}
}
Expand All @@ -429,6 +453,9 @@ function deepCloneConfig(config: PluginConfig): PluginConfig {
...config.strategies.pruneTool,
protectedTools: [...config.strategies.pruneTool.protectedTools],
nudge: { ...config.strategies.pruneTool.nudge }
},
supersedeWrites: {
...config.strategies.supersedeWrites
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { SessionState, WithParts } from "./state"
import type { Logger } from "./logger"
import type { PluginConfig } from "./config"
import { syncToolCache } from "./state/tool-cache"
import { deduplicate } from "./strategies"
import { deduplicate, supersedeWrites } from "./strategies"
import { prune, insertPruneToolContext } from "./messages"
import { checkSession } from "./state"
import { runOnIdle } from "./strategies/on-idle"
Expand All @@ -27,6 +27,7 @@ export function createChatMessageTransformHandler(
syncToolCache(state, config, logger, output.messages);

deduplicate(state, logger, config, output.messages)
supersedeWrites(state, logger, config, output.messages)

prune(state, logger, config, output.messages)

Expand Down
1 change: 1 addition & 0 deletions lib/strategies/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { deduplicate } from "./deduplication"
export { runOnIdle } from "./on-idle"
export { createPruneTool } from "./prune-tool"
export { supersedeWrites } from "./supersede-writes"
100 changes: 100 additions & 0 deletions lib/strategies/supersede-writes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { PluginConfig } from "../config"
import { Logger } from "../logger"
import type { SessionState, WithParts } from "../state"
import { buildToolIdList } from "../messages/utils"
import { calculateTokensSaved } from "./utils"

/**
* Supersede Writes strategy - prunes write tool inputs for files that have
* subsequently been read. When a file is written and later read, the original
* write content becomes redundant since the current file state is captured
* in the read result.
*
* Modifies the session state in place to add pruned tool call IDs.
*/
export const supersedeWrites = (
state: SessionState,
logger: Logger,
config: PluginConfig,
messages: WithParts[]
): void => {
if (!config.strategies.supersedeWrites.enabled) {
return
}

// Build list of all tool call IDs from messages (chronological order)
const allToolIds = buildToolIdList(state, messages, logger)
if (allToolIds.length === 0) {
return
}

// Filter out IDs already pruned
const alreadyPruned = new Set(state.prune.toolIds)

const unprunedIds = allToolIds.filter(id => !alreadyPruned.has(id))
if (unprunedIds.length === 0) {
return
}

// Track write tools by file path: filePath -> [{ id, index }]
// We track index to determine chronological order
const writesByFile = new Map<string, { id: string, index: number }[]>()

// Track read file paths with their index
const readsByFile = new Map<string, number[]>()

for (let i = 0; i < allToolIds.length; i++) {
const id = allToolIds[i]
const metadata = state.toolParameters.get(id)
if (!metadata) {
continue
}

const filePath = metadata.parameters?.filePath
if (!filePath) {
continue
}

if (metadata.tool === 'write') {
if (!writesByFile.has(filePath)) {
writesByFile.set(filePath, [])
}
writesByFile.get(filePath)!.push({ id, index: i })
} else if (metadata.tool === 'read') {
if (!readsByFile.has(filePath)) {
readsByFile.set(filePath, [])
}
readsByFile.get(filePath)!.push(i)
}
}

// Find writes that are superseded by subsequent reads
const newPruneIds: string[] = []

for (const [filePath, writes] of writesByFile.entries()) {
const reads = readsByFile.get(filePath)
if (!reads || reads.length === 0) {
continue
}

// For each write, check if there's a read that comes after it
for (const write of writes) {
// Skip if already pruned
if (alreadyPruned.has(write.id)) {
continue
}

// Check if any read comes after this write
const hasSubsequentRead = reads.some(readIndex => readIndex > write.index)
if (hasSubsequentRead) {
newPruneIds.push(write.id)
}
}
}

if (newPruneIds.length > 0) {
state.stats.totalPruneTokens += calculateTokensSaved(state, messages, newPruneIds)
state.prune.toolIds.push(...newPruneIds)
logger.debug(`Marked ${newPruneIds.length} superseded write tool calls for pruning`)
}
}