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/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/index.ts b/index.ts index d3e5ab15..6b13c75b 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,6 @@ // 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" @@ -20,7 +21,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 +39,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 +143,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 @@ -158,8 +177,11 @@ const plugin: Plugin = (async (ctx) => { // 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 }) }) } @@ -168,7 +190,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 @@ -188,6 +210,63 @@ 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. + +## 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')" + ), + }, + 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 janitor.formatPruningResultForTool(result) + }, + }), + } : undefined, } }) satisfies Plugin diff --git a/lib/config.ts b/lib/config.ts index a922a0fa..98d1b70a 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" | "ai-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', 'ai-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", "ai-analysis" + // Empty array = disabled + "strategies": { + // Strategies to run when session goes idle (automatic) + "onIdle": ["deduplication", "ai-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..0ce903ca 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 { + 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('ai-analysis')) { // Filter out duplicates and protected tools const protectedToolCallIds: string[] = [] const prunableToolCallIds = unprunedToolCallIds.filter(id => { @@ -198,8 +249,14 @@ 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, + allPrunedSoFar, + protectedToolCallIds, + options.reason + ) // Save janitor shadow context directly (auth providers may bypass globalThis.fetch) await this.logger.saveWrappedContext( @@ -211,7 +268,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 +304,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 +327,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 +340,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('ai-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 +376,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 } } @@ -486,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) */ @@ -543,21 +691,10 @@ export class Janitor { } message += '\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) - }) - } + // Group by tool type using shared helper + const grouped = this.groupDeduplicationDetails(deduplicationDetails) - // 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` @@ -575,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 */ @@ -613,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` @@ -640,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 6139a471..22f094f3 100644 --- a/lib/prompt.ts +++ b/lib/prompt.ts @@ -103,7 +103,13 @@ 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[], + 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 +117,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.` + : '' + 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: 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",