diff --git a/index.ts b/index.ts index ac87705..b1e395a 100644 --- a/index.ts +++ b/index.ts @@ -4,7 +4,7 @@ import { Logger } from "./lib/logger" import { loadPrompt } from "./lib/prompt" import { createSessionState } from "./lib/state" import { createPruneTool } from "./lib/strategies" -import { createChatMessageTransformHandler, createEventHandler } from "./lib/hooks" +import { createChatMessageTransformHandler, createChatParamsHandler, createEventHandler } from "./lib/hooks" const plugin: Plugin = (async (ctx) => { const config = getConfig(ctx) @@ -25,10 +25,16 @@ const plugin: Plugin = (async (ctx) => { // Log initialization logger.info("DCP initialized", { strategies: config.strategies, + overrides: config.overrides }) return { + "chat.params": createChatParamsHandler(ctx.client, state, logger, config), "experimental.chat.system.transform": async (_input: unknown, output: { system: string[] }) => { + // Skip system prompt injection if DCP is disabled for current provider + if (state.provider.effectiveConfig && !state.provider.effectiveConfig.enabled) { + return + } const syntheticPrompt = loadPrompt("prune-system-prompt") output.system.push(syntheticPrompt) }, diff --git a/lib/config.ts b/lib/config.ts index 9a670fe..533ac4d 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -38,10 +38,27 @@ export interface SupersedeWrites { enabled: boolean } +export interface ProviderStrategies { + deduplication?: { enabled?: boolean } + onIdle?: { enabled?: boolean } + pruneTool?: { enabled?: boolean } + supersedeWrites?: { enabled?: boolean } +} + +export interface ProviderOverride { + enabled?: boolean + strategies?: ProviderStrategies +} + +export interface Overrides { + provider?: Record +} + export interface PluginConfig { enabled: boolean debug: boolean pruningSummary: "off" | "minimal" | "detailed" + overrides: Overrides strategies: { deduplication: Deduplication onIdle: OnIdle @@ -53,12 +70,15 @@ export interface PluginConfig { const DEFAULT_PROTECTED_TOOLS = ['task', 'todowrite', 'todoread', 'prune', 'batch'] // Valid config keys for validation against user config +// Note: overrides.provider uses dynamic keys so we validate structure, not exact paths export const VALID_CONFIG_KEYS = new Set([ // Top-level keys 'enabled', 'debug', 'showUpdateToasts', // Deprecated but kept for backwards compatibility 'pruningSummary', + 'overrides', + 'overrides.provider', 'strategies', // strategies.deduplication 'strategies.deduplication', @@ -86,11 +106,31 @@ export const VALID_CONFIG_KEYS = new Set([ 'strategies.pruneTool.nudge.frequency' ]) +// Valid keys within overrides.provider. objects +const VALID_PROVIDER_OVERRIDE_KEYS = new Set([ + 'enabled', + 'strategies', + 'strategies.deduplication', + 'strategies.deduplication.enabled', + 'strategies.onIdle', + 'strategies.onIdle.enabled', + 'strategies.pruneTool', + 'strategies.pruneTool.enabled', + 'strategies.supersedeWrites', + 'strategies.supersedeWrites.enabled' +]) + // Extract all key paths from a config object for validation +// Skips overrides.provider children since they have dynamic provider keys function getConfigKeyPaths(obj: Record, prefix = ''): string[] { const keys: string[] = [] for (const key of Object.keys(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key + // Skip deep traversal of overrides.provider - validated separately + if (fullKey === 'overrides.provider') { + keys.push(fullKey) + continue + } keys.push(fullKey) if (obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key])) { keys.push(...getConfigKeyPaths(obj[key], fullKey)) @@ -99,10 +139,30 @@ function getConfigKeyPaths(obj: Record, prefix = ''): string[] { return keys } +// Validate overrides.provider structure +function getInvalidProviderOverrideKeys(providerOverrides: Record): string[] { + const invalid: string[] = [] + for (const provider of Object.keys(providerOverrides)) { + const override = providerOverrides[provider] + if (!override || typeof override !== 'object') continue + const overrideKeys = getConfigKeyPaths(override) + for (const key of overrideKeys) { + if (!VALID_PROVIDER_OVERRIDE_KEYS.has(key)) { + invalid.push(`overrides.provider.${provider}.${key}`) + } + } + } + return invalid +} + // Returns invalid keys found in user config export function getInvalidConfigKeys(userConfig: Record): string[] { const userKeys = getConfigKeyPaths(userConfig) - return userKeys.filter(key => !VALID_CONFIG_KEYS.has(key)) + const invalidTopLevel = userKeys.filter(key => !VALID_CONFIG_KEYS.has(key)) + const invalidProviderOverrides = userConfig.overrides?.provider + ? getInvalidProviderOverrideKeys(userConfig.overrides.provider) + : [] + return [...invalidTopLevel, ...invalidProviderOverrides] } // Type validators for config values @@ -128,6 +188,34 @@ function validateConfigTypes(config: Record): ValidationError[] { errors.push({ key: 'pruningSummary', expected: '"off" | "minimal" | "detailed"', actual: JSON.stringify(config.pruningSummary) }) } } + if (config.overrides !== undefined) { + if (typeof config.overrides !== 'object' || Array.isArray(config.overrides)) { + errors.push({ key: 'overrides', expected: 'Overrides object', actual: typeof config.overrides }) + } else if (config.overrides.provider !== undefined) { + if (typeof config.overrides.provider !== 'object' || Array.isArray(config.overrides.provider)) { + errors.push({ key: 'overrides.provider', expected: 'Record', actual: typeof config.overrides.provider }) + } else { + // Validate each provider override + for (const [provider, override] of Object.entries(config.overrides.provider)) { + if (typeof override !== 'object' || override === null) { + errors.push({ key: `overrides.provider.${provider}`, expected: 'ProviderOverride object', actual: typeof override }) + continue + } + const o = override as Record + if (o.enabled !== undefined && typeof o.enabled !== 'boolean') { + errors.push({ key: `overrides.provider.${provider}.enabled`, expected: 'boolean', actual: typeof o.enabled }) + } + if (o.strategies) { + for (const strategy of ['deduplication', 'onIdle', 'pruneTool', 'supersedeWrites']) { + if (o.strategies[strategy]?.enabled !== undefined && typeof o.strategies[strategy].enabled !== 'boolean') { + errors.push({ key: `overrides.provider.${provider}.strategies.${strategy}.enabled`, expected: 'boolean', actual: typeof o.strategies[strategy].enabled }) + } + } + } + } + } + } + } // Strategies validators const strategies = config.strategies @@ -246,6 +334,7 @@ const defaultConfig: PluginConfig = { enabled: true, debug: false, pruningSummary: 'detailed', + overrides: {}, strategies: { deduplication: { enabled: true, @@ -345,6 +434,19 @@ function createDefaultConfig(): void { "debug": false, // Summary display: "off", "minimal", or "detailed" "pruningSummary": "detailed", + // Per-provider overrides for granular control + // "overrides": { + // "provider": { + // "openai": { + // "enabled": false // Disable all DCP features for OpenAI + // }, + // "anthropic": { + // "strategies": { + // "pruneTool": { "enabled": false } // Disable only the prune tool for Anthropic + // } + // } + // } + // }, // Strategies for pruning tokens from chat history "strategies": { // Remove duplicate tool calls (same tool with same arguments) @@ -470,6 +572,7 @@ function mergeStrategies( function deepCloneConfig(config: PluginConfig): PluginConfig { return { ...config, + overrides: JSON.parse(JSON.stringify(config.overrides)), strategies: { deduplication: { ...config.strategies.deduplication, @@ -492,6 +595,33 @@ function deepCloneConfig(config: PluginConfig): PluginConfig { } } +function mergeOverrides( + base: Overrides, + override?: Overrides +): Overrides { + if (!override) return base + const result: Overrides = { ...base } + if (override.provider) { + result.provider = result.provider ?? {} + for (const [provider, overrideConfig] of Object.entries(override.provider)) { + if (!result.provider[provider]) { + result.provider[provider] = overrideConfig + } else { + result.provider[provider] = { + enabled: overrideConfig.enabled ?? result.provider[provider].enabled, + strategies: overrideConfig.strategies + ? { + ...result.provider[provider].strategies, + ...overrideConfig.strategies + } + : result.provider[provider].strategies + } + } + } + } + return result +} + export function getConfig(ctx: PluginInput): PluginConfig { let config = deepCloneConfig(defaultConfig) @@ -520,6 +650,7 @@ export function getConfig(ctx: PluginInput): PluginConfig { enabled: result.data.enabled ?? config.enabled, debug: result.data.debug ?? config.debug, pruningSummary: result.data.pruningSummary ?? config.pruningSummary, + overrides: mergeOverrides(config.overrides, result.data.overrides), strategies: mergeStrategies(config.strategies, result.data.strategies as any) } } @@ -551,6 +682,7 @@ export function getConfig(ctx: PluginInput): PluginConfig { enabled: result.data.enabled ?? config.enabled, debug: result.data.debug ?? config.debug, pruningSummary: result.data.pruningSummary ?? config.pruningSummary, + overrides: mergeOverrides(config.overrides, result.data.overrides), strategies: mergeStrategies(config.strategies, result.data.strategies as any) } } @@ -579,6 +711,7 @@ export function getConfig(ctx: PluginInput): PluginConfig { enabled: result.data.enabled ?? config.enabled, debug: result.data.debug ?? config.debug, pruningSummary: result.data.pruningSummary ?? config.pruningSummary, + overrides: mergeOverrides(config.overrides, result.data.overrides), strategies: mergeStrategies(config.strategies, result.data.strategies as any) } } @@ -586,3 +719,46 @@ export function getConfig(ctx: PluginInput): PluginConfig { return config } + +export interface EffectiveProviderConfig { + enabled: boolean + strategies: { + deduplication: boolean + onIdle: boolean + pruneTool: boolean + supersedeWrites: boolean + } +} + +/** + * Computes the effective config for a specific provider, applying overrides.provider. + */ +export function getEffectiveProviderConfig( + config: PluginConfig, + providerID: string +): EffectiveProviderConfig { + // Check for provider-specific overrides + const override = config.overrides.provider?.[providerID] + if (override?.enabled === false) { + return { + enabled: false, + strategies: { + deduplication: false, + onIdle: false, + pruneTool: false, + supersedeWrites: false + } + } + } + + // Apply granular strategy overrides + return { + enabled: true, + strategies: { + deduplication: override?.strategies?.deduplication?.enabled ?? config.strategies.deduplication.enabled, + onIdle: override?.strategies?.onIdle?.enabled ?? config.strategies.onIdle.enabled, + pruneTool: override?.strategies?.pruneTool?.enabled ?? config.strategies.pruneTool.enabled, + supersedeWrites: override?.strategies?.supersedeWrites?.enabled ?? config.strategies.supersedeWrites.enabled + } + } +} diff --git a/lib/hooks.ts b/lib/hooks.ts index c578e4b..426fc8c 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -1,12 +1,64 @@ import type { SessionState, WithParts } from "./state" import type { Logger } from "./logger" import type { PluginConfig } from "./config" +import { getEffectiveProviderConfig } from "./config" import { syncToolCache } from "./state/tool-cache" import { deduplicate, supersedeWrites } from "./strategies" import { prune, insertPruneToolContext } from "./messages" import { checkSession } from "./state" import { runOnIdle } from "./strategies/on-idle" +export function createChatParamsHandler( + client: any, + state: SessionState, + logger: Logger, + config: PluginConfig +) { + return async ( + input: { + sessionID: string + agent: string + model: { id: string; providerID: string; name: string } + provider: { source: string; info: any; options: Record } + message: any + }, + _output: { temperature: number; topP: number; options: Record } + ) => { + const { model } = input + const providerID = model.providerID + + // Check if provider changed + if (state.provider.providerID !== providerID) { + logger.info("Provider changed", { + from: state.provider.providerID, + to: providerID, + model: model.id + }) + + state.provider.providerID = providerID + state.provider.modelID = model.id + state.provider.effectiveConfig = getEffectiveProviderConfig(config, providerID) + + // Show toast if DCP is disabled for this provider (but only once per provider switch) + if (!state.provider.effectiveConfig.enabled && state.provider.lastNotifiedProvider !== providerID) { + state.provider.lastNotifiedProvider = providerID + try { + await client.tui.showToast({ + body: { + title: "DCP Disabled", + message: `Dynamic Context Pruning is disabled for ${providerID}`, + variant: "info", + duration: 4000 + } + }) + } catch { + // Ignore toast errors + } + } + } + } +} + export function createChatMessageTransformHandler( client: any, @@ -24,14 +76,35 @@ export function createChatMessageTransformHandler( return } + // Check if DCP is disabled for the current provider + const effectiveConfig = state.provider.effectiveConfig + if (effectiveConfig && !effectiveConfig.enabled) { + logger.info("DCP disabled for provider", { provider: state.provider.providerID }) + return + } + syncToolCache(state, config, logger, output.messages); - deduplicate(state, logger, config, output.messages) - supersedeWrites(state, logger, config, output.messages) + // Apply strategies based on effective config (respects provider overrides) + const strategies = effectiveConfig?.strategies ?? { + deduplication: config.strategies.deduplication.enabled, + onIdle: config.strategies.onIdle.enabled, + pruneTool: config.strategies.pruneTool.enabled, + supersedeWrites: config.strategies.supersedeWrites.enabled + } + + if (strategies.deduplication) { + deduplicate(state, logger, config, output.messages) + } + if (strategies.supersedeWrites) { + supersedeWrites(state, logger, config, output.messages) + } prune(state, logger, config, output.messages) - insertPruneToolContext(state, config, logger, output.messages) + if (strategies.pruneTool) { + insertPruneToolContext(state, config, logger, output.messages) + } } } @@ -50,6 +123,14 @@ export function createEventHandler( } if (event.type === "session.status" && event.properties.status.type === "idle") { + // Check if onIdle is disabled globally or for current provider + const effectiveConfig = state.provider.effectiveConfig + if (effectiveConfig && !effectiveConfig.enabled) { + return + } + if (effectiveConfig && !effectiveConfig.strategies.onIdle) { + return + } if (!config.strategies.onIdle.enabled) { return } diff --git a/lib/state/state.ts b/lib/state/state.ts index e33f4c8..e1464db 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -53,7 +53,13 @@ export function createSessionState(): SessionState { nudgeCounter: 0, lastToolPrune: false, lastCompaction: 0, - currentTurn: 0 + currentTurn: 0, + provider: { + providerID: null, + modelID: null, + effectiveConfig: null, + lastNotifiedProvider: null + } } } @@ -72,6 +78,12 @@ export function resetSessionState(state: SessionState): void { state.lastToolPrune = false state.lastCompaction = 0 state.currentTurn = 0 + state.provider = { + providerID: null, + modelID: null, + effectiveConfig: null, + lastNotifiedProvider: null + } } export async function ensureSessionInitialized( diff --git a/lib/state/types.ts b/lib/state/types.ts index 04847d5..a22e782 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -1,4 +1,5 @@ import { Message, Part } from "@opencode-ai/sdk" +import type { EffectiveProviderConfig } from "../config" export interface WithParts { info: Message @@ -24,6 +25,13 @@ export interface Prune { toolIds: string[] } +export interface ProviderState { + providerID: string | null + modelID: string | null + effectiveConfig: EffectiveProviderConfig | null + lastNotifiedProvider: string | null +} + export interface SessionState { sessionId: string | null isSubAgent: boolean @@ -34,4 +42,5 @@ export interface SessionState { lastToolPrune: boolean lastCompaction: number currentTurn: number // Current turn count derived from step-start parts + provider: ProviderState } diff --git a/lib/strategies/prune-tool.ts b/lib/strategies/prune-tool.ts index a0ab83d..6c6d61b 100644 --- a/lib/strategies/prune-tool.ts +++ b/lib/strategies/prune-tool.ts @@ -50,6 +50,17 @@ export function createPruneTool( logger.info("Prune tool invoked") logger.info(JSON.stringify(args)) + // Check if prune tool is disabled for current provider + const effectiveConfig = state.provider.effectiveConfig + if (effectiveConfig && !effectiveConfig.enabled) { + logger.info("Prune tool disabled for provider", { provider: state.provider.providerID }) + return `DCP is disabled for ${state.provider.providerID}. Pruning is not available.` + } + if (effectiveConfig && !effectiveConfig.strategies.pruneTool) { + logger.info("Prune tool strategy disabled for provider", { provider: state.provider.providerID }) + return `Prune tool is disabled for ${state.provider.providerID}. Pruning is not available.` + } + if (!args.ids || args.ids.length === 0) { logger.debug("Prune tool called but args.ids is empty or undefined: " + JSON.stringify(args)) return "No IDs provided. Check the list for available IDs to prune." diff --git a/package-lock.json b/package-lock.json index 42b0ebd..00b6960 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-1.0.28.tgz", "integrity": "sha512-yKubDxLYtXyGUzkr9lNStf/lE/I+Okc8tmotvyABhsQHHieLKk6oV5fJeRJxhr67Ejhg+FRnwUOxAmjRoFM4dA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" @@ -1289,7 +1290,6 @@ "resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz", "integrity": "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==", "license": "MIT", - "peer": true, "dependencies": { "@oslojs/binary": "1.0.0" } @@ -1298,15 +1298,13 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@oslojs/binary/-/binary-1.0.0.tgz", "integrity": "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@oslojs/crypto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@oslojs/crypto/-/crypto-1.0.1.tgz", "integrity": "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==", "license": "MIT", - "peer": true, "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" @@ -1316,15 +1314,13 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@oslojs/jwt": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@oslojs/jwt/-/jwt-0.2.0.tgz", "integrity": "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==", "license": "MIT", - "peer": true, "dependencies": { "@oslojs/encoding": "0.4.1" } @@ -1333,8 +1329,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-0.4.1.tgz", "integrity": "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@smithy/abort-controller": { "version": "4.2.5", @@ -2269,6 +2264,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2301,6 +2297,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }