From 2c7ab582e85967a0accd4b9be485b5dc1d0b036b Mon Sep 17 00:00:00 2001 From: Jorgen Henriksen Date: Wed, 17 Dec 2025 20:40:39 +0100 Subject: [PATCH 1/4] supersede writes setup --- README.md | 8 ++++++-- lib/config.ts | 33 +++++++++++++++++++++++++++--- lib/hooks.ts | 3 ++- lib/strategies/index.ts | 1 + lib/strategies/supersede-writes.ts | 22 ++++++++++++++++++++ 5 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 lib/strategies/supersede-writes.ts diff --git a/README.md b/README.md index a8e76615..9bfd0610 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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, diff --git a/lib/config.ts b/lib/config.ts index 1887442f..536943dc 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -28,6 +28,10 @@ export interface PruneTool { nudge: PruneToolNudge } +export interface SupersedeWrites { + enabled: boolean +} + export interface PluginConfig { enabled: boolean debug: boolean @@ -36,6 +40,7 @@ export interface PluginConfig { deduplication: Deduplication onIdle: OnIdle pruneTool: PruneTool + supersedeWrites: SupersedeWrites } } @@ -67,6 +72,9 @@ export const VALID_CONFIG_KEYS = new Set([ 'strategies.pruneTool.nudge', 'strategies.pruneTool.nudge.enabled', 'strategies.pruneTool.nudge.frequency', + // strategies.supersedeWrites + 'strategies.supersedeWrites', + 'strategies.supersedeWrites.enabled', ]) // Extract all key paths from a config object for validation @@ -159,6 +167,13 @@ function validateConfigTypes(config: Record): 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 @@ -219,6 +234,9 @@ const defaultConfig: PluginConfig = { enabled: true, protectedTools: [...DEFAULT_PROTECTED_TOOLS] }, + supersedeWrites: { + enabled: true + }, pruneTool: { enabled: true, protectedTools: [...DEFAULT_PROTECTED_TOOLS], @@ -255,7 +273,6 @@ 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)) { @@ -263,7 +280,7 @@ function getConfigPaths(ctx?: PluginInput): { global: string | null, configDir: } 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 @@ -276,7 +293,7 @@ function getConfigPaths(ctx?: PluginInput): { global: string | null, configDir: configDirPath = configJson } } - + // Project: /.opencode/dcp.jsonc|json let projectPath: string | null = null if (ctx?.directory) { @@ -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, @@ -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 } } } @@ -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 } } } diff --git a/lib/hooks.ts b/lib/hooks.ts index 72fda69e..c578e4bb 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -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" @@ -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) diff --git a/lib/strategies/index.ts b/lib/strategies/index.ts index 105d9c81..869a2431 100644 --- a/lib/strategies/index.ts +++ b/lib/strategies/index.ts @@ -1,3 +1,4 @@ export { deduplicate } from "./deduplication" export { runOnIdle } from "./on-idle" export { createPruneTool } from "./prune-tool" +export { supersedeWrites } from "./supersede-writes" diff --git a/lib/strategies/supersede-writes.ts b/lib/strategies/supersede-writes.ts new file mode 100644 index 00000000..b9e12b3f --- /dev/null +++ b/lib/strategies/supersede-writes.ts @@ -0,0 +1,22 @@ +import { PluginConfig } from "../config" +import { Logger } from "../logger" +import type { SessionState, WithParts } from "../state" + +/** + * 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 + } +} From 2ac639e4b3cdcb9de6eb12ffec863241b0ab448a Mon Sep 17 00:00:00 2001 From: Jorgen Henriksen Date: Wed, 17 Dec 2025 21:26:13 +0100 Subject: [PATCH 2/4] supersede writes logic --- lib/strategies/supersede-writes.ts | 78 ++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/lib/strategies/supersede-writes.ts b/lib/strategies/supersede-writes.ts index b9e12b3f..b8bb8473 100644 --- a/lib/strategies/supersede-writes.ts +++ b/lib/strategies/supersede-writes.ts @@ -1,6 +1,8 @@ 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 @@ -19,4 +21,80 @@ export const supersedeWrites = ( 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() + + // Track read file paths with their index + const readsByFile = new Map() + + 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`) + } } From 2f78a60d38a56674255decb105fe4d33a8157c6a Mon Sep 17 00:00:00 2001 From: Jorgen Henriksen Date: Wed, 17 Dec 2025 21:28:00 +0100 Subject: [PATCH 3/4] clean up --- lib/config.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index 536943dc..e1dbd1a7 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -58,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', @@ -71,10 +74,7 @@ export const VALID_CONFIG_KEYS = new Set([ 'strategies.pruneTool.protectedTools', 'strategies.pruneTool.nudge', 'strategies.pruneTool.nudge.enabled', - 'strategies.pruneTool.nudge.frequency', - // strategies.supersedeWrites - 'strategies.supersedeWrites', - 'strategies.supersedeWrites.enabled', + 'strategies.pruneTool.nudge.frequency' ]) // Extract all key paths from a config object for validation From b8ee3244ccaf4f3e62b38f377dfd88f39431f0c2 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 18 Dec 2025 00:41:47 -0500 Subject: [PATCH 4/4] check compaction on session init --- lib/state/state.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/state/state.ts b/lib/state/state.ts index 7ad26174..caab6d9b 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -84,7 +84,6 @@ export async function ensureSessionInitialized( logger.info("session ID = " + sessionId) logger.info("Initializing session state", { sessionId: sessionId }) - // Clear previous session data resetSessionState(state) state.sessionId = sessionId @@ -92,13 +91,13 @@ export async function ensureSessionInitialized( state.isSubAgent = isSubAgent logger.info("isSubAgent = " + isSubAgent) - // Load session data from storage + state.lastCompaction = findLastCompactionTimestamp(messages) + const persisted = await loadSessionState(sessionId, logger) if (persisted === null) { return; } - // Populate state with loaded data state.prune = { toolIds: persisted.prune.toolIds || [] }