From 64054bdd261ac8549f17f3f16ebfb655aceb01cd Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 25 Nov 2025 22:05:04 -0500 Subject: [PATCH 1/5] 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 --- .gitignore | 3 + index.ts | 62 +++++++++++++++-- lib/config.ts | 184 ++++++++++++++++++++++++++++++++++++++++--------- lib/janitor.ts | 126 +++++++++++++++++++++++++++------ lib/prompt.ts | 16 ++++- 5 files changed, 329 insertions(+), 62 deletions(-) diff --git a/.gitignore b/.gitignore index f1f5836d..0688b834 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ Thumbs.db # Tests (local development only) tests/ + +# Development notes +notes/ diff --git a/index.ts b/index.ts index d3e5ab15..7aec0db1 100644 --- a/index.ts +++ b/index.ts @@ -1,8 +1,10 @@ // index.ts - Main plugin entry point for Dynamic Context Pruning import type { Plugin } from "@opencode-ai/plugin" +import { tool } from "@opencode-ai/plugin" import { getConfig } from "./lib/config" import { Logger } from "./lib/logger" import { Janitor, type SessionStats } from "./lib/janitor" +import { formatTokenCount } from "./lib/tokenizer" import { checkForUpdates } from "./lib/version-checker" /** @@ -20,7 +22,7 @@ async function isSubagentSession(client: any, sessionID: string): Promise { - const config = getConfig(ctx) + const { config, migrations } = getConfig(ctx) // Exit early if plugin is disabled if (!config.enabled) { @@ -38,7 +40,7 @@ const plugin: Plugin = (async (ctx) => { const statsState = new Map() const toolParametersCache = new Map() // callID -> parameters const modelCache = new Map() // sessionID -> model info - const janitor = new Janitor(ctx.client, prunedIdsState, statsState, logger, toolParametersCache, config.protectedTools, modelCache, config.model, config.showModelErrorToasts, config.pruningMode, config.pruning_summary, ctx.directory) + const janitor = new Janitor(ctx.client, prunedIdsState, statsState, logger, toolParametersCache, config.protectedTools, modelCache, config.model, config.showModelErrorToasts, config.pruning_summary, ctx.directory) const cacheToolParameters = (messages: any[]) => { for (const message of messages) { @@ -142,13 +144,31 @@ const plugin: Plugin = (async (ctx) => { } logger.info("plugin", "DCP initialized", { - mode: config.pruningMode, + strategies: config.strategies, model: config.model || "auto" }) // Check for updates on launch (fire and forget) checkForUpdates(ctx.client, logger).catch(() => {}) + // Show migration toast if config was migrated (delayed to not overlap with version toast) + if (migrations.length > 0) { + setTimeout(async () => { + try { + await ctx.client.tui.showToast({ + body: { + title: "DCP: Config upgraded", + message: migrations.join('\n'), + variant: "info", + duration: 8000 + } + }) + } catch { + // Silently fail - toast is non-critical + } + }, 7000) // 7s delay to show after version toast (6s) completes + } + return { /** * Event Hook: Triggers janitor analysis when session becomes idle @@ -157,9 +177,12 @@ const plugin: Plugin = (async (ctx) => { if (event.type === "session.status" && event.properties.status.type === "idle") { // Skip pruning for subagent sessions if (await isSubagentSession(ctx.client, event.properties.sessionID)) return + + // Skip if no idle strategies configured + if (config.strategies.onIdle.length === 0) return // Fire and forget the janitor - don't block the event handler - janitor.run(event.properties.sessionID).catch(err => { + janitor.runOnIdle(event.properties.sessionID, config.strategies.onIdle).catch(err => { logger.error("janitor", "Failed", { error: err.message }) }) } @@ -188,6 +211,37 @@ const plugin: Plugin = (async (ctx) => { }) } }, + + /** + * Tool Hook: Exposes context_pruning tool to AI (if configured) + */ + tool: config.strategies.onTool.length > 0 ? { + context_pruning: tool({ + description: "Performs semantic pruning on session tool outputs that are no longer " + + "relevant to the current task. Use this to declutter the conversation context and " + + "filter signal from noise when you notice the context is getting cluttered with " + + "outdated information (e.g., after completing a debugging session, switching to a " + + "new task, or when old file reads are no longer needed).", + args: { + reason: tool.schema.string().optional().describe( + "Brief reason for triggering pruning (e.g., 'task complete', 'switching focus')" + ), + }, + async execute(args, ctx) { + const result = await janitor.runForTool( + ctx.sessionID, + config.strategies.onTool, + args.reason + ) + + if (!result || result.prunedCount === 0) { + return "No prunable tool outputs found. Context is already optimized." + } + + return `Context pruning complete. Pruned ${result.prunedCount} tool outputs (~${formatTokenCount(result.tokensSaved)} tokens saved).` + }, + }), + } : undefined, } }) satisfies Plugin diff --git a/lib/config.ts b/lib/config.ts index a922a0fa..97ca785a 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,30 +1,59 @@ // lib/config.ts -import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs' +import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, copyFileSync } from 'fs' import { join, dirname } from 'path' import { homedir } from 'os' import { parse } from 'jsonc-parser' import { Logger } from './logger' import type { PluginInput } from '@opencode-ai/plugin' +// Pruning strategy types +export type PruningStrategy = "deduplication" | "llm-analysis" + export interface PluginConfig { enabled: boolean debug: boolean protectedTools: string[] model?: string // Format: "provider/model" (e.g., "anthropic/claude-haiku-4-5") showModelErrorToasts?: boolean // Show toast notifications when model selection fails - pruningMode: "auto" | "smart" // Pruning strategy: auto (deduplication only) or smart (deduplication + LLM analysis) pruning_summary: "off" | "minimal" | "detailed" // UI summary display mode + strategies: { + // Strategies for automatic pruning (on session idle). Empty array = idle pruning disabled + onIdle: PruningStrategy[] + // Strategies for the AI-callable tool. Empty array = tool not exposed to AI + onTool: PruningStrategy[] + } +} + +export interface ConfigResult { + config: PluginConfig + migrations: string[] // List of migration messages to show user } const defaultConfig: PluginConfig = { enabled: true, // Plugin is enabled by default debug: false, // Disable debug logging by default - protectedTools: ['task', 'todowrite', 'todoread'], // Tools that should never be pruned (including stateful tools) + protectedTools: ['task', 'todowrite', 'todoread', 'context_pruning'], // Tools that should never be pruned showModelErrorToasts: true, // Show model error toasts by default - pruningMode: 'smart', // Default to smart mode (deduplication + LLM analysis) - pruning_summary: 'detailed' // Default to detailed summary + pruning_summary: 'detailed', // Default to detailed summary + strategies: { + // Default: Full analysis on idle (like previous "smart" mode) + onIdle: ['deduplication', 'llm-analysis'], + // Default: Only deduplication when AI calls the tool (faster, no extra LLM cost) + onTool: ['deduplication'] + } } +// Valid top-level keys in the current config schema +const VALID_CONFIG_KEYS = new Set([ + 'enabled', + 'debug', + 'protectedTools', + 'model', + 'showModelErrorToasts', + 'pruning_summary', + 'strategies' +]) + const GLOBAL_CONFIG_DIR = join(homedir(), '.config', 'opencode') const GLOBAL_CONFIG_PATH_JSONC = join(GLOBAL_CONFIG_DIR, 'dcp.jsonc') const GLOBAL_CONFIG_PATH_JSON = join(GLOBAL_CONFIG_DIR, 'dcp.json') @@ -106,10 +135,17 @@ function createDefaultConfig(): void { // Set to false to disable these informational toasts "showModelErrorToasts": true, - // Pruning strategy: - // "auto": Automatic duplicate removal only (fast, no LLM cost) - // "smart": Deduplication + AI analysis for intelligent pruning (recommended) - "pruningMode": "smart", + // Pruning strategies configuration + // Available strategies: "deduplication", "llm-analysis" + // Empty array = disabled + "strategies": { + // Strategies to run when session goes idle (automatic) + "onIdle": ["deduplication", "llm-analysis"], + + // Strategies to run when AI calls the context_pruning tool + // Empty array = tool not exposed to AI + "onTool": ["deduplication"] + }, // Pruning summary display mode: // "off": No UI summary (silent pruning) @@ -120,7 +156,8 @@ function createDefaultConfig(): void { // List of tools that should never be pruned from context // "task": Each subagent invocation is intentional // "todowrite"/"todoread": Stateful tools where each call matters - "protectedTools": ["task", "todowrite", "todoread"] + // "context_pruning": The pruning tool itself + "protectedTools": ["task", "todowrite", "todoread", "context_pruning"] } ` @@ -130,15 +167,65 @@ function createDefaultConfig(): void { /** * Loads a single config file and parses it */ -function loadConfigFile(configPath: string): Partial | null { +function loadConfigFile(configPath: string): Record | null { try { const fileContent = readFileSync(configPath, 'utf-8') - return parse(fileContent) as Partial + return parse(fileContent) } catch (error: any) { return null } } +/** + * Check if config has any unknown or deprecated keys + */ +function getInvalidKeys(config: Record): string[] { + const invalidKeys: string[] = [] + for (const key of Object.keys(config)) { + if (!VALID_CONFIG_KEYS.has(key)) { + invalidKeys.push(key) + } + } + return invalidKeys +} + +/** + * Backs up existing config and creates fresh default config + * Returns the backup path if successful, null if failed + */ +function backupAndResetConfig(configPath: string, logger: Logger): string | null { + try { + const backupPath = configPath + '.bak' + + // Create backup + copyFileSync(configPath, backupPath) + logger.info('config', 'Created config backup', { backup: backupPath }) + + // Write fresh default config + createDefaultConfig() + logger.info('config', 'Created fresh default config', { path: GLOBAL_CONFIG_PATH_JSONC }) + + return backupPath + } catch (error: any) { + logger.error('config', 'Failed to backup/reset config', { error: error.message }) + return null + } +} + +/** + * Merge strategies config, handling partial overrides + */ +function mergeStrategies( + base: PluginConfig['strategies'], + override?: Partial +): PluginConfig['strategies'] { + if (!override) return base + return { + onIdle: override.onIdle ?? base.onIdle, + onTool: override.onTool ?? base.onTool + } +} + /** * Loads configuration with support for both global and project-level configs * @@ -147,30 +234,47 @@ function loadConfigFile(configPath: string): Partial | null { * 2. Merge with global config (~/.config/opencode/dcp.jsonc) * 3. Merge with project config (.opencode/dcp.jsonc) if found * + * If config has invalid/deprecated keys, backs up and resets to defaults. + * * Project config overrides global config, which overrides defaults. * * @param ctx - Plugin input context (optional). If provided, will search for project-level config. - * @returns Merged configuration + * @returns ConfigResult with merged configuration and any migration messages */ -export function getConfig(ctx?: PluginInput): PluginConfig { - let config = { ...defaultConfig } +export function getConfig(ctx?: PluginInput): ConfigResult { + let config = { ...defaultConfig, protectedTools: [...defaultConfig.protectedTools] } const configPaths = getConfigPaths(ctx) const logger = new Logger(true) // Always log config loading + const migrations: string[] = [] // 1. Load global config if (configPaths.global) { const globalConfig = loadConfigFile(configPaths.global) if (globalConfig) { - config = { - enabled: globalConfig.enabled ?? config.enabled, - debug: globalConfig.debug ?? config.debug, - protectedTools: globalConfig.protectedTools ?? config.protectedTools, - model: globalConfig.model ?? config.model, - showModelErrorToasts: globalConfig.showModelErrorToasts ?? config.showModelErrorToasts, - pruningMode: globalConfig.pruningMode ?? config.pruningMode, - pruning_summary: globalConfig.pruning_summary ?? config.pruning_summary + // Check for invalid keys + const invalidKeys = getInvalidKeys(globalConfig) + + if (invalidKeys.length > 0) { + // Config has deprecated/unknown keys - backup and reset + logger.info('config', 'Found invalid config keys', { keys: invalidKeys }) + const backupPath = backupAndResetConfig(configPaths.global, logger) + if (backupPath) { + migrations.push(`Old config backed up to ${backupPath}`) + } + // Config is now reset to defaults, no need to merge + } else { + // Valid config - merge with defaults + config = { + enabled: globalConfig.enabled ?? config.enabled, + debug: globalConfig.debug ?? config.debug, + protectedTools: globalConfig.protectedTools ?? config.protectedTools, + model: globalConfig.model ?? config.model, + showModelErrorToasts: globalConfig.showModelErrorToasts ?? config.showModelErrorToasts, + strategies: mergeStrategies(config.strategies, globalConfig.strategies as any), + pruning_summary: globalConfig.pruning_summary ?? config.pruning_summary + } + logger.info('config', 'Loaded global config', { path: configPaths.global }) } - logger.info('config', 'Loaded global config', { path: configPaths.global }) } } else { // Create default global config if it doesn't exist @@ -182,20 +286,32 @@ export function getConfig(ctx?: PluginInput): PluginConfig { if (configPaths.project) { const projectConfig = loadConfigFile(configPaths.project) if (projectConfig) { - config = { - enabled: projectConfig.enabled ?? config.enabled, - debug: projectConfig.debug ?? config.debug, - protectedTools: projectConfig.protectedTools ?? config.protectedTools, - model: projectConfig.model ?? config.model, - showModelErrorToasts: projectConfig.showModelErrorToasts ?? config.showModelErrorToasts, - pruningMode: projectConfig.pruningMode ?? config.pruningMode, - pruning_summary: projectConfig.pruning_summary ?? config.pruning_summary + // Check for invalid keys + const invalidKeys = getInvalidKeys(projectConfig) + + if (invalidKeys.length > 0) { + // Project config has deprecated/unknown keys - just warn, don't reset project configs + logger.warn('config', 'Project config has invalid keys (ignored)', { + path: configPaths.project, + keys: invalidKeys + }) + } else { + // Valid config - merge with current config + config = { + enabled: projectConfig.enabled ?? config.enabled, + debug: projectConfig.debug ?? config.debug, + protectedTools: projectConfig.protectedTools ?? config.protectedTools, + model: projectConfig.model ?? config.model, + showModelErrorToasts: projectConfig.showModelErrorToasts ?? config.showModelErrorToasts, + strategies: mergeStrategies(config.strategies, projectConfig.strategies as any), + pruning_summary: projectConfig.pruning_summary ?? config.pruning_summary + } + logger.info('config', 'Loaded project config (overrides global)', { path: configPaths.project }) } - logger.info('config', 'Loaded project config (overrides global)', { path: configPaths.project }) } } else if (ctx?.directory) { logger.debug('config', 'No project config found', { searchedFrom: ctx.directory }) } - return config + return { config, migrations } } diff --git a/lib/janitor.ts b/lib/janitor.ts index 3d50aa19..e61702f7 100644 --- a/lib/janitor.ts +++ b/lib/janitor.ts @@ -1,5 +1,6 @@ import { z } from "zod" import type { Logger } from "./logger" +import type { PruningStrategy } from "./config" import { buildAnalysisPrompt } from "./prompt" import { selectModel, extractModelFromSession } from "./model-selector" import { estimateTokensBatch, formatTokenCount } from "./tokenizer" @@ -10,6 +11,21 @@ export interface SessionStats { totalTokensSaved: number } +export interface PruningResult { + prunedCount: number + tokensSaved: number + deduplicatedIds: string[] + llmPrunedIds: string[] + deduplicationDetails: Map + toolMetadata: Map + sessionStats: SessionStats +} + +export interface PruningOptions { + reason?: string + trigger: 'idle' | 'tool' +} + export class Janitor { constructor( private client: any, @@ -21,7 +37,6 @@ export class Janitor { private modelCache: Map, private configModel?: string, // Format: "provider/model" private showModelErrorToasts: boolean = true, // Whether to show toast for model errors - private pruningMode: "auto" | "smart" = "smart", // Pruning strategy private pruningSummary: "off" | "minimal" | "detailed" = "detailed", // UI summary display mode private workingDirectory?: string // Current working directory for relative path display ) { } @@ -51,8 +66,39 @@ export class Janitor { } } - async run(sessionID: string) { + /** + * Convenience method for idle-triggered pruning (sends notification automatically) + */ + async runOnIdle(sessionID: string, strategies: PruningStrategy[]): Promise { + const result = await this.runWithStrategies(sessionID, strategies, { trigger: 'idle' }) + // Notification is handled inside runWithStrategies + } + + /** + * Convenience method for tool-triggered pruning (returns result for tool output) + */ + async runForTool( + sessionID: string, + strategies: PruningStrategy[], + reason?: string + ): Promise { + return await this.runWithStrategies(sessionID, strategies, { trigger: 'tool', reason }) + } + + /** + * Core pruning method that accepts strategies and options + */ + async runWithStrategies( + sessionID: string, + strategies: PruningStrategy[], + options: PruningOptions + ): Promise { try { + // Skip if no strategies configured + if (strategies.length === 0) { + return null + } + // Fetch session info and messages from OpenCode API const [sessionInfoResponse, messagesResponse] = await Promise.all([ this.client.session.get({ path: { id: sessionID } }), @@ -65,7 +111,7 @@ export class Janitor { // If there are no messages or very few, skip analysis if (!messages || messages.length < 3) { - return + return null } // Extract tool call IDs from the session and track their output sizes @@ -124,15 +170,20 @@ export class Janitor { // If there are no unpruned tool calls, skip analysis if (unprunedToolCallIds.length === 0) { - return + return null } // ============================================================ - // PHASE 1: DUPLICATE DETECTION (runs for both modes) + // PHASE 1: DUPLICATE DETECTION (if enabled) // ============================================================ - const dedupeResult = detectDuplicates(toolMetadata, unprunedToolCallIds, this.protectedTools) - const deduplicatedIds = dedupeResult.duplicateIds - const deduplicationDetails = dedupeResult.deduplicationDetails + let deduplicatedIds: string[] = [] + let deduplicationDetails = new Map() + + if (strategies.includes('deduplication')) { + const dedupeResult = detectDuplicates(toolMetadata, unprunedToolCallIds, this.protectedTools) + deduplicatedIds = dedupeResult.duplicateIds + deduplicationDetails = dedupeResult.deduplicationDetails + } // Calculate candidates available for pruning (excludes protected tools) const candidateCount = unprunedToolCallIds.filter(id => { @@ -141,11 +192,11 @@ export class Janitor { }).length // ============================================================ - // PHASE 2: LLM ANALYSIS (only runs in "smart" mode) + // PHASE 2: LLM ANALYSIS (if enabled) // ============================================================ let llmPrunedIds: string[] = [] - if (this.pruningMode === "smart") { + if (strategies.includes('llm-analysis')) { // Filter out duplicates and protected tools const protectedToolCallIds: string[] = [] const prunableToolCallIds = unprunedToolCallIds.filter(id => { @@ -198,8 +249,15 @@ export class Janitor { const allPrunedSoFar = [...alreadyPrunedIds, ...deduplicatedIds] const sanitizedMessages = this.replacePrunedToolOutputs(messages, allPrunedSoFar) - // Build the prompt for analysis - const analysisPrompt = buildAnalysisPrompt(prunableToolCallIds, sanitizedMessages, this.protectedTools, allPrunedSoFar, protectedToolCallIds) + // Build the prompt for analysis (pass reason if provided) + const analysisPrompt = buildAnalysisPrompt( + prunableToolCallIds, + sanitizedMessages, + this.protectedTools, + allPrunedSoFar, + protectedToolCallIds, + options.reason + ) // Save janitor shadow context directly (auth providers may bypass globalThis.fetch) await this.logger.saveWrappedContext( @@ -211,7 +269,9 @@ export class Janitor { modelID: modelSelection.modelInfo.modelID, candidateToolCount: prunableToolCallIds.length, alreadyPrunedCount: allPrunedSoFar.length, - protectedToolCount: protectedToolCallIds.length + protectedToolCount: protectedToolCallIds.length, + trigger: options.trigger, + reason: options.reason } ) @@ -245,7 +305,7 @@ export class Janitor { const newlyPrunedIds = [...deduplicatedIds, ...llmPrunedIds] if (newlyPrunedIds.length === 0) { - return + return null } // Expand batch tool IDs to include their children @@ -268,7 +328,7 @@ export class Janitor { const finalPrunedIds = Array.from(expandedPrunedIds) // ============================================================ - // PHASE 4: NOTIFICATION + // PHASE 4: CALCULATE STATS & NOTIFICATION // ============================================================ // Calculate token savings once (used by both notification and log) const tokensSaved = await this.calculateTokensSaved(finalNewlyPrunedIds, toolOutputs) @@ -281,21 +341,24 @@ export class Janitor { } this.statsState.set(sessionID, sessionStats) - if (this.pruningMode === "auto") { - await this.sendAutoModeNotification( + // Determine notification mode based on which strategies ran + const hasLlmAnalysis = strategies.includes('llm-analysis') + + if (hasLlmAnalysis) { + await this.sendSmartModeNotification( sessionID, deduplicatedIds, deduplicationDetails, + llmPrunedIds, + toolMetadata, tokensSaved, sessionStats ) } else { - await this.sendSmartModeNotification( + await this.sendAutoModeNotification( sessionID, deduplicatedIds, deduplicationDetails, - llmPrunedIds, - toolMetadata, tokensSaved, sessionStats ) @@ -314,14 +377,33 @@ export class Janitor { const keptCount = candidateCount - prunedCount const hasBoth = deduplicatedIds.length > 0 && llmPrunedIds.length > 0 const breakdown = hasBoth ? ` (${deduplicatedIds.length} duplicate, ${llmPrunedIds.length} llm)` : "" - this.logger.info("janitor", `Pruned ${prunedCount}/${candidateCount} tools${breakdown}, ${keptCount} kept (~${formatTokenCount(tokensSaved)} tokens)`) + + // Build log metadata + const logMeta: Record = { trigger: options.trigger } + if (options.reason) { + logMeta.reason = options.reason + } + + this.logger.info("janitor", `Pruned ${prunedCount}/${candidateCount} tools${breakdown}, ${keptCount} kept (~${formatTokenCount(tokensSaved)} tokens)`, logMeta) + + return { + prunedCount: finalNewlyPrunedIds.length, + tokensSaved, + deduplicatedIds, + llmPrunedIds, + deduplicationDetails, + toolMetadata, + sessionStats + } } catch (error: any) { this.logger.error("janitor", "Analysis failed", { - error: error.message + error: error.message, + trigger: options.trigger }) // Don't throw - this is a fire-and-forget background process // Silently fail and try again on next idle event + return null } } diff --git a/lib/prompt.ts b/lib/prompt.ts index 6139a471..143df8fb 100644 --- a/lib/prompt.ts +++ b/lib/prompt.ts @@ -103,7 +103,14 @@ function minimizeMessages(messages: any[], alreadyPrunedIds?: string[], protecte }) } -export function buildAnalysisPrompt(unprunedToolCallIds: string[], messages: any[], protectedTools: string[], alreadyPrunedIds?: string[], protectedToolCallIds?: string[]): string { +export function buildAnalysisPrompt( + unprunedToolCallIds: string[], + messages: any[], + protectedTools: string[], + alreadyPrunedIds?: string[], + protectedToolCallIds?: string[], + reason?: string // Optional reason from tool call +): string { // Minimize messages to reduce token usage, passing already-pruned and protected IDs for replacement const minimizedMessages = minimizeMessages(messages, alreadyPrunedIds, protectedToolCallIds) @@ -111,8 +118,13 @@ export function buildAnalysisPrompt(unprunedToolCallIds: string[], messages: any // This makes the logged prompts much more readable const messagesJson = JSON.stringify(minimizedMessages, null, 2).replace(/\\n/g, '\n') - return `You are a conversation analyzer that identifies obsolete tool outputs in a coding session. + // Build optional context section if reason provided + const reasonContext = reason + ? `\nContext: The AI has requested pruning with the following reason: "${reason}"\nUse this context to inform your decisions about what is most relevant to keep.\n` + : '' + return `You are a conversation analyzer that identifies obsolete tool outputs in a coding session. +${reasonContext} Your task: Analyze the session history and identify tool call IDs whose outputs are NO LONGER RELEVANT to the current conversation context. Guidelines for identifying obsolete tool calls: From 1f0601442351e925db04981afb358dd1a889ed0c Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 25 Nov 2025 23:23:29 -0500 Subject: [PATCH 2/5] Rename 'llm-analysis' strategy to 'ai-analysis' for clearer terminology --- lib/config.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index 97ca785a..98d1b70a 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -7,7 +7,7 @@ import { Logger } from './logger' import type { PluginInput } from '@opencode-ai/plugin' // Pruning strategy types -export type PruningStrategy = "deduplication" | "llm-analysis" +export type PruningStrategy = "deduplication" | "ai-analysis" export interface PluginConfig { enabled: boolean @@ -37,7 +37,7 @@ const defaultConfig: PluginConfig = { pruning_summary: 'detailed', // Default to detailed summary strategies: { // Default: Full analysis on idle (like previous "smart" mode) - onIdle: ['deduplication', 'llm-analysis'], + onIdle: ['deduplication', 'ai-analysis'], // Default: Only deduplication when AI calls the tool (faster, no extra LLM cost) onTool: ['deduplication'] } @@ -136,11 +136,11 @@ function createDefaultConfig(): void { "showModelErrorToasts": true, // Pruning strategies configuration - // Available strategies: "deduplication", "llm-analysis" + // Available strategies: "deduplication", "ai-analysis" // Empty array = disabled "strategies": { // Strategies to run when session goes idle (automatic) - "onIdle": ["deduplication", "llm-analysis"], + "onIdle": ["deduplication", "ai-analysis"], // Strategies to run when AI calls the context_pruning tool // Empty array = tool not exposed to AI From e46066afefbd67e65c2dbb5cf3f662d7187dc045 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 25 Nov 2025 23:23:37 -0500 Subject: [PATCH 3/5] Improve tool output formatting and add usage examples to context_pruning description - Add detailed usage guidance and examples to context_pruning tool description - Extract shared formatting helpers in janitor (groupDeduplicationDetails, formatDeduplicationLines, formatToolSummaryLines) - Add formatPruningResultForTool method for structured tool output - Remove unused protectedTools parameter from buildAnalysisPrompt - Update janitor to use 'ai-analysis' strategy name --- index.ts | 46 ++++++++++++---- lib/janitor.ts | 141 ++++++++++++++++++++++++++++++++++++------------- lib/prompt.ts | 1 - 3 files changed, 140 insertions(+), 48 deletions(-) diff --git a/index.ts b/index.ts index 7aec0db1..64daf9bd 100644 --- a/index.ts +++ b/index.ts @@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin" import { getConfig } from "./lib/config" import { Logger } from "./lib/logger" import { Janitor, type SessionStats } from "./lib/janitor" -import { formatTokenCount } from "./lib/tokenizer" + import { checkForUpdates } from "./lib/version-checker" /** @@ -149,7 +149,7 @@ const plugin: Plugin = (async (ctx) => { }) // Check for updates on launch (fire and forget) - checkForUpdates(ctx.client, logger).catch(() => {}) + checkForUpdates(ctx.client, logger).catch(() => { }) // Show migration toast if config was migrated (delayed to not overlap with version toast) if (migrations.length > 0) { @@ -177,7 +177,7 @@ const plugin: Plugin = (async (ctx) => { if (event.type === "session.status" && event.properties.status.type === "idle") { // Skip pruning for subagent sessions if (await isSubagentSession(ctx.client, event.properties.sessionID)) return - + // Skip if no idle strategies configured if (config.strategies.onIdle.length === 0) return @@ -191,7 +191,7 @@ const plugin: Plugin = (async (ctx) => { /** * Chat Params Hook: Caches model info for janitor */ - "chat.params": async (input, output) => { + "chat.params": async (input, _output) => { const sessionId = input.sessionID // Cache model information for this session so janitor can access it @@ -217,11 +217,37 @@ const plugin: Plugin = (async (ctx) => { */ tool: config.strategies.onTool.length > 0 ? { context_pruning: tool({ - description: "Performs semantic pruning on session tool outputs that are no longer " + - "relevant to the current task. Use this to declutter the conversation context and " + - "filter signal from noise when you notice the context is getting cluttered with " + - "outdated information (e.g., after completing a debugging session, switching to a " + - "new task, or when old file reads are no longer needed).", + description: `Performs semantic pruning on session tool outputs that are no longer relevant to the current task. Use this to declutter the conversation context and filter signal from noise when you notice the context is getting cluttered with outdated information. + +## When to Use This Tool + +- After completing a debugging session or fixing a bug +- When switching focus to a new task or feature +- After exploring multiple files that didn't lead to changes +- When you've been iterating on a difficult problem and some approaches didn't pan out +- When old file reads, greps, or bash outputs are no longer relevant + +## Examples + + +Working through a list of bugs to fix: +User: Please fix these 5 type errors in the codebase. +Assistant: I'll work through each error. [Fixes first error] +First error fixed. Let me prune the debugging context before moving to the next one. +[Uses context_pruning with reason: "first bug fixed, moving to next task"] + + + +After exploring the codebase to understand it: +Assistant: I've reviewed the relevant files. Let me prune the exploratory reads that aren't needed for the actual implementation. +[Uses context_pruning with reason: "exploration complete, pruning unrelated file reads"] + + + +After trying multiple approaches that didn't work: +Assistant: I've been trying several approaches to fix this issue. Let me prune the failed attempts to keep focus on the working solution. +[Uses context_pruning with reason: "pruning failed iteration attempts, keeping working solution context"] +`, args: { reason: tool.schema.string().optional().describe( "Brief reason for triggering pruning (e.g., 'task complete', 'switching focus')" @@ -238,7 +264,7 @@ const plugin: Plugin = (async (ctx) => { return "No prunable tool outputs found. Context is already optimized." } - return `Context pruning complete. Pruned ${result.prunedCount} tool outputs (~${formatTokenCount(result.tokensSaved)} tokens saved).` + return janitor.formatPruningResultForTool(result) }, }), } : undefined, diff --git a/lib/janitor.ts b/lib/janitor.ts index e61702f7..0ce903ca 100644 --- a/lib/janitor.ts +++ b/lib/janitor.ts @@ -70,7 +70,7 @@ export class Janitor { * Convenience method for idle-triggered pruning (sends notification automatically) */ async runOnIdle(sessionID: string, strategies: PruningStrategy[]): Promise { - const result = await this.runWithStrategies(sessionID, strategies, { trigger: 'idle' }) + await this.runWithStrategies(sessionID, strategies, { trigger: 'idle' }) // Notification is handled inside runWithStrategies } @@ -196,7 +196,7 @@ export class Janitor { // ============================================================ let llmPrunedIds: string[] = [] - if (strategies.includes('llm-analysis')) { + if (strategies.includes('ai-analysis')) { // Filter out duplicates and protected tools const protectedToolCallIds: string[] = [] const prunableToolCallIds = unprunedToolCallIds.filter(id => { @@ -253,7 +253,6 @@ export class Janitor { const analysisPrompt = buildAnalysisPrompt( prunableToolCallIds, sanitizedMessages, - this.protectedTools, allPrunedSoFar, protectedToolCallIds, options.reason @@ -342,8 +341,8 @@ export class Janitor { this.statsState.set(sessionID, sessionStats) // Determine notification mode based on which strategies ran - const hasLlmAnalysis = strategies.includes('llm-analysis') - + const hasLlmAnalysis = strategies.includes('ai-analysis') + if (hasLlmAnalysis) { await this.sendSmartModeNotification( sessionID, @@ -377,13 +376,13 @@ export class Janitor { const keptCount = candidateCount - prunedCount const hasBoth = deduplicatedIds.length > 0 && llmPrunedIds.length > 0 const breakdown = hasBoth ? ` (${deduplicatedIds.length} duplicate, ${llmPrunedIds.length} llm)` : "" - + // Build log metadata const logMeta: Record = { trigger: options.trigger } if (options.reason) { logMeta.reason = options.reason } - + this.logger.info("janitor", `Pruned ${prunedCount}/${candidateCount} tools${breakdown}, ${keptCount} kept (~${formatTokenCount(tokensSaved)} tokens)`, logMeta) return { @@ -568,6 +567,73 @@ export class Janitor { return toolsSummary } + /** + * Group deduplication details by tool type + * Shared helper used by notifications and tool output formatting + */ + private groupDeduplicationDetails( + deduplicationDetails: Map + ): Map> { + const grouped = new Map>() + + for (const [_, details] of deduplicationDetails) { + const { toolName, parameterKey, duplicateCount } = details + if (!grouped.has(toolName)) { + grouped.set(toolName, []) + } + grouped.get(toolName)!.push({ + count: duplicateCount, + key: this.shortenPath(parameterKey) + }) + } + + return grouped + } + + /** + * Format grouped deduplication results as lines + * Shared helper for building deduplication summaries + */ + private formatDeduplicationLines( + grouped: Map>, + indent: string = ' ' + ): string[] { + const lines: string[] = [] + + for (const [toolName, items] of grouped.entries()) { + for (const item of items) { + const removedCount = item.count - 1 + lines.push(`${indent}${toolName}: ${item.key} (${removedCount}× duplicate)`) + } + } + + return lines + } + + /** + * Format tool summary (from buildToolsSummary) as lines + * Shared helper for building LLM-pruned summaries + */ + private formatToolSummaryLines( + toolsSummary: Map, + indent: string = ' ' + ): string[] { + const lines: string[] = [] + + for (const [toolName, params] of toolsSummary.entries()) { + if (params.length === 1) { + lines.push(`${indent}${toolName}: ${params[0]}`) + } else if (params.length > 1) { + lines.push(`${indent}${toolName} (${params.length}):`) + for (const param of params) { + lines.push(`${indent} ${param}`) + } + } + } + + return lines + } + /** * Send minimal summary notification (just tokens saved and count) */ @@ -625,21 +691,10 @@ export class Janitor { } message += '\n' - // Group by tool type - const grouped = new Map>() + // Group by tool type using shared helper + const grouped = this.groupDeduplicationDetails(deduplicationDetails) - for (const [_, details] of deduplicationDetails) { - const { toolName, parameterKey, duplicateCount } = details - if (!grouped.has(toolName)) { - grouped.set(toolName, []) - } - grouped.get(toolName)!.push({ - count: duplicateCount, - key: this.shortenPath(parameterKey) - }) - } - - // Display grouped results + // Display grouped results (with UI-specific formatting: total dupes header, limit to 5) for (const [toolName, items] of grouped.entries()) { const totalDupes = items.reduce((sum, item) => sum + (item.count - 1), 0) message += `\n${toolName} (${totalDupes} duplicate${totalDupes > 1 ? 's' : ''}):\n` @@ -657,6 +712,33 @@ export class Janitor { await this.sendIgnoredMessage(sessionID, message.trim()) } + /** + * Format pruning result for tool output (returned to AI) + * Uses shared helpers for consistency with UI notifications + */ + formatPruningResultForTool(result: PruningResult): string { + const lines: string[] = [] + lines.push(`Context pruning complete. Pruned ${result.prunedCount} tool outputs.`) + lines.push('') + + // Section 1: Deduplicated tools + if (result.deduplicatedIds.length > 0 && result.deduplicationDetails.size > 0) { + lines.push(`Duplicates removed (${result.deduplicatedIds.length}):`) + const grouped = this.groupDeduplicationDetails(result.deduplicationDetails) + lines.push(...this.formatDeduplicationLines(grouped)) + lines.push('') + } + + // Section 2: LLM-pruned tools + if (result.llmPrunedIds.length > 0) { + lines.push(`Semantically pruned (${result.llmPrunedIds.length}):`) + const toolsSummary = this.buildToolsSummary(result.llmPrunedIds, result.toolMetadata) + lines.push(...this.formatToolSummaryLines(toolsSummary)) + } + + return lines.join('\n').trim() + } + /** * Smart mode notification - shows both deduplication and LLM analysis results */ @@ -695,20 +777,7 @@ export class Janitor { // Section 1: Deduplicated tools if (deduplicatedIds.length > 0 && deduplicationDetails) { message += `\n📦 Duplicates removed (${deduplicatedIds.length}):\n` - - // Group by tool type - const grouped = new Map>() - - for (const [_, details] of deduplicationDetails) { - const { toolName, parameterKey, duplicateCount } = details - if (!grouped.has(toolName)) { - grouped.set(toolName, []) - } - grouped.get(toolName)!.push({ - count: duplicateCount, - key: this.shortenPath(parameterKey) - }) - } + const grouped = this.groupDeduplicationDetails(deduplicationDetails) for (const [toolName, items] of grouped.entries()) { message += ` ${toolName}:\n` @@ -722,8 +791,6 @@ export class Janitor { // Section 2: LLM-pruned tools if (llmPrunedIds.length > 0) { message += `\n🤖 LLM analysis (${llmPrunedIds.length}):\n` - - // Use buildToolsSummary logic const toolsSummary = this.buildToolsSummary(llmPrunedIds, toolMetadata) for (const [toolName, params] of toolsSummary.entries()) { diff --git a/lib/prompt.ts b/lib/prompt.ts index 143df8fb..6266bdfd 100644 --- a/lib/prompt.ts +++ b/lib/prompt.ts @@ -106,7 +106,6 @@ function minimizeMessages(messages: any[], alreadyPrunedIds?: string[], protecte export function buildAnalysisPrompt( unprunedToolCallIds: string[], messages: any[], - protectedTools: string[], alreadyPrunedIds?: string[], protectedToolCallIds?: string[], reason?: string // Optional reason from tool call From 0cb63e78405fbcf895ada335287a74cffa70d329 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 25 Nov 2025 23:25:50 -0500 Subject: [PATCH 4/5] Fix minor formatting inconsistencies - Remove extra blank line between imports in index.ts - Remove space inside empty catch callback braces - Fix trailing newline in prompt template causing double blank line --- index.ts | 3 +-- lib/prompt.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index 64daf9bd..6b13c75b 100644 --- a/index.ts +++ b/index.ts @@ -4,7 +4,6 @@ import { tool } from "@opencode-ai/plugin" import { getConfig } from "./lib/config" import { Logger } from "./lib/logger" import { Janitor, type SessionStats } from "./lib/janitor" - import { checkForUpdates } from "./lib/version-checker" /** @@ -149,7 +148,7 @@ const plugin: Plugin = (async (ctx) => { }) // Check for updates on launch (fire and forget) - checkForUpdates(ctx.client, logger).catch(() => { }) + checkForUpdates(ctx.client, logger).catch(() => {}) // Show migration toast if config was migrated (delayed to not overlap with version toast) if (migrations.length > 0) { diff --git a/lib/prompt.ts b/lib/prompt.ts index 6266bdfd..22f094f3 100644 --- a/lib/prompt.ts +++ b/lib/prompt.ts @@ -119,7 +119,7 @@ export function buildAnalysisPrompt( // Build optional context section if reason provided const reasonContext = reason - ? `\nContext: The AI has requested pruning with the following reason: "${reason}"\nUse this context to inform your decisions about what is most relevant to keep.\n` + ? `\nContext: The AI has requested pruning with the following reason: "${reason}"\nUse this context to inform your decisions about what is most relevant to keep.` : '' return `You are a conversation analyzer that identifies obsolete tool outputs in a coding session. From 09ccfb4978a06a6460f97ee461dbc3656323bd4e Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 25 Nov 2025 23:27:00 -0500 Subject: [PATCH 5/5] v0.3.15 - Bump version --- README.md | 2 +- package-lock.json | 18 +++++++++++------- package.json | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8a1374eb..ae0ab1c4 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ If you want to ensure a specific version is always used or update your version, ```json { "plugin": [ - "@tarquinen/opencode-dcp@0.3.14" + "@tarquinen/opencode-dcp@0.3.15" ] } ``` diff --git a/package-lock.json b/package-lock.json index a066cc39..b7c6599a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tarquinen/opencode-dcp", - "version": "0.3.14", + "version": "0.3.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "0.3.14", + "version": "0.3.15", "license": "MIT", "dependencies": { "@ai-sdk/openai-compatible": "^1.0.27", @@ -1289,6 +1289,7 @@ "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" } @@ -1297,13 +1298,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@oslojs/binary/-/binary-1.0.0.tgz", "integrity": "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "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" @@ -1313,13 +1316,15 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "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" } @@ -1328,7 +1333,8 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-0.4.1.tgz", "integrity": "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@smithy/abort-controller": { "version": "4.2.5", @@ -2227,7 +2233,6 @@ "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" @@ -2260,7 +2265,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index b5a4b939..227031fb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@tarquinen/opencode-dcp", - "version": "0.3.14", + "version": "0.3.15", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js",