diff --git a/README.md b/README.md index 3278c06..2d4ada2 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,9 @@ DCP uses its own config file: "enabled": false, "turns": 4, }, + // Protect file operations from pruning via glob patterns + // Patterns match tool parameters.filePath (e.g. read/write/edit) + "protectedFilePatterns": [], // LLM-driven context pruning tools "tools": { // Shared settings for all prune tools @@ -141,6 +144,10 @@ Each level overrides the previous, so project settings take priority over config Restart OpenCode after making config changes. +## Limitations + +**Subagents** — DCP is disabled for subagents. Subagents are not designed to be token efficient; what matters is that the final message returned to the main agent is a concise summary of findings. DCP's pruning could interfere with this summarization behavior. + ## License MIT diff --git a/dcp.schema.json b/dcp.schema.json new file mode 100644 index 0000000..e874136 --- /dev/null +++ b/dcp.schema.json @@ -0,0 +1,207 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/Opencode-DCP/opencode-dynamic-context-pruning/main/dcp.schema.json", + "title": "DCP Plugin Configuration", + "description": "Configuration schema for the OpenCode Dynamic Context Pruning plugin", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference for IDE autocomplete" + }, + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable or disable the DCP plugin" + }, + "debug": { + "type": "boolean", + "default": false, + "description": "Enable debug logging" + }, + "pruneNotification": { + "type": "string", + "enum": ["off", "minimal", "detailed"], + "default": "detailed", + "description": "Level of notification shown when pruning occurs" + }, + "turnProtection": { + "type": "object", + "description": "Protect recent tool outputs from being pruned", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable turn-based protection" + }, + "turns": { + "type": "number", + "default": 4, + "description": "Number of recent turns to protect from pruning" + } + } + }, + "protectedFilePatterns": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Glob patterns for files that should be protected from pruning (e.g., '**/*.config.ts')" + }, + "tools": { + "type": "object", + "description": "Configuration for pruning tools", + "additionalProperties": false, + "properties": { + "settings": { + "type": "object", + "description": "General tool settings", + "additionalProperties": false, + "properties": { + "nudgeEnabled": { + "type": "boolean", + "default": true, + "description": "Enable nudge reminders to prune context" + }, + "nudgeFrequency": { + "type": "number", + "default": 10, + "description": "Frequency of nudge reminders (in turns)" + }, + "protectedTools": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "task", + "todowrite", + "todoread", + "discard", + "extract", + "batch", + "write", + "edit" + ], + "description": "Tool names that should be protected from automatic pruning" + } + } + }, + "discard": { + "type": "object", + "description": "Configuration for the discard tool", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable the discard tool" + } + } + }, + "extract": { + "type": "object", + "description": "Configuration for the extract tool", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable the extract tool" + }, + "showDistillation": { + "type": "boolean", + "default": false, + "description": "Show distillation output in the UI" + } + } + } + } + }, + "strategies": { + "type": "object", + "description": "Automatic pruning strategies", + "additionalProperties": false, + "properties": { + "deduplication": { + "type": "object", + "description": "Remove duplicate tool outputs", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable deduplication strategy" + }, + "protectedTools": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "task", + "todowrite", + "todoread", + "discard", + "extract", + "batch", + "write", + "edit" + ], + "description": "Tool names excluded from deduplication" + } + } + }, + "supersedeWrites": { + "type": "object", + "description": "Replace older write/edit outputs when new ones target the same file", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable supersede writes strategy" + } + } + }, + "purgeErrors": { + "type": "object", + "description": "Remove tool outputs that resulted in errors", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable purge errors strategy" + }, + "turns": { + "type": "number", + "default": 4, + "description": "Number of turns after which errors are purged" + }, + "protectedTools": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "task", + "todowrite", + "todoread", + "discard", + "extract", + "batch", + "write", + "edit" + ], + "description": "Tool names excluded from error purging" + } + } + } + } + } + } +} diff --git a/lib/config.ts b/lib/config.ts index 2b48263..d0c436d 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -50,6 +50,7 @@ export interface PluginConfig { debug: boolean pruneNotification: "off" | "minimal" | "detailed" turnProtection: TurnProtection + protectedFilePatterns: string[] tools: Tools strategies: { deduplication: Deduplication @@ -79,6 +80,7 @@ export const VALID_CONFIG_KEYS = new Set([ "turnProtection", "turnProtection.enabled", "turnProtection.turns", + "protectedFilePatterns", "tools", "tools.settings", "tools.settings.nudgeEnabled", @@ -151,6 +153,22 @@ function validateConfigTypes(config: Record): ValidationError[] { } } + if (config.protectedFilePatterns !== undefined) { + if (!Array.isArray(config.protectedFilePatterns)) { + errors.push({ + key: "protectedFilePatterns", + expected: "string[]", + actual: typeof config.protectedFilePatterns, + }) + } else if (!config.protectedFilePatterns.every((v) => typeof v === "string")) { + errors.push({ + key: "protectedFilePatterns", + expected: "string[]", + actual: "non-string entries", + }) + } + } + // Top-level turnProtection validator if (config.turnProtection) { if ( @@ -371,6 +389,7 @@ const defaultConfig: PluginConfig = { enabled: false, turns: 4, }, + protectedFilePatterns: [], tools: { settings: { nudgeEnabled: true, @@ -469,6 +488,7 @@ function createDefaultConfig(): void { } const configContent = `{ + "$schema": "https://raw.githubusercontent.com/Opencode-DCP/opencode-dynamic-context-pruning/main/dcp.schema.json", // Enable or disable the plugin "enabled": true, // Enable debug logging to ~/.config/opencode/logs/dcp/ @@ -480,6 +500,9 @@ function createDefaultConfig(): void { "enabled": false, "turns": 4 }, + // Protect file operations from pruning via glob patterns + // Patterns match tool parameters.filePath (e.g. read/write/edit) + "protectedFilePatterns": [], // LLM-driven context pruning tools "tools": { // Shared settings for all prune tools @@ -615,6 +638,7 @@ function deepCloneConfig(config: PluginConfig): PluginConfig { return { ...config, turnProtection: { ...config.turnProtection }, + protectedFilePatterns: [...config.protectedFilePatterns], tools: { settings: { ...config.tools.settings, @@ -670,6 +694,12 @@ export function getConfig(ctx: PluginInput): PluginConfig { enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, turns: result.data.turnProtection?.turns ?? config.turnProtection.turns, }, + protectedFilePatterns: [ + ...new Set([ + ...config.protectedFilePatterns, + ...(result.data.protectedFilePatterns ?? []), + ]), + ], tools: mergeTools(config.tools, result.data.tools as any), strategies: mergeStrategies(config.strategies, result.data.strategies as any), } @@ -706,6 +736,12 @@ export function getConfig(ctx: PluginInput): PluginConfig { enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, turns: result.data.turnProtection?.turns ?? config.turnProtection.turns, }, + protectedFilePatterns: [ + ...new Set([ + ...config.protectedFilePatterns, + ...(result.data.protectedFilePatterns ?? []), + ]), + ], tools: mergeTools(config.tools, result.data.tools as any), strategies: mergeStrategies(config.strategies, result.data.strategies as any), } @@ -739,6 +775,12 @@ export function getConfig(ctx: PluginInput): PluginConfig { enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, turns: result.data.turnProtection?.turns ?? config.turnProtection.turns, }, + protectedFilePatterns: [ + ...new Set([ + ...config.protectedFilePatterns, + ...(result.data.protectedFilePatterns ?? []), + ]), + ], tools: mergeTools(config.tools, result.data.tools as any), strategies: mergeStrategies(config.strategies, result.data.strategies as any), } diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 11153b7..5d9b36f 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -4,6 +4,7 @@ import type { PluginConfig } from "../config" import type { UserMessage } from "@opencode-ai/sdk/v2" import { loadPrompt } from "../prompts" import { extractParameterKey, buildToolIdList, createSyntheticUserMessage } from "./utils" +import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" import { getLastUserMessage } from "../shared-utils" const getNudgeString = (config: PluginConfig): string => { @@ -62,6 +63,11 @@ const buildPrunableToolsList = ( return } + const filePath = getFilePathFromParameters(toolParameterEntry.parameters) + if (isProtectedFilePath(filePath, config.protectedFilePatterns)) { + return + } + const numericId = toolIdList.indexOf(toolCallId) if (numericId === -1) { logger.warn(`Tool in cache but not in toolIdList - possible stale entry`, { diff --git a/lib/protected-file-patterns.ts b/lib/protected-file-patterns.ts new file mode 100644 index 0000000..3370e20 --- /dev/null +++ b/lib/protected-file-patterns.ts @@ -0,0 +1,82 @@ +function normalizePath(input: string): string { + return input.replaceAll("\\\\", "/") +} + +function escapeRegExpChar(ch: string): string { + return /[\\.^$+{}()|\[\]]/.test(ch) ? `\\${ch}` : ch +} + +/** + * Basic glob matching with support for `**`, `*`, and `?`. + * + * Notes: + * - Matching is performed against the full (normalized) string. + * - `*` and `?` do not match `/`. + * - `**` matches across `/`. + */ +export function matchesGlob(inputPath: string, pattern: string): boolean { + if (!pattern) return false + + const input = normalizePath(inputPath) + const pat = normalizePath(pattern) + + let regex = "^" + + for (let i = 0; i < pat.length; i++) { + const ch = pat[i] + + if (ch === "*") { + const next = pat[i + 1] + if (next === "*") { + const after = pat[i + 2] + if (after === "/") { + // **/ (zero or more directories) + regex += "(?:.*/)?" + i += 2 + continue + } + + // ** + regex += ".*" + i++ + continue + } + + // * + regex += "[^/]*" + continue + } + + if (ch === "?") { + regex += "[^/]" + continue + } + + if (ch === "/") { + regex += "/" + continue + } + + regex += escapeRegExpChar(ch) + } + + regex += "$" + + return new RegExp(regex).test(input) +} + +export function getFilePathFromParameters(parameters: unknown): string | undefined { + if (typeof parameters !== "object" || parameters === null) { + return undefined + } + + const filePath = (parameters as Record).filePath + return typeof filePath === "string" && filePath.length > 0 ? filePath : undefined +} + +export function isProtectedFilePath(filePath: string | undefined, patterns: string[]): boolean { + if (!filePath) return false + if (!patterns || patterns.length === 0) return false + + return patterns.some((pattern) => matchesGlob(filePath, pattern)) +} diff --git a/lib/strategies/deduplication.ts b/lib/strategies/deduplication.ts index 101e664..fb6ce4e 100644 --- a/lib/strategies/deduplication.ts +++ b/lib/strategies/deduplication.ts @@ -2,6 +2,7 @@ import { PluginConfig } from "../config" import { Logger } from "../logger" import type { SessionState, WithParts } from "../state" import { buildToolIdList } from "../messages/utils" +import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" import { calculateTokensSaved } from "./utils" /** @@ -50,6 +51,11 @@ export const deduplicate = ( continue } + const filePath = getFilePathFromParameters(metadata.parameters) + if (isProtectedFilePath(filePath, config.protectedFilePatterns)) { + continue + } + const signature = createToolSignature(metadata.tool, metadata.parameters) if (!signatureMap.has(signature)) { signatureMap.set(signature, []) diff --git a/lib/strategies/purge-errors.ts b/lib/strategies/purge-errors.ts index 84d3aa8..c3debf6 100644 --- a/lib/strategies/purge-errors.ts +++ b/lib/strategies/purge-errors.ts @@ -2,6 +2,7 @@ import { PluginConfig } from "../config" import { Logger } from "../logger" import type { SessionState, WithParts } from "../state" import { buildToolIdList } from "../messages/utils" +import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" import { calculateTokensSaved } from "./utils" /** @@ -52,6 +53,11 @@ export const purgeErrors = ( continue } + const filePath = getFilePathFromParameters(metadata.parameters) + if (isProtectedFilePath(filePath, config.protectedFilePatterns)) { + continue + } + // Only process error tools if (metadata.status !== "error") { continue diff --git a/lib/strategies/supersede-writes.ts b/lib/strategies/supersede-writes.ts index 327cb58..ef765c4 100644 --- a/lib/strategies/supersede-writes.ts +++ b/lib/strategies/supersede-writes.ts @@ -2,6 +2,7 @@ import { PluginConfig } from "../config" import { Logger } from "../logger" import type { SessionState, WithParts } from "../state" import { buildToolIdList } from "../messages/utils" +import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" import { calculateTokensSaved } from "./utils" /** @@ -50,11 +51,15 @@ export const supersedeWrites = ( continue } - const filePath = metadata.parameters?.filePath + const filePath = getFilePathFromParameters(metadata.parameters) if (!filePath) { continue } + if (isProtectedFilePath(filePath, config.protectedFilePatterns)) { + continue + } + if (metadata.tool === "write") { if (!writesByFile.has(filePath)) { writesByFile.set(filePath, []) diff --git a/lib/strategies/tools.ts b/lib/strategies/tools.ts index 6ca7d59..e3d8e03 100644 --- a/lib/strategies/tools.ts +++ b/lib/strategies/tools.ts @@ -9,6 +9,7 @@ import { saveSessionState } from "../state/persistence" import type { Logger } from "../logger" import { loadPrompt } from "../prompts" import { calculateTokensSaved, getCurrentParams } from "./utils" +import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" const DISCARD_TOOL_DESCRIPTION = loadPrompt("discard-tool-spec") const EXTRACT_TOOL_DESCRIPTION = loadPrompt("extract-tool-spec") @@ -88,6 +89,17 @@ async function executePruneOperation( }) return "Invalid IDs provided. Only use numeric IDs from the list." } + + const filePath = getFilePathFromParameters(metadata.parameters) + if (isProtectedFilePath(filePath, config.protectedFilePatterns)) { + logger.debug("Rejecting prune request - protected file path", { + index, + id, + tool: metadata.tool, + filePath, + }) + return "Invalid IDs provided. Only use numeric IDs from the list." + } } const pruneToolIds: string[] = numericToolIds.map((index) => toolIdList[index]) diff --git a/package-lock.json b/package-lock.json index bb5a950..9cede6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tarquinen/opencode-dcp", - "version": "1.1.4", + "version": "1.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "1.1.4", + "version": "1.1.5", "license": "MIT", "dependencies": { "@opencode-ai/sdk": "^1.1.3", diff --git a/package.json b/package.json index 99c8e64..f6305ca 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@tarquinen/opencode-dcp", - "version": "1.1.4", + "version": "1.1.5", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js",