diff --git a/README.md b/README.md index a8e7661..9bfd061 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 1887442..e1dbd1a 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 } } @@ -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', @@ -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 @@ -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 72fda69..c578e4b 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/state/state.ts b/lib/state/state.ts index 7ad2617..caab6d9 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 || [] } diff --git a/lib/strategies/index.ts b/lib/strategies/index.ts index 105d9c8..869a243 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 0000000..b8bb847 --- /dev/null +++ b/lib/strategies/supersede-writes.ts @@ -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() + + // 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`) + } +}