Skip to content

Commit 64054bd

Browse files
committed
Add context_pruning tool and refactor config to strategies-based system
- Add AI-callable context_pruning tool that lets the model trigger pruning on demand - Replace pruningMode (auto/smart) with strategies config (onIdle/onTool arrays) - Add config validation with automatic backup (.bak) and reset on deprecated keys - Show toast notification when config is upgraded - Refactor janitor to support both idle-triggered and tool-triggered pruning
1 parent 8267728 commit 64054bd

File tree

5 files changed

+329
-62
lines changed

5 files changed

+329
-62
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,6 @@ Thumbs.db
3030

3131
# Tests (local development only)
3232
tests/
33+
34+
# Development notes
35+
notes/

index.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
// index.ts - Main plugin entry point for Dynamic Context Pruning
22
import type { Plugin } from "@opencode-ai/plugin"
3+
import { tool } from "@opencode-ai/plugin"
34
import { getConfig } from "./lib/config"
45
import { Logger } from "./lib/logger"
56
import { Janitor, type SessionStats } from "./lib/janitor"
7+
import { formatTokenCount } from "./lib/tokenizer"
68
import { checkForUpdates } from "./lib/version-checker"
79

810
/**
@@ -20,7 +22,7 @@ async function isSubagentSession(client: any, sessionID: string): Promise<boolea
2022
}
2123

2224
const plugin: Plugin = (async (ctx) => {
23-
const config = getConfig(ctx)
25+
const { config, migrations } = getConfig(ctx)
2426

2527
// Exit early if plugin is disabled
2628
if (!config.enabled) {
@@ -38,7 +40,7 @@ const plugin: Plugin = (async (ctx) => {
3840
const statsState = new Map<string, SessionStats>()
3941
const toolParametersCache = new Map<string, any>() // callID -> parameters
4042
const modelCache = new Map<string, { providerID: string; modelID: string }>() // sessionID -> model info
41-
const janitor = new Janitor(ctx.client, prunedIdsState, statsState, logger, toolParametersCache, config.protectedTools, modelCache, config.model, config.showModelErrorToasts, config.pruningMode, config.pruning_summary, ctx.directory)
43+
const janitor = new Janitor(ctx.client, prunedIdsState, statsState, logger, toolParametersCache, config.protectedTools, modelCache, config.model, config.showModelErrorToasts, config.pruning_summary, ctx.directory)
4244

4345
const cacheToolParameters = (messages: any[]) => {
4446
for (const message of messages) {
@@ -142,13 +144,31 @@ const plugin: Plugin = (async (ctx) => {
142144
}
143145

144146
logger.info("plugin", "DCP initialized", {
145-
mode: config.pruningMode,
147+
strategies: config.strategies,
146148
model: config.model || "auto"
147149
})
148150

149151
// Check for updates on launch (fire and forget)
150152
checkForUpdates(ctx.client, logger).catch(() => {})
151153

154+
// Show migration toast if config was migrated (delayed to not overlap with version toast)
155+
if (migrations.length > 0) {
156+
setTimeout(async () => {
157+
try {
158+
await ctx.client.tui.showToast({
159+
body: {
160+
title: "DCP: Config upgraded",
161+
message: migrations.join('\n'),
162+
variant: "info",
163+
duration: 8000
164+
}
165+
})
166+
} catch {
167+
// Silently fail - toast is non-critical
168+
}
169+
}, 7000) // 7s delay to show after version toast (6s) completes
170+
}
171+
152172
return {
153173
/**
154174
* Event Hook: Triggers janitor analysis when session becomes idle
@@ -157,9 +177,12 @@ const plugin: Plugin = (async (ctx) => {
157177
if (event.type === "session.status" && event.properties.status.type === "idle") {
158178
// Skip pruning for subagent sessions
159179
if (await isSubagentSession(ctx.client, event.properties.sessionID)) return
180+
181+
// Skip if no idle strategies configured
182+
if (config.strategies.onIdle.length === 0) return
160183

161184
// Fire and forget the janitor - don't block the event handler
162-
janitor.run(event.properties.sessionID).catch(err => {
185+
janitor.runOnIdle(event.properties.sessionID, config.strategies.onIdle).catch(err => {
163186
logger.error("janitor", "Failed", { error: err.message })
164187
})
165188
}
@@ -188,6 +211,37 @@ const plugin: Plugin = (async (ctx) => {
188211
})
189212
}
190213
},
214+
215+
/**
216+
* Tool Hook: Exposes context_pruning tool to AI (if configured)
217+
*/
218+
tool: config.strategies.onTool.length > 0 ? {
219+
context_pruning: tool({
220+
description: "Performs semantic pruning on session tool outputs that are no longer " +
221+
"relevant to the current task. Use this to declutter the conversation context and " +
222+
"filter signal from noise when you notice the context is getting cluttered with " +
223+
"outdated information (e.g., after completing a debugging session, switching to a " +
224+
"new task, or when old file reads are no longer needed).",
225+
args: {
226+
reason: tool.schema.string().optional().describe(
227+
"Brief reason for triggering pruning (e.g., 'task complete', 'switching focus')"
228+
),
229+
},
230+
async execute(args, ctx) {
231+
const result = await janitor.runForTool(
232+
ctx.sessionID,
233+
config.strategies.onTool,
234+
args.reason
235+
)
236+
237+
if (!result || result.prunedCount === 0) {
238+
return "No prunable tool outputs found. Context is already optimized."
239+
}
240+
241+
return `Context pruning complete. Pruned ${result.prunedCount} tool outputs (~${formatTokenCount(result.tokensSaved)} tokens saved).`
242+
},
243+
}),
244+
} : undefined,
191245
}
192246
}) satisfies Plugin
193247

lib/config.ts

Lines changed: 150 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,59 @@
11
// lib/config.ts
2-
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs'
2+
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, copyFileSync } from 'fs'
33
import { join, dirname } from 'path'
44
import { homedir } from 'os'
55
import { parse } from 'jsonc-parser'
66
import { Logger } from './logger'
77
import type { PluginInput } from '@opencode-ai/plugin'
88

9+
// Pruning strategy types
10+
export type PruningStrategy = "deduplication" | "llm-analysis"
11+
912
export 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

1932
const 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+
2857
const GLOBAL_CONFIG_DIR = join(homedir(), '.config', 'opencode')
2958
const GLOBAL_CONFIG_PATH_JSONC = join(GLOBAL_CONFIG_DIR, 'dcp.jsonc')
3059
const 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

Comments
 (0)