11// lib/config.ts
2- import { readFileSync , writeFileSync , existsSync , mkdirSync , statSync } from 'fs'
2+ import { readFileSync , writeFileSync , existsSync , mkdirSync , statSync , copyFileSync } from 'fs'
33import { join , dirname } from 'path'
44import { homedir } from 'os'
55import { parse } from 'jsonc-parser'
66import { Logger } from './logger'
77import type { PluginInput } from '@opencode-ai/plugin'
88
9+ // Pruning strategy types
10+ export type PruningStrategy = "deduplication" | "llm-analysis"
11+
912export interface PluginConfig {
1013 enabled : boolean
1114 debug : boolean
1215 protectedTools : string [ ]
1316 model ?: string // Format: "provider/model" (e.g., "anthropic/claude-haiku-4-5")
1417 showModelErrorToasts ?: boolean // Show toast notifications when model selection fails
15- pruningMode : "auto" | "smart" // Pruning strategy: auto (deduplication only) or smart (deduplication + LLM analysis)
1618 pruning_summary : "off" | "minimal" | "detailed" // UI summary display mode
19+ strategies : {
20+ // Strategies for automatic pruning (on session idle). Empty array = idle pruning disabled
21+ onIdle : PruningStrategy [ ]
22+ // Strategies for the AI-callable tool. Empty array = tool not exposed to AI
23+ onTool : PruningStrategy [ ]
24+ }
25+ }
26+
27+ export interface ConfigResult {
28+ config : PluginConfig
29+ migrations : string [ ] // List of migration messages to show user
1730}
1831
1932const defaultConfig : PluginConfig = {
2033 enabled : true , // Plugin is enabled by default
2134 debug : false , // Disable debug logging by default
22- protectedTools : [ 'task' , 'todowrite' , 'todoread' ] , // Tools that should never be pruned (including stateful tools)
35+ protectedTools : [ 'task' , 'todowrite' , 'todoread' , 'context_pruning' ] , // Tools that should never be pruned
2336 showModelErrorToasts : true , // Show model error toasts by default
24- pruningMode : 'smart' , // Default to smart mode (deduplication + LLM analysis)
25- pruning_summary : 'detailed' // Default to detailed summary
37+ pruning_summary : 'detailed' , // Default to detailed summary
38+ strategies : {
39+ // Default: Full analysis on idle (like previous "smart" mode)
40+ onIdle : [ 'deduplication' , 'llm-analysis' ] ,
41+ // Default: Only deduplication when AI calls the tool (faster, no extra LLM cost)
42+ onTool : [ 'deduplication' ]
43+ }
2644}
2745
46+ // Valid top-level keys in the current config schema
47+ const VALID_CONFIG_KEYS = new Set ( [
48+ 'enabled' ,
49+ 'debug' ,
50+ 'protectedTools' ,
51+ 'model' ,
52+ 'showModelErrorToasts' ,
53+ 'pruning_summary' ,
54+ 'strategies'
55+ ] )
56+
2857const GLOBAL_CONFIG_DIR = join ( homedir ( ) , '.config' , 'opencode' )
2958const GLOBAL_CONFIG_PATH_JSONC = join ( GLOBAL_CONFIG_DIR , 'dcp.jsonc' )
3059const GLOBAL_CONFIG_PATH_JSON = join ( GLOBAL_CONFIG_DIR , 'dcp.json' )
@@ -106,10 +135,17 @@ function createDefaultConfig(): void {
106135 // Set to false to disable these informational toasts
107136 "showModelErrorToasts": true,
108137
109- // Pruning strategy:
110- // "auto": Automatic duplicate removal only (fast, no LLM cost)
111- // "smart": Deduplication + AI analysis for intelligent pruning (recommended)
112- "pruningMode": "smart",
138+ // Pruning strategies configuration
139+ // Available strategies: "deduplication", "llm-analysis"
140+ // Empty array = disabled
141+ "strategies": {
142+ // Strategies to run when session goes idle (automatic)
143+ "onIdle": ["deduplication", "llm-analysis"],
144+
145+ // Strategies to run when AI calls the context_pruning tool
146+ // Empty array = tool not exposed to AI
147+ "onTool": ["deduplication"]
148+ },
113149
114150 // Pruning summary display mode:
115151 // "off": No UI summary (silent pruning)
@@ -120,7 +156,8 @@ function createDefaultConfig(): void {
120156 // List of tools that should never be pruned from context
121157 // "task": Each subagent invocation is intentional
122158 // "todowrite"/"todoread": Stateful tools where each call matters
123- "protectedTools": ["task", "todowrite", "todoread"]
159+ // "context_pruning": The pruning tool itself
160+ "protectedTools": ["task", "todowrite", "todoread", "context_pruning"]
124161}
125162`
126163
@@ -130,15 +167,65 @@ function createDefaultConfig(): void {
130167/**
131168 * Loads a single config file and parses it
132169 */
133- function loadConfigFile ( configPath : string ) : Partial < PluginConfig > | null {
170+ function loadConfigFile ( configPath : string ) : Record < string , any > | null {
134171 try {
135172 const fileContent = readFileSync ( configPath , 'utf-8' )
136- return parse ( fileContent ) as Partial < PluginConfig >
173+ return parse ( fileContent )
137174 } catch ( error : any ) {
138175 return null
139176 }
140177}
141178
179+ /**
180+ * Check if config has any unknown or deprecated keys
181+ */
182+ function getInvalidKeys ( config : Record < string , any > ) : string [ ] {
183+ const invalidKeys : string [ ] = [ ]
184+ for ( const key of Object . keys ( config ) ) {
185+ if ( ! VALID_CONFIG_KEYS . has ( key ) ) {
186+ invalidKeys . push ( key )
187+ }
188+ }
189+ return invalidKeys
190+ }
191+
192+ /**
193+ * Backs up existing config and creates fresh default config
194+ * Returns the backup path if successful, null if failed
195+ */
196+ function backupAndResetConfig ( configPath : string , logger : Logger ) : string | null {
197+ try {
198+ const backupPath = configPath + '.bak'
199+
200+ // Create backup
201+ copyFileSync ( configPath , backupPath )
202+ logger . info ( 'config' , 'Created config backup' , { backup : backupPath } )
203+
204+ // Write fresh default config
205+ createDefaultConfig ( )
206+ logger . info ( 'config' , 'Created fresh default config' , { path : GLOBAL_CONFIG_PATH_JSONC } )
207+
208+ return backupPath
209+ } catch ( error : any ) {
210+ logger . error ( 'config' , 'Failed to backup/reset config' , { error : error . message } )
211+ return null
212+ }
213+ }
214+
215+ /**
216+ * Merge strategies config, handling partial overrides
217+ */
218+ function mergeStrategies (
219+ base : PluginConfig [ 'strategies' ] ,
220+ override ?: Partial < PluginConfig [ 'strategies' ] >
221+ ) : PluginConfig [ 'strategies' ] {
222+ if ( ! override ) return base
223+ return {
224+ onIdle : override . onIdle ?? base . onIdle ,
225+ onTool : override . onTool ?? base . onTool
226+ }
227+ }
228+
142229/**
143230 * Loads configuration with support for both global and project-level configs
144231 *
@@ -147,30 +234,47 @@ function loadConfigFile(configPath: string): Partial<PluginConfig> | null {
147234 * 2. Merge with global config (~/.config/opencode/dcp.jsonc)
148235 * 3. Merge with project config (.opencode/dcp.jsonc) if found
149236 *
237+ * If config has invalid/deprecated keys, backs up and resets to defaults.
238+ *
150239 * Project config overrides global config, which overrides defaults.
151240 *
152241 * @param ctx - Plugin input context (optional). If provided, will search for project-level config.
153- * @returns Merged configuration
242+ * @returns ConfigResult with merged configuration and any migration messages
154243 */
155- export function getConfig ( ctx ?: PluginInput ) : PluginConfig {
156- let config = { ...defaultConfig }
244+ export function getConfig ( ctx ?: PluginInput ) : ConfigResult {
245+ let config = { ...defaultConfig , protectedTools : [ ... defaultConfig . protectedTools ] }
157246 const configPaths = getConfigPaths ( ctx )
158247 const logger = new Logger ( true ) // Always log config loading
248+ const migrations : string [ ] = [ ]
159249
160250 // 1. Load global config
161251 if ( configPaths . global ) {
162252 const globalConfig = loadConfigFile ( configPaths . global )
163253 if ( globalConfig ) {
164- config = {
165- enabled : globalConfig . enabled ?? config . enabled ,
166- debug : globalConfig . debug ?? config . debug ,
167- protectedTools : globalConfig . protectedTools ?? config . protectedTools ,
168- model : globalConfig . model ?? config . model ,
169- showModelErrorToasts : globalConfig . showModelErrorToasts ?? config . showModelErrorToasts ,
170- pruningMode : globalConfig . pruningMode ?? config . pruningMode ,
171- pruning_summary : globalConfig . pruning_summary ?? config . pruning_summary
254+ // Check for invalid keys
255+ const invalidKeys = getInvalidKeys ( globalConfig )
256+
257+ if ( invalidKeys . length > 0 ) {
258+ // Config has deprecated/unknown keys - backup and reset
259+ logger . info ( 'config' , 'Found invalid config keys' , { keys : invalidKeys } )
260+ const backupPath = backupAndResetConfig ( configPaths . global , logger )
261+ if ( backupPath ) {
262+ migrations . push ( `Old config backed up to ${ backupPath } ` )
263+ }
264+ // Config is now reset to defaults, no need to merge
265+ } else {
266+ // Valid config - merge with defaults
267+ config = {
268+ enabled : globalConfig . enabled ?? config . enabled ,
269+ debug : globalConfig . debug ?? config . debug ,
270+ protectedTools : globalConfig . protectedTools ?? config . protectedTools ,
271+ model : globalConfig . model ?? config . model ,
272+ showModelErrorToasts : globalConfig . showModelErrorToasts ?? config . showModelErrorToasts ,
273+ strategies : mergeStrategies ( config . strategies , globalConfig . strategies as any ) ,
274+ pruning_summary : globalConfig . pruning_summary ?? config . pruning_summary
275+ }
276+ logger . info ( 'config' , 'Loaded global config' , { path : configPaths . global } )
172277 }
173- logger . info ( 'config' , 'Loaded global config' , { path : configPaths . global } )
174278 }
175279 } else {
176280 // Create default global config if it doesn't exist
@@ -182,20 +286,32 @@ export function getConfig(ctx?: PluginInput): PluginConfig {
182286 if ( configPaths . project ) {
183287 const projectConfig = loadConfigFile ( configPaths . project )
184288 if ( projectConfig ) {
185- config = {
186- enabled : projectConfig . enabled ?? config . enabled ,
187- debug : projectConfig . debug ?? config . debug ,
188- protectedTools : projectConfig . protectedTools ?? config . protectedTools ,
189- model : projectConfig . model ?? config . model ,
190- showModelErrorToasts : projectConfig . showModelErrorToasts ?? config . showModelErrorToasts ,
191- pruningMode : projectConfig . pruningMode ?? config . pruningMode ,
192- pruning_summary : projectConfig . pruning_summary ?? config . pruning_summary
289+ // Check for invalid keys
290+ const invalidKeys = getInvalidKeys ( projectConfig )
291+
292+ if ( invalidKeys . length > 0 ) {
293+ // Project config has deprecated/unknown keys - just warn, don't reset project configs
294+ logger . warn ( 'config' , 'Project config has invalid keys (ignored)' , {
295+ path : configPaths . project ,
296+ keys : invalidKeys
297+ } )
298+ } else {
299+ // Valid config - merge with current config
300+ config = {
301+ enabled : projectConfig . enabled ?? config . enabled ,
302+ debug : projectConfig . debug ?? config . debug ,
303+ protectedTools : projectConfig . protectedTools ?? config . protectedTools ,
304+ model : projectConfig . model ?? config . model ,
305+ showModelErrorToasts : projectConfig . showModelErrorToasts ?? config . showModelErrorToasts ,
306+ strategies : mergeStrategies ( config . strategies , projectConfig . strategies as any ) ,
307+ pruning_summary : projectConfig . pruning_summary ?? config . pruning_summary
308+ }
309+ logger . info ( 'config' , 'Loaded project config (overrides global)' , { path : configPaths . project } )
193310 }
194- logger . info ( 'config' , 'Loaded project config (overrides global)' , { path : configPaths . project } )
195311 }
196312 } else if ( ctx ?. directory ) {
197313 logger . debug ( 'config' , 'No project config found' , { searchedFrom : ctx . directory } )
198314 }
199315
200- return config
316+ return { config, migrations }
201317}
0 commit comments