diff --git a/README.md b/README.md index 6f929b9..72e4a15 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,27 @@ Automatically reduces token usage in OpenCode by removing obsolete tool outputs ![DCP in action](dcp-demo.png) +## Pruning Strategies + +DCP implements two complementary strategies: + +**Deduplication** — Fast, zero-cost pruning that identifies repeated tool calls (e.g., reading the same file multiple times) and keeps only the most recent output. Runs instantly with no LLM calls. + +**AI Analysis** — Uses a language model to semantically analyze conversation context and identify tool outputs that are no longer relevant to the current task. More thorough but incurs LLM cost. + ## Installation -Add to your OpenCode config (`~/.config/opencode/opencode.json` or `.opencode/opencode.json`): +Add to your OpenCode config: -```json +```jsonc +// opencode.jsonc { - "plugin": ["@tarquinen/opencode-dcp"] + "plugins": ["@tarquinen/opencode-dcp@0.3.17"] } ``` +When a new version is available, DCP will show a toast notification. Update by changing the version number in your config. + Restart OpenCode. The plugin will automatically start optimizing your sessions. > **Note:** Project `plugin` arrays override global completely—include all desired plugins in project config if using both. @@ -36,6 +47,7 @@ DCP uses its own config file (`~/.config/opencode/dcp.jsonc` or `.opencode/dcp.j | `debug` | `false` | Log to `~/.config/opencode/logs/dcp/` | | `model` | (session) | Model for analysis (e.g., `"anthropic/claude-haiku-4-5"`) | | `showModelErrorToasts` | `true` | Show notifications on model fallback | +| `strictModelSelection` | `false` | Only run AI analysis with session or configured model (disables fallback models) | | `pruning_summary` | `"detailed"` | `"off"`, `"minimal"`, or `"detailed"` | | `protectedTools` | `["task", "todowrite", "todoread", "context_pruning"]` | Tools that are never pruned | | `strategies.onIdle` | `["deduplication", "ai-analysis"]` | Strategies for automatic pruning | @@ -54,13 +66,11 @@ DCP uses its own config file (`~/.config/opencode/dcp.jsonc` or `.opencode/dcp.j } ``` -Settings merge: **Defaults** → **Global** → **Project**. Restart OpenCode after changes. +### Config Precedence -### Version Pinning +Settings are merged in order: **Defaults** → **Global** (`~/.config/opencode/dcp.jsonc`) → **Project** (`.opencode/dcp.jsonc`). Each level overrides the previous, so project settings take priority over global, which takes priority over defaults. -```json -{ "plugin": ["@tarquinen/opencode-dcp@0.3.16"] } -``` +Restart OpenCode after making config changes. ## License diff --git a/index.ts b/index.ts index c8784d5..744070b 100644 --- a/index.ts +++ b/index.ts @@ -1,4 +1,3 @@ -// 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" @@ -6,16 +5,11 @@ import { Logger } from "./lib/logger" import { Janitor, type SessionStats } from "./lib/janitor" import { checkForUpdates } from "./lib/version-checker" -/** - * Checks if a session is a subagent (child session) - * Subagent sessions should skip pruning operations - */ async function isSubagentSession(client: any, sessionID: string): Promise { try { const result = await client.session.get({ path: { id: sessionID } }) return !!result.data?.parentID } catch (error: any) { - // On error, assume it's not a subagent and continue (fail open) return false } } @@ -23,23 +17,20 @@ async function isSubagentSession(client: any, sessionID: string): Promise { const { config, migrations } = getConfig(ctx) - // Exit early if plugin is disabled if (!config.enabled) { return {} } - // Suppress AI SDK warnings about responseFormat (harmless for our use case) if (typeof globalThis !== 'undefined') { (globalThis as any).AI_SDK_LOG_WARNINGS = false } - // Logger uses ~/.config/opencode/logs/dcp/ for consistent log location const logger = new Logger(config.debug) const prunedIdsState = new Map() 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.pruning_summary, ctx.directory) + const toolParametersCache = new Map() + const modelCache = new Map() + const janitor = new Janitor(ctx.client, prunedIdsState, statsState, logger, toolParametersCache, config.protectedTools, modelCache, config.model, config.showModelErrorToasts, config.strictModelSelection, config.pruning_summary, ctx.directory) const cacheToolParameters = (messages: any[]) => { for (const message of messages) { @@ -61,45 +52,37 @@ const plugin: Plugin = (async (ctx) => { parameters: params }) } catch (error) { - // Ignore JSON parse errors for individual tool calls } } } } - // Global fetch wrapper that both caches tool parameters AND performs pruning - // This works because all providers ultimately call globalThis.fetch + // Global fetch wrapper - caches tool parameters and performs pruning const originalGlobalFetch = globalThis.fetch globalThis.fetch = async (input: any, init?: any) => { if (init?.body && typeof init.body === 'string') { try { const body = JSON.parse(init.body) if (body.messages && Array.isArray(body.messages)) { - // Cache tool parameters for janitor metadata cacheToolParameters(body.messages) - // Check for tool messages that might need pruning const toolMessages = body.messages.filter((m: any) => m.role === 'tool') - // Collect all pruned IDs across all sessions (excluding subagents) - // This is safe because tool_call_ids are globally unique const allSessions = await ctx.client.session.list() const allPrunedIds = new Set() if (allSessions.data) { for (const session of allSessions.data) { - if (session.parentID) continue // Skip subagent sessions + if (session.parentID) continue const prunedIds = prunedIdsState.get(session.id) ?? [] prunedIds.forEach((id: string) => allPrunedIds.add(id)) } } - // Only process tool message replacement if there are tool messages and pruned IDs if (toolMessages.length > 0 && allPrunedIds.size > 0) { let replacedCount = 0 body.messages = body.messages.map((m: any) => { - // Normalize ID to lowercase for case-insensitive matching if (m.role === 'tool' && allPrunedIds.has(m.tool_call_id?.toLowerCase())) { replacedCount++ return { @@ -116,7 +99,6 @@ const plugin: Plugin = (async (ctx) => { total: toolMessages.length }) - // Save wrapped context to file if debug is enabled if (logger.enabled) { await logger.saveWrappedContext( "global", @@ -129,13 +111,11 @@ const plugin: Plugin = (async (ctx) => { ) } - // Update the request body with modified messages init.body = JSON.stringify(body) } } } } catch (e) { - // Ignore parse errors and fall through to original fetch } } @@ -147,10 +127,8 @@ const plugin: Plugin = (async (ctx) => { 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 { @@ -163,42 +141,27 @@ const plugin: Plugin = (async (ctx) => { } }) } catch { - // Silently fail - toast is non-critical } - }, 7000) // 7s delay to show after version toast (6s) completes + }, 7000) } return { - /** - * Event Hook: Triggers janitor analysis when session becomes idle - */ event: async ({ event }) => { 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.runOnIdle(event.properties.sessionID, config.strategies.onIdle).catch(err => { logger.error("janitor", "Failed", { error: err.message }) }) } }, - /** - * Chat Params Hook: Caches model info for janitor - */ "chat.params": async (input, _output) => { const sessionId = input.sessionID - - // Cache model information for this session so janitor can access it - // The provider.id is actually nested at provider.info.id (not in SDK types) let providerID = (input.provider as any)?.info?.id || input.provider?.id const modelID = input.model?.id - // If provider.id is not available, try to get it from the message if (!providerID && input.message?.model?.providerID) { providerID = input.message.model.providerID } @@ -211,9 +174,6 @@ 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 no longer needed information. diff --git a/lib/config.ts b/lib/config.ts index 98d1b70..60d5b59 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,4 +1,3 @@ -// lib/config.ts import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, copyFileSync } from 'fs' import { join, dirname } from 'path' import { homedir } from 'os' @@ -6,50 +5,47 @@ 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 - pruning_summary: "off" | "minimal" | "detailed" // UI summary display mode + model?: string + showModelErrorToasts?: boolean + strictModelSelection?: boolean + pruning_summary: "off" | "minimal" | "detailed" 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 + migrations: string[] } const defaultConfig: PluginConfig = { - enabled: true, // Plugin is enabled by default - debug: false, // Disable debug logging by default - protectedTools: ['task', 'todowrite', 'todoread', 'context_pruning'], // Tools that should never be pruned - showModelErrorToasts: true, // Show model error toasts by default - pruning_summary: 'detailed', // Default to detailed summary + enabled: true, + debug: false, + protectedTools: ['task', 'todowrite', 'todoread', 'context_pruning'], + showModelErrorToasts: true, + strictModelSelection: false, + pruning_summary: 'detailed', 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', + 'strictModelSelection', 'pruning_summary', 'strategies' ]) @@ -58,10 +54,6 @@ 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') -/** - * Searches for .opencode directory starting from current directory and going up - * Returns the path to .opencode directory if found, null otherwise - */ function findOpencodeDir(startDir: string): string | null { let current = startDir while (current !== '/') { @@ -70,18 +62,13 @@ function findOpencodeDir(startDir: string): string | null { return candidate } const parent = dirname(current) - if (parent === current) break // Reached root + if (parent === current) break current = parent } return null } -/** - * Determines which config file to use (prefers .jsonc, falls back to .json) - * Checks both project-level and global configs - */ function getConfigPaths(ctx?: PluginInput): { global: string | null, project: string | null } { - // Global config paths let globalPath: string | null = null if (existsSync(GLOBAL_CONFIG_PATH_JSONC)) { globalPath = GLOBAL_CONFIG_PATH_JSONC @@ -89,7 +76,6 @@ function getConfigPaths(ctx?: PluginInput): { global: string | null, project: st globalPath = GLOBAL_CONFIG_PATH_JSON } - // Project config paths (if context provided) let projectPath: string | null = null if (ctx?.directory) { const opencodeDir = findOpencodeDir(ctx.directory) @@ -107,56 +93,32 @@ function getConfigPaths(ctx?: PluginInput): { global: string | null, project: st return { global: globalPath, project: projectPath } } -/** - * Creates the default configuration file with helpful comments - */ function createDefaultConfig(): void { - // Ensure the directory exists if (!existsSync(GLOBAL_CONFIG_DIR)) { mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true }) } const configContent = `{ - // Enable or disable the Dynamic Context Pruning plugin + // Enable or disable the plugin "enabled": true, - // Enable debug logging to ~/.config/opencode/logs/dcp/ - // Outputs include: - // - daily/YYYY-MM-DD.log (plugin activity, decisions, errors) - // - ai-context/*.json (messages sent to AI after pruning) "debug": false, - - // Optional: Specify a model to use for analysis instead of the session model - // Format: "provider/model" (same as agent model config in opencode.jsonc) - // NOTE: Anthropic OAuth sonnet 4+ tier models are currently not supported + // Override model for analysis (format: "provider/model", e.g. "anthropic/claude-haiku-4-5") // "model": "anthropic/claude-haiku-4-5", - - // Show toast notifications when model selection fails and falls back - // Set to false to disable these informational toasts + // Show toast notifications when model selection fails "showModelErrorToasts": true, - - // Pruning strategies configuration - // Available strategies: "deduplication", "ai-analysis" - // Empty array = disabled + // Only run AI analysis with session model or configured model (disables fallback models) + "strictModelSelection": false, + // Pruning strategies: "deduplication", "ai-analysis" (empty array = disabled) "strategies": { - // Strategies to run when session goes idle (automatic) + // Strategies to run when session goes idle "onIdle": ["deduplication", "ai-analysis"], - - // Strategies to run when AI calls the context_pruning tool - // Empty array = tool not exposed to AI + // Strategies to run when AI calls context_pruning tool "onTool": ["deduplication"] }, - - // Pruning summary display mode: - // "off": No UI summary (silent pruning) - // "minimal": Show tokens saved and count (e.g., "Saved ~2.5K tokens (6 tools pruned)") - // "detailed": Show full breakdown by tool type and pruning method (default) + // Summary display: "off", "minimal", or "detailed" "pruning_summary": "detailed", - - // List of tools that should never be pruned from context - // "task": Each subagent invocation is intentional - // "todowrite"/"todoread": Stateful tools where each call matters - // "context_pruning": The pruning tool itself + // Tools that should never be pruned "protectedTools": ["task", "todowrite", "todoread", "context_pruning"] } ` @@ -164,9 +126,6 @@ function createDefaultConfig(): void { writeFileSync(GLOBAL_CONFIG_PATH_JSONC, configContent, 'utf-8') } -/** - * Loads a single config file and parses it - */ function loadConfigFile(configPath: string): Record | null { try { const fileContent = readFileSync(configPath, 'utf-8') @@ -176,9 +135,6 @@ function loadConfigFile(configPath: string): Record | 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)) { @@ -189,22 +145,13 @@ function getInvalidKeys(config: Record): string[] { 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 }) @@ -212,9 +159,6 @@ function backupAndResetConfig(configPath: string, logger: Logger): string | null } } -/** - * Merge strategies config, handling partial overrides - */ function mergeStrategies( base: PluginConfig['strategies'], override?: Partial @@ -226,50 +170,31 @@ function mergeStrategies( } } -/** - * Loads configuration with support for both global and project-level configs - * - * Config resolution order: - * 1. Start with default config - * 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 ConfigResult with merged configuration and any migration messages - */ 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 logger = new Logger(true) const migrations: string[] = [] - // 1. Load global config if (configPaths.global) { const globalConfig = loadConfigFile(configPaths.global) if (globalConfig) { - // 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, + strictModelSelection: globalConfig.strictModelSelection ?? config.strictModelSelection, strategies: mergeStrategies(config.strategies, globalConfig.strategies as any), pruning_summary: globalConfig.pruning_summary ?? config.pruning_summary } @@ -277,32 +202,29 @@ export function getConfig(ctx?: PluginInput): ConfigResult { } } } else { - // Create default global config if it doesn't exist createDefaultConfig() logger.info('config', 'Created default global config', { path: GLOBAL_CONFIG_PATH_JSONC }) } - // 2. Load project config (overrides global) if (configPaths.project) { const projectConfig = loadConfigFile(configPaths.project) if (projectConfig) { - // 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)', { + logger.warn('config', 'Project config has invalid keys (ignored)', { path: configPaths.project, - keys: invalidKeys + keys: invalidKeys }) + migrations.push(`Project config has invalid keys: ${invalidKeys.join(', ')}`) } 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, + strictModelSelection: projectConfig.strictModelSelection ?? config.strictModelSelection, strategies: mergeStrategies(config.strategies, projectConfig.strategies as any), pruning_summary: projectConfig.pruning_summary ?? config.pruning_summary } diff --git a/lib/deduplicator.ts b/lib/deduplicator.ts index 15767b5..2ba2c7d 100644 --- a/lib/deduplicator.ts +++ b/lib/deduplicator.ts @@ -1,10 +1,3 @@ -/** - * Deduplicator Module - * - * Handles automatic detection and removal of duplicate tool calls based on - * tool name + parameter signature matching. - */ - export interface DuplicateDetectionResult { duplicateIds: string[] // IDs to prune (older duplicates) deduplicationDetails: Map } -/** - * Detects duplicate tool calls based on tool name + parameter signature - * Keeps only the most recent occurrence of each duplicate set - * Respects protected tools - they are never deduplicated - */ export function detectDuplicates( toolMetadata: Map, unprunedToolCallIds: string[], // In chronological order @@ -28,13 +16,11 @@ export function detectDuplicates( ): DuplicateDetectionResult { const signatureMap = new Map() - // Filter out protected tools before processing const deduplicatableIds = unprunedToolCallIds.filter(id => { const metadata = toolMetadata.get(id) return !metadata || !protectedTools.includes(metadata.tool) }) - // Build map of signature -> [ids in chronological order] for (const id of deduplicatableIds) { const metadata = toolMetadata.get(id) if (!metadata) continue @@ -46,7 +32,6 @@ export function detectDuplicates( signatureMap.get(signature)!.push(id) } - // Identify duplicates (keep only last occurrence) const duplicateIds: string[] = [] const deduplicationDetails = new Map() @@ -69,25 +54,14 @@ export function detectDuplicates( return { duplicateIds, deduplicationDetails } } -/** - * Creates a deterministic signature for a tool call - * Format: "toolName::JSON(sortedParameters)" - */ function createToolSignature(tool: string, parameters?: any): string { if (!parameters) return tool - // Normalize parameters for consistent comparison const normalized = normalizeParameters(parameters) const sorted = sortObjectKeys(normalized) return `${tool}::${JSON.stringify(sorted)}` } -/** - * Normalize parameters to handle edge cases: - * - Remove undefined/null values - * - Resolve relative paths to absolute (future enhancement) - * - Sort arrays if order doesn't matter (future enhancement) - */ function normalizeParameters(params: any): any { if (typeof params !== 'object' || params === null) return params if (Array.isArray(params)) return params @@ -101,9 +75,6 @@ function normalizeParameters(params: any): any { return normalized } -/** - * Recursively sort object keys for deterministic comparison - */ function sortObjectKeys(obj: any): any { if (typeof obj !== 'object' || obj === null) return obj if (Array.isArray(obj)) return obj.map(sortObjectKeys) @@ -115,31 +86,11 @@ function sortObjectKeys(obj: any): any { return sorted } -/** - * Extract human-readable parameter key for notifications - * Supports all ACTIVE OpenCode tools with appropriate parameter extraction - * - * ACTIVE Tools (always available): - * - File: read, write, edit - * - Search: list, glob, grep - * - Execution: bash - * - Agent: task - * - Web: webfetch - * - Todo: todowrite, todoread - * - * CONDITIONAL Tools (may be disabled): - * - batch (experimental.batch_tool flag) - * - websearch, codesearch (OPENCODE_EXPERIMENTAL_EXA flag) - * - * INACTIVE Tools (exist but not registered, skip): - * - multiedit, patch, lsp_diagnostics, lsp_hover - */ export function extractParameterKey(metadata: { tool: string, parameters?: any }): string { if (!metadata.parameters) return '' const { tool, parameters } = metadata - // ===== File Operation Tools ===== if (tool === "read" && parameters.filePath) { return parameters.filePath } @@ -150,13 +101,10 @@ export function extractParameterKey(metadata: { tool: string, parameters?: any } return parameters.filePath } - // ===== Directory/Search Tools ===== if (tool === "list") { - // path is optional, defaults to current directory return parameters.path || '(current directory)' } if (tool === "glob") { - // pattern is required for glob if (parameters.pattern) { const pathInfo = parameters.path ? ` in ${parameters.path}` : "" return `"${parameters.pattern}"${pathInfo}` @@ -171,7 +119,6 @@ export function extractParameterKey(metadata: { tool: string, parameters?: any } return '(unknown pattern)' } - // ===== Execution Tools ===== if (tool === "bash") { if (parameters.description) return parameters.description if (parameters.command) { @@ -181,7 +128,6 @@ export function extractParameterKey(metadata: { tool: string, parameters?: any } } } - // ===== Web Tools ===== if (tool === "webfetch" && parameters.url) { return parameters.url } @@ -192,8 +138,6 @@ export function extractParameterKey(metadata: { tool: string, parameters?: any } return `"${parameters.query}"` } - // ===== Todo Tools ===== - // Note: Todo tools are stateful and in protectedTools by default if (tool === "todowrite") { return `${parameters.todos?.length || 0} todos` } @@ -201,22 +145,16 @@ export function extractParameterKey(metadata: { tool: string, parameters?: any } return "read todo list" } - // ===== Agent/Task Tools ===== - // Note: task is in protectedTools by default if (tool === "task" && parameters.description) { return parameters.description } - // Note: batch is experimental and needs special handling if (tool === "batch") { return `${parameters.tool_calls?.length || 0} parallel tools` } - // ===== Fallback ===== - // For unknown tools, custom tools, or tools without extractable keys - // Check if parameters is empty or only has empty values const paramStr = JSON.stringify(parameters) if (paramStr === '{}' || paramStr === '[]' || paramStr === 'null') { - return '' // Return empty to trigger (default) fallback in UI + return '' } return paramStr.substring(0, 50) } diff --git a/lib/janitor.ts b/lib/janitor.ts index dfece47..7013f84 100644 --- a/lib/janitor.ts +++ b/lib/janitor.ts @@ -35,23 +35,19 @@ export class Janitor { private toolParametersCache: Map, private protectedTools: string[], private modelCache: Map, - private configModel?: string, // Format: "provider/model" - private showModelErrorToasts: boolean = true, // Whether to show toast for model errors - private pruningSummary: "off" | "minimal" | "detailed" = "detailed", // UI summary display mode - private workingDirectory?: string // Current working directory for relative path display + private configModel?: string, + private showModelErrorToasts: boolean = true, + private strictModelSelection: boolean = false, + private pruningSummary: "off" | "minimal" | "detailed" = "detailed", + private workingDirectory?: string ) { } - /** - * Sends an ignored message to the session UI (user sees it, AI doesn't) - */ private async sendIgnoredMessage(sessionID: string, text: string) { try { await this.client.session.prompt({ - path: { - id: sessionID - }, + path: { id: sessionID }, body: { - noReply: true, // Don't wait for AI response + noReply: true, parts: [{ type: 'text', text: text, @@ -60,23 +56,14 @@ export class Janitor { } }) } catch (error: any) { - this.logger.error("janitor", "Failed to send notification", { - error: error.message - }) + this.logger.error("janitor", "Failed to send notification", { error: error.message }) } } - /** - * 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[], @@ -85,78 +72,59 @@ export class Janitor { 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 } }), this.client.session.messages({ path: { id: sessionID }, query: { limit: 100 } }) ]) const sessionInfo = sessionInfoResponse.data - // Handle the response format - it should be { data: Array<{info, parts}> } or just the array const messages = messagesResponse.data || messagesResponse - // If there are no messages or very few, skip analysis if (!messages || messages.length < 3) { return null } - // Extract tool call IDs from the session and track their output sizes - // Also track batch tool relationships and tool metadata const toolCallIds: string[] = [] const toolOutputs = new Map() - const toolMetadata = new Map() // callID -> {tool, parameters} - const batchToolChildren = new Map() // batchID -> [childIDs] + const toolMetadata = new Map() + const batchToolChildren = new Map() let currentBatchId: string | null = null for (const msg of messages) { if (msg.parts) { for (const part of msg.parts) { if (part.type === "tool" && part.callID) { - // Normalize tool call IDs to lowercase for consistent comparison const normalizedId = part.callID.toLowerCase() toolCallIds.push(normalizedId) - // Try to get parameters from cache first, fall back to part.parameters - // Cache might have either case, so check both const cachedData = this.toolParametersCache.get(part.callID) || this.toolParametersCache.get(normalizedId) const parameters = cachedData?.parameters || part.parameters - // Track tool metadata (name and parameters) toolMetadata.set(normalizedId, { tool: part.tool, parameters: parameters }) - // Track the output content for size calculation if (part.state?.status === "completed" && part.state.output) { toolOutputs.set(normalizedId, part.state.output) } - // Check if this is a batch tool by looking at the tool name if (part.tool === "batch") { currentBatchId = normalizedId batchToolChildren.set(normalizedId, []) - } - // If we're inside a batch and this is a prt_ (parallel) tool call, it's a child - else if (currentBatchId && normalizedId.startsWith('prt_')) { + } else if (currentBatchId && normalizedId.startsWith('prt_')) { batchToolChildren.get(currentBatchId)!.push(normalizedId) - } - // If we hit a non-batch, non-prt_ tool, we're out of the batch - else if (currentBatchId && !normalizedId.startsWith('prt_')) { + } else if (currentBatchId && !normalizedId.startsWith('prt_')) { currentBatchId = null } } @@ -164,18 +132,14 @@ export class Janitor { } } - // Get already pruned IDs to filter them out const alreadyPrunedIds = this.prunedIdsState.get(sessionID) ?? [] const unprunedToolCallIds = toolCallIds.filter(id => !alreadyPrunedIds.includes(id)) - // If there are no unpruned tool calls, skip analysis if (unprunedToolCallIds.length === 0) { return null } - // ============================================================ - // PHASE 1: DUPLICATE DETECTION (if enabled) - // ============================================================ + // PHASE 1: DUPLICATE DETECTION let deduplicatedIds: string[] = [] let deduplicationDetails = new Map() @@ -185,25 +149,19 @@ export class Janitor { deduplicationDetails = dedupeResult.deduplicationDetails } - // Calculate candidates available for pruning (excludes protected tools) const candidateCount = unprunedToolCallIds.filter(id => { const metadata = toolMetadata.get(id) return !metadata || !this.protectedTools.includes(metadata.tool) }).length - // ============================================================ - // PHASE 2: LLM ANALYSIS (if enabled) - // ============================================================ + // PHASE 2: LLM ANALYSIS let llmPrunedIds: string[] = [] if (strategies.includes('ai-analysis')) { - // Filter out duplicates and protected tools const protectedToolCallIds: string[] = [] const prunableToolCallIds = unprunedToolCallIds.filter(id => { - // Skip already deduplicated if (deduplicatedIds.includes(id)) return false - // Skip protected tools const metadata = toolMetadata.get(id) if (metadata && this.protectedTools.includes(metadata.tool)) { protectedToolCallIds.push(id) @@ -213,9 +171,7 @@ export class Janitor { return true }) - // Run LLM analysis only if there are prunable tools if (prunableToolCallIds.length > 0) { - // Select appropriate model with intelligent fallback const cachedModelInfo = this.modelCache.get(sessionID) const sessionModelInfo = extractModelFromSession(sessionInfo, this.logger) const currentModelInfo = cachedModelInfo || sessionModelInfo @@ -226,94 +182,88 @@ export class Janitor { source: modelSelection.source }) - // Show toast if we had to fallback from a failed model if (modelSelection.failedModel && this.showModelErrorToasts) { + const skipAi = modelSelection.source === 'fallback' && this.strictModelSelection try { await this.client.tui.showToast({ body: { - title: "DCP: Model fallback", - message: `${modelSelection.failedModel.providerID}/${modelSelection.failedModel.modelID} failed\nUsing ${modelSelection.modelInfo.providerID}/${modelSelection.modelInfo.modelID}`, + title: skipAi ? "DCP: AI analysis skipped" : "DCP: Model fallback", + message: skipAi + ? `${modelSelection.failedModel.providerID}/${modelSelection.failedModel.modelID} failed\nAI analysis skipped (strictModelSelection enabled)` + : `${modelSelection.failedModel.providerID}/${modelSelection.failedModel.modelID} failed\nUsing ${modelSelection.modelInfo.providerID}/${modelSelection.modelInfo.modelID}`, variant: "info", duration: 5000 } }) } catch (toastError: any) { - // Don't fail the whole operation if toast fails } } - // Lazy import - only load the 2.8MB ai package when actually needed - const { generateObject } = await import('ai') - - // Replace already-pruned tool outputs to save tokens in janitor context - const allPrunedSoFar = [...alreadyPrunedIds, ...deduplicatedIds] - const sanitizedMessages = this.replacePrunedToolOutputs(messages, allPrunedSoFar) - - // 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( - "janitor-shadow", - [{ role: "user", content: analysisPrompt }], - { - sessionID, - modelProvider: modelSelection.modelInfo.providerID, - modelID: modelSelection.modelInfo.modelID, - candidateToolCount: prunableToolCallIds.length, - alreadyPrunedCount: allPrunedSoFar.length, - protectedToolCount: protectedToolCallIds.length, - trigger: options.trigger, - reason: options.reason + if (modelSelection.source === 'fallback' && this.strictModelSelection) { + this.logger.info("janitor", "Skipping AI analysis (fallback model, strictModelSelection enabled)") + } else { + const { generateObject } = await import('ai') + + const allPrunedSoFar = [...alreadyPrunedIds, ...deduplicatedIds] + const sanitizedMessages = this.replacePrunedToolOutputs(messages, allPrunedSoFar) + + const analysisPrompt = buildAnalysisPrompt( + prunableToolCallIds, + sanitizedMessages, + allPrunedSoFar, + protectedToolCallIds, + options.reason + ) + + await this.logger.saveWrappedContext( + "janitor-shadow", + [{ role: "user", content: analysisPrompt }], + { + sessionID, + modelProvider: modelSelection.modelInfo.providerID, + modelID: modelSelection.modelInfo.modelID, + candidateToolCount: prunableToolCallIds.length, + alreadyPrunedCount: allPrunedSoFar.length, + protectedToolCount: protectedToolCallIds.length, + trigger: options.trigger, + reason: options.reason + } + ) + + const result = await generateObject({ + model: modelSelection.model, + schema: z.object({ + pruned_tool_call_ids: z.array(z.string()), + reasoning: z.string(), + }), + prompt: analysisPrompt + }) + + const rawLlmPrunedIds = result.object.pruned_tool_call_ids + llmPrunedIds = rawLlmPrunedIds.filter(id => + prunableToolCallIds.includes(id.toLowerCase()) + ) + + if (llmPrunedIds.length > 0) { + const reasoning = result.object.reasoning.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim() + this.logger.info("janitor", `LLM reasoning: ${reasoning.substring(0, 200)}${reasoning.length > 200 ? '...' : ''}`) } - ) - - // Analyze which tool calls are obsolete - const result = await generateObject({ - model: modelSelection.model, - schema: z.object({ - pruned_tool_call_ids: z.array(z.string()), - reasoning: z.string(), - }), - prompt: analysisPrompt - }) - - // Filter LLM results to only include IDs that were actually candidates - // (LLM sometimes returns duplicate IDs that were already filtered out) - const rawLlmPrunedIds = result.object.pruned_tool_call_ids - llmPrunedIds = rawLlmPrunedIds.filter(id => - prunableToolCallIds.includes(id.toLowerCase()) - ) - - if (llmPrunedIds.length > 0) { - const reasoning = result.object.reasoning.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim() - this.logger.info("janitor", `LLM reasoning: ${reasoning.substring(0, 200)}${reasoning.length > 200 ? '...' : ''}`) } } } - // ============================================================ // PHASE 3: COMBINE & EXPAND - // ============================================================ const newlyPrunedIds = [...deduplicatedIds, ...llmPrunedIds] if (newlyPrunedIds.length === 0) { return null } - // Helper to expand batch tool IDs to include their children const expandBatchIds = (ids: string[]): string[] => { const expanded = new Set() for (const id of ids) { const normalizedId = id.toLowerCase() expanded.add(normalizedId) - // If this is a batch tool, add all its children const children = batchToolChildren.get(normalizedId) if (children) { children.forEach(childId => expanded.add(childId)) @@ -322,25 +272,14 @@ export class Janitor { return Array.from(expanded) } - // Expand batch tool IDs to include their children const expandedPrunedIds = new Set(expandBatchIds(newlyPrunedIds)) - - // Expand llmPrunedIds for UI display (so batch children show instead of "unknown metadata") const expandedLlmPrunedIds = expandBatchIds(llmPrunedIds) - - // Calculate which IDs are actually NEW (not already pruned) const finalNewlyPrunedIds = Array.from(expandedPrunedIds).filter(id => !alreadyPrunedIds.includes(id)) - - // finalPrunedIds includes everything (new + already pruned) for logging const finalPrunedIds = Array.from(expandedPrunedIds) - // ============================================================ // PHASE 4: CALCULATE STATS & NOTIFICATION - // ============================================================ - // Calculate token savings once (used by both notification and log) const tokensSaved = await this.calculateTokensSaved(finalNewlyPrunedIds, toolOutputs) - // Accumulate session stats (for showing cumulative totals in UI) const currentStats = this.statsState.get(sessionID) ?? { totalToolsPruned: 0, totalTokensSaved: 0 } const sessionStats: SessionStats = { totalToolsPruned: currentStats.totalToolsPruned + finalNewlyPrunedIds.length, @@ -348,7 +287,6 @@ export class Janitor { } this.statsState.set(sessionID, sessionStats) - // Determine notification mode based on which strategies ran const hasLlmAnalysis = strategies.includes('ai-analysis') if (hasLlmAnalysis) { @@ -371,21 +309,15 @@ export class Janitor { ) } - // ============================================================ // PHASE 5: STATE UPDATE - // ============================================================ - // Merge newly pruned IDs with existing ones (using expanded IDs) const allPrunedIds = [...new Set([...alreadyPrunedIds, ...finalPrunedIds])] this.prunedIdsState.set(sessionID, allPrunedIds) - // Log final summary - // Format: "Pruned 5/5 tools (~4.2K tokens), 0 kept" or with breakdown if both duplicate and llm const prunedCount = finalNewlyPrunedIds.length 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 @@ -408,18 +340,11 @@ export class Janitor { 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 } } - /** - * Helper function to shorten paths for display - */ private shortenPath(input: string): string { - // Handle compound strings like: "pattern" in /absolute/path - // Extract and shorten just the path portion const inPathMatch = input.match(/^(.+) in (.+)$/) if (inPathMatch) { const prefix = inPathMatch[1] @@ -431,35 +356,27 @@ export class Janitor { return this.shortenSinglePath(input) } - /** - * Shorten a single path string - */ private shortenSinglePath(path: string): string { const homeDir = require('os').homedir() - // Strip working directory FIRST (before ~ replacement) for cleaner relative paths if (this.workingDirectory) { if (path.startsWith(this.workingDirectory + '/')) { return path.slice(this.workingDirectory.length + 1) } - // Exact match (the directory itself) if (path === this.workingDirectory) { return '.' } } - // Replace home directory with ~ if (path.startsWith(homeDir)) { path = '~' + path.slice(homeDir.length) } - // Shorten node_modules paths: show package + file only const nodeModulesMatch = path.match(/node_modules\/(@[^\/]+\/[^\/]+|[^\/]+)\/(.*)/) if (nodeModulesMatch) { return `${nodeModulesMatch[1]}/${nodeModulesMatch[2]}` } - // Try matching against ~ version of working directory (for paths already with ~) if (this.workingDirectory) { const workingDirWithTilde = this.workingDirectory.startsWith(homeDir) ? '~' + this.workingDirectory.slice(homeDir.length) @@ -476,11 +393,6 @@ export class Janitor { return path } - /** - * Replace pruned tool outputs with placeholder text to save tokens in janitor context - * This applies the same replacement logic as the global fetch wrapper, but for the - * janitor's shadow inference to avoid sending already-pruned content to the LLM - */ private replacePrunedToolOutputs(messages: any[], prunedIds: string[]): any[] { if (prunedIds.length === 0) return messages @@ -496,7 +408,6 @@ export class Janitor { part.callID && prunedIdsSet.has(part.callID.toLowerCase()) && part.state?.output) { - // Replace with the same placeholder used by the global fetch wrapper return { ...part, state: { @@ -511,9 +422,6 @@ export class Janitor { }) } - /** - * Helper function to calculate token savings from tool outputs - */ private async calculateTokensSaved(prunedIds: string[], toolOutputs: Map): Promise { const outputsToTokenize: string[] = [] @@ -525,7 +433,6 @@ export class Janitor { } if (outputsToTokenize.length > 0) { - // Use batch tokenization for efficiency (lazy loads gpt-tokenizer) const tokenCounts = await estimateTokensBatch(outputsToTokenize) return tokenCounts.reduce((sum, count) => sum + count, 0) } @@ -533,42 +440,29 @@ export class Janitor { return 0 } - /** - * Build a summary of tools by grouping them - * Uses shared extractParameterKey logic for consistent parameter extraction - * - * Note: prunedIds may be in original case (from LLM) but toolMetadata uses lowercase keys - */ private buildToolsSummary(prunedIds: string[], toolMetadata: Map): Map { const toolsSummary = new Map() - // Helper function to truncate long strings const truncate = (str: string, maxLen: number = 60): string => { if (str.length <= maxLen) return str return str.slice(0, maxLen - 3) + '...' } for (const prunedId of prunedIds) { - // Normalize ID to lowercase for lookup (toolMetadata uses lowercase keys) const normalizedId = prunedId.toLowerCase() const metadata = toolMetadata.get(normalizedId) if (metadata) { const toolName = metadata.tool - // Skip 'batch' tool in UI summary - it's a wrapper and its children are shown individually if (toolName === 'batch') continue if (!toolsSummary.has(toolName)) { toolsSummary.set(toolName, []) } - // Use shared parameter extraction logic const paramKey = extractParameterKey(metadata) if (paramKey) { - // Apply path shortening and truncation for display const displayKey = truncate(this.shortenPath(paramKey), 80) toolsSummary.get(toolName)!.push(displayKey) } else { - // For tools with no extractable parameter key, add a placeholder - // This ensures the tool still shows up in the summary toolsSummary.get(toolName)!.push('(default)') } } @@ -577,10 +471,6 @@ 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> { @@ -588,7 +478,6 @@ export class Janitor { for (const [_, details] of deduplicationDetails) { const { toolName, parameterKey, duplicateCount } = details - // Skip 'batch' tool in UI summary - it's a wrapper and its children are shown individually if (toolName === 'batch') continue if (!grouped.has(toolName)) { grouped.set(toolName, []) @@ -602,10 +491,6 @@ export class Janitor { return grouped } - /** - * Format grouped deduplication results as lines - * Shared helper for building deduplication summaries - */ private formatDeduplicationLines( grouped: Map>, indent: string = ' ' @@ -622,10 +507,6 @@ export class Janitor { return lines } - /** - * Format tool summary (from buildToolsSummary) as lines - * Shared helper for building LLM-pruned summaries - */ private formatToolSummaryLines( toolsSummary: Map, indent: string = ' ' @@ -646,9 +527,6 @@ export class Janitor { return lines } - /** - * Send minimal summary notification (just tokens saved and count) - */ private async sendMinimalNotification( sessionID: string, totalPruned: number, @@ -662,7 +540,6 @@ export class Janitor { let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${totalPruned} ${toolText} pruned)` - // Add session totals if there's been more than one pruning run if (sessionStats.totalToolsPruned > totalPruned) { message += ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools` } @@ -670,9 +547,6 @@ export class Janitor { await this.sendIgnoredMessage(sessionID, message) } - /** - * Auto mode notification - shows only deduplication results - */ private async sendAutoModeNotification( sessionID: string, deduplicatedIds: string[], @@ -681,32 +555,24 @@ export class Janitor { sessionStats: SessionStats ) { if (deduplicatedIds.length === 0) return - - // Check if notifications are disabled if (this.pruningSummary === 'off') return - // Send minimal notification if configured if (this.pruningSummary === 'minimal') { await this.sendMinimalNotification(sessionID, deduplicatedIds.length, tokensSaved, sessionStats) return } - // Otherwise send detailed notification const tokensFormatted = formatTokenCount(tokensSaved) - const toolText = deduplicatedIds.length === 1 ? 'tool' : 'tools' let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${deduplicatedIds.length} duplicate ${toolText} removed)` - // Add session totals if there's been more than one pruning run if (sessionStats.totalToolsPruned > deduplicatedIds.length) { message += ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools` } message += '\n' - // Group by tool type using shared helper const grouped = this.groupDeduplicationDetails(deduplicationDetails) - // 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` @@ -724,16 +590,11 @@ 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) @@ -741,7 +602,6 @@ export class Janitor { 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) @@ -751,9 +611,6 @@ export class Janitor { return lines.join('\n').trim() } - /** - * Smart mode notification - shows both deduplication and LLM analysis results - */ private async sendSmartModeNotification( sessionID: string, deduplicatedIds: string[], @@ -765,28 +622,22 @@ export class Janitor { ) { const totalPruned = deduplicatedIds.length + llmPrunedIds.length if (totalPruned === 0) return - - // Check if notifications are disabled if (this.pruningSummary === 'off') return - // Send minimal notification if configured if (this.pruningSummary === 'minimal') { await this.sendMinimalNotification(sessionID, totalPruned, tokensSaved, sessionStats) return } - // Otherwise send detailed notification const tokensFormatted = formatTokenCount(tokensSaved) let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${totalPruned} tool${totalPruned > 1 ? 's' : ''} pruned)` - // Add session totals if there's been more than one pruning run if (sessionStats.totalToolsPruned > totalPruned) { message += ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools` } message += '\n' - // Section 1: Deduplicated tools if (deduplicatedIds.length > 0 && deduplicationDetails) { message += `\n📦 Duplicates removed (${deduplicatedIds.length}):\n` const grouped = this.groupDeduplicationDetails(deduplicationDetails) @@ -794,13 +645,12 @@ export class Janitor { for (const [toolName, items] of grouped.entries()) { message += ` ${toolName}:\n` for (const item of items) { - const removedCount = item.count - 1 // Total occurrences minus the one we kept + const removedCount = item.count - 1 message += ` ${item.key} (${removedCount}× duplicate)\n` } } } - // Section 2: LLM-pruned tools if (llmPrunedIds.length > 0) { message += `\n🤖 LLM analysis (${llmPrunedIds.length}):\n` const toolsSummary = this.buildToolsSummary(llmPrunedIds, toolMetadata) @@ -814,7 +664,6 @@ export class Janitor { } } - // Handle any tools that weren't found in metadata (edge case) const foundToolNames = new Set(toolsSummary.keys()) const missingTools = llmPrunedIds.filter(id => { const normalizedId = id.toLowerCase() diff --git a/lib/logger.ts b/lib/logger.ts index f40b8b1..5a97018 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -1,4 +1,3 @@ -// lib/logger.ts import { writeFile, mkdir } from "fs/promises" import { join } from "path" import { existsSync } from "fs" @@ -7,12 +6,10 @@ import { homedir } from "os" export class Logger { private logDir: string public enabled: boolean - private fileCounter: number = 0 // Counter to prevent filename collisions + private fileCounter: number = 0 constructor(enabled: boolean) { this.enabled = enabled - // Always save logs to ~/.config/opencode/logs/dcp/ regardless of installation method - // This ensures users can find logs in a consistent location const opencodeConfigDir = join(homedir(), ".config", "opencode") this.logDir = join(opencodeConfigDir, "logs", "dcp") } @@ -23,30 +20,24 @@ export class Logger { } } - /** - * Formats data object into a compact, readable string - * e.g., {saved: "~4.1K", pruned: 4, duplicates: 0} -> "saved=~4.1K pruned=4 duplicates=0" - */ private formatData(data?: any): string { if (!data) return "" - + const parts: string[] = [] for (const [key, value] of Object.entries(data)) { if (value === undefined || value === null) continue - + // Format arrays compactly if (Array.isArray(value)) { if (value.length === 0) continue parts.push(`${key}=[${value.slice(0, 3).join(",")}${value.length > 3 ? `...+${value.length - 3}` : ""}]`) } - // Format objects inline if small, skip if large else if (typeof value === 'object') { const str = JSON.stringify(value) if (str.length < 50) { parts.push(`${key}=${str}`) } } - // Format primitives directly else { parts.push(`${key}=${value}`) } @@ -62,8 +53,7 @@ export class Logger { const timestamp = new Date().toISOString() const dataStr = this.formatData(data) - - // Simple, readable format: TIMESTAMP LEVEL component: message | key=value key=value + const logLine = `${timestamp} ${level.padEnd(5)} ${component}: ${message}${dataStr ? " | " + dataStr : ""}\n` const dailyLogDir = join(this.logDir, "daily") @@ -74,7 +64,6 @@ export class Logger { const logFile = join(dailyLogDir, `${new Date().toISOString().split('T')[0]}.log`) await writeFile(logFile, logLine, { flag: "a" }) } catch (error) { - // Silently fail - don't break the plugin if logging fails } } @@ -94,14 +83,6 @@ export class Logger { return this.write("ERROR", component, message, data) } - /** - * Parses janitor prompt to extract structured components - * Returns null if parsing fails (not a janitor prompt or malformed) - * - * Note: The session history in the prompt has literal newlines (not \n escapes) - * due to prompt.ts line 93 doing .replace(/\\n/g, '\n') for readability. - * We need to reverse this before parsing. - */ private parseJanitorPrompt(prompt: string): { instructions: string availableToolCallIds: string[] @@ -109,40 +90,29 @@ export class Logger { responseSchema: any } | null { try { - // Extract available tool call IDs const idsMatch = prompt.match(/Available tool call IDs for analysis:\s*([^\n]+)/) - const availableToolCallIds = idsMatch + const availableToolCallIds = idsMatch ? idsMatch[1].split(',').map(id => id.trim()) : [] - // Extract session history (between "Session history" and "\n\nYou MUST respond") - // The captured text has literal newlines, so we need to escape them back to \n for valid JSON const historyMatch = prompt.match(/Session history[^\n]*:\s*\n([\s\S]*?)\n\nYou MUST respond/) let sessionHistory: any[] = [] - + if (historyMatch) { - // Re-escape newlines in string literals for valid JSON parsing - // This reverses the .replace(/\\n/g, '\n') done in prompt.ts const historyText = historyMatch[1] - - // Fix: escape literal newlines within strings to make valid JSON - // We need to be careful to only escape newlines inside string values + const fixedJson = this.escapeNewlinesInJson(historyText) sessionHistory = JSON.parse(fixedJson) } - // Extract instructions (everything before "IMPORTANT: Available tool call IDs") const instructionsMatch = prompt.match(/([\s\S]*?)\n\nIMPORTANT: Available tool call IDs/) - const instructions = instructionsMatch + const instructions = instructionsMatch ? instructionsMatch[1].trim() : '' - // Extract response schema (after "You MUST respond with valid JSON matching this exact schema:") - // Note: The schema contains "..." placeholders which aren't valid JSON, so we save it as a string - // Now matches until end of prompt since we removed the "Return ONLY..." line const schemaMatch = prompt.match(/matching this exact schema:\s*\n(\{[\s\S]*?\})\s*$/) - const responseSchema = schemaMatch - ? schemaMatch[1] // Keep as string since it has "..." placeholders + const responseSchema = schemaMatch + ? schemaMatch[1] : null return { @@ -152,78 +122,58 @@ export class Logger { responseSchema } } catch (error) { - // If parsing fails, return null and fall back to default logging return null } } - /** - * Helper to escape literal newlines within JSON string values - * This makes JSON with literal newlines parseable again - */ private escapeNewlinesInJson(jsonText: string): string { - // Strategy: Replace literal newlines that appear inside strings with \\n - // We detect being "inside a string" by tracking quotes let result = '' let inString = false - + for (let i = 0; i < jsonText.length; i++) { const char = jsonText[i] const prevChar = i > 0 ? jsonText[i - 1] : '' - + if (char === '"' && prevChar !== '\\') { inString = !inString result += char } else if (char === '\n' && inString) { - // Replace literal newline with escaped version result += '\\n' } else { result += char } } - + return result } - /** - * Saves AI context to a dedicated directory for debugging - * Each call creates a new timestamped file in ~/.config/opencode/logs/dcp/ai-context/ - * Only writes if debug is enabled - * - * For janitor-shadow sessions, parses and structures the embedded session history - * for better readability - */ async saveWrappedContext(sessionID: string, messages: any[], metadata: any) { if (!this.enabled) return try { await this.ensureLogDir() - + const aiContextDir = join(this.logDir, "ai-context") if (!existsSync(aiContextDir)) { await mkdir(aiContextDir, { recursive: true }) } const timestamp = new Date().toISOString().replace(/:/g, '-').replace(/\./g, '-') - // Add counter to prevent filename collisions when multiple requests happen in same millisecond const counter = (this.fileCounter++).toString().padStart(3, '0') const filename = `${timestamp}_${counter}_${sessionID.substring(0, 15)}.json` const filepath = join(aiContextDir, filename) - // Check if this is a janitor-shadow session - const isJanitorShadow = sessionID === "janitor-shadow" && - messages.length === 1 && + const isJanitorShadow = sessionID === "janitor-shadow" && + messages.length === 1 && messages[0]?.role === 'user' && typeof messages[0]?.content === 'string' let content: any if (isJanitorShadow) { - // Parse the janitor prompt to extract structured data const parsed = this.parseJanitorPrompt(messages[0].content) - + if (parsed) { - // Create enhanced structured format for readability content = { timestamp: new Date().toISOString(), sessionID, @@ -231,15 +181,13 @@ export class Logger { janitorAnalysis: { instructions: parsed.instructions, availableToolCallIds: parsed.availableToolCallIds, - protectedTools: ["task", "todowrite", "todoread"], // From prompt + protectedTools: ["task", "todowrite", "todoread"], sessionHistory: parsed.sessionHistory, responseSchema: parsed.responseSchema }, - // Keep raw prompt for reference/debugging rawPrompt: messages[0].content } } else { - // Parsing failed, use default format content = { timestamp: new Date().toISOString(), sessionID, @@ -249,7 +197,6 @@ export class Logger { } } } else { - // Standard format for non-janitor sessions content = { timestamp: new Date().toISOString(), sessionID, @@ -258,12 +205,10 @@ export class Logger { } } - // Pretty print with 2-space indentation const jsonString = JSON.stringify(content, null, 2) - + await writeFile(filepath, jsonString) } catch (error) { - // Silently fail - don't break the plugin if logging fails } } } diff --git a/lib/model-selector.ts b/lib/model-selector.ts index af52134..5051420 100644 --- a/lib/model-selector.ts +++ b/lib/model-selector.ts @@ -1,14 +1,3 @@ -/** - * Model Selection and Fallback Logic - * - * This module handles intelligent model selection for the DCP plugin's analysis tasks. - * It attempts to use the same model as the current session, with fallbacks to other - * available models when needed. - * - * NOTE: OpencodeAI is lazily imported to avoid loading the 812KB package during - * plugin initialization. The package is only loaded when model selection is needed. - */ - import type { LanguageModel } from 'ai'; import type { Logger } from './logger'; @@ -17,10 +6,6 @@ export interface ModelInfo { modelID: string; } -/** - * Fallback models to try in priority order - * Earlier entries are tried first - */ export const FALLBACK_MODELS: Record = { openai: 'gpt-5-mini', anthropic: 'claude-haiku-4-5', @@ -43,10 +28,6 @@ const PROVIDER_PRIORITY = [ 'opencode' ]; -/** - * Providers to skip for background analysis - * These providers are either expensive or not suitable for background tasks - */ const SKIP_PROVIDERS = ['github-copilot', 'anthropic']; export interface ModelSelectionResult { @@ -54,80 +35,52 @@ export interface ModelSelectionResult { modelInfo: ModelInfo; source: 'user-model' | 'config' | 'fallback'; reason?: string; - failedModel?: ModelInfo; // The model that failed, if any + failedModel?: ModelInfo; } -/** - * Checks if a provider should be skipped for background analysis - */ function shouldSkipProvider(providerID: string): boolean { const normalized = providerID.toLowerCase().trim(); return SKIP_PROVIDERS.some(skip => normalized.includes(skip.toLowerCase())); } -/** - * Attempts to import OpencodeAI with retry logic to handle plugin initialization timing issues. - * Some providers (like openai via @openhax/codex) may not be fully initialized on first attempt. - */ async function importOpencodeAI(logger?: Logger, maxRetries: number = 3, delayMs: number = 100, workspaceDir?: string): Promise { let lastError: Error | undefined; - + for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const { OpencodeAI } = await import('@tarquinen/opencode-auth-provider'); return new OpencodeAI({ workspaceDir }); } catch (error: any) { lastError = error; - - // Check if this is the specific initialization error we're handling + if (error.message?.includes('before initialization')) { logger?.debug('model-selector', `Import attempt ${attempt}/${maxRetries} failed, will retry`, { error: error.message }); - + if (attempt < maxRetries) { - // Wait before retrying, with exponential backoff await new Promise(resolve => setTimeout(resolve, delayMs * attempt)); continue; } } - - // For other errors, don't retry + throw error; } } - - // All retries exhausted + throw lastError; } -/** - * Main model selection function with intelligent fallback logic - * - * Selection hierarchy: - * 1. Try the config-specified model (if provided in dcp.jsonc) - * 2. Try the user's current model (skip if github-copilot or anthropic) - * 3. Try fallback models from authenticated providers (in priority order) - * - * @param currentModel - The model being used in the current session (optional) - * @param logger - Logger instance for debug output - * @param configModel - Model string in "provider/model" format (e.g., "anthropic/claude-haiku-4-5") - * @returns Selected model with metadata about the selection - */ export async function selectModel( - currentModel?: ModelInfo, + currentModel?: ModelInfo, logger?: Logger, configModel?: string, workspaceDir?: string ): Promise { - // Lazy import with retry logic - handles plugin initialization timing issues - // Some providers (like openai via @openhax/codex) may not be ready on first attempt - // Pass workspaceDir so OpencodeAI can find project-level config and plugins const opencodeAI = await importOpencodeAI(logger, 3, 100, workspaceDir); let failedModelInfo: ModelInfo | undefined; - // Step 1: Try config-specified model first (highest priority) if (configModel) { const parts = configModel.split('/'); if (parts.length !== 2) { @@ -152,10 +105,8 @@ export async function selectModel( } } - // Step 2: Try user's current model (if not skipped provider) if (currentModel) { if (shouldSkipProvider(currentModel.providerID)) { - // Track as failed so we can show toast if (!failedModelInfo) { failedModelInfo = currentModel; } @@ -176,7 +127,6 @@ export async function selectModel( } } - // Step 3: Try fallback models from authenticated providers const providers = await opencodeAI.listProviders(); for (const providerID of PROVIDER_PRIORITY) { @@ -202,12 +152,7 @@ export async function selectModel( throw new Error('No available models for analysis. Please authenticate with at least one provider.'); } -/** - * Helper to extract model info from OpenCode session state - * This can be used by the plugin to get the current session's model - */ export function extractModelFromSession(sessionState: any, logger?: Logger): ModelInfo | undefined { - // Try to get from ACP session state if (sessionState?.model?.providerID && sessionState?.model?.modelID) { return { providerID: sessionState.model.providerID, @@ -215,7 +160,6 @@ export function extractModelFromSession(sessionState: any, logger?: Logger): Mod }; } - // Try to get from last message if (sessionState?.messages && Array.isArray(sessionState.messages)) { const lastMessage = sessionState.messages[sessionState.messages.length - 1]; if (lastMessage?.model?.providerID && lastMessage?.model?.modelID) { diff --git a/lib/prompt.ts b/lib/prompt.ts index 22f094f..7dc9306 100644 --- a/lib/prompt.ts +++ b/lib/prompt.ts @@ -1,9 +1,3 @@ -/** - * Minimize message structure for AI analysis - keep only what's needed - * to determine if tool calls are obsolete - * Also replaces callIDs of already-pruned tools with "" - * and protected tools with "" - */ function minimizeMessages(messages: any[], alreadyPrunedIds?: string[], protectedToolCallIds?: string[]): any[] { const prunedIdsSet = alreadyPrunedIds ? new Set(alreadyPrunedIds.map(id => id.toLowerCase())) : new Set() const protectedIdsSet = protectedToolCallIds ? new Set(protectedToolCallIds.map(id => id.toLowerCase())) : new Set() @@ -13,20 +7,16 @@ function minimizeMessages(messages: any[], alreadyPrunedIds?: string[], protecte role: msg.info?.role } - // Keep essential parts only if (msg.parts) { minimized.parts = msg.parts .filter((part: any) => { - // Completely remove step markers - they add no value for janitor if (part.type === 'step-start' || part.type === 'step-finish') { return false } return true }) .map((part: any) => { - // For text parts, keep the text content (needed for user intent & retention requests) if (part.type === 'text') { - // Filter out ignored messages (e.g., DCP summary UI messages) if (part.ignored) { return null } @@ -36,7 +26,6 @@ function minimizeMessages(messages: any[], alreadyPrunedIds?: string[], protecte } } - // For tool parts, keep what's needed for pruning decisions if (part.type === 'tool') { const callIDLower = part.callID?.toLowerCase() const isAlreadyPruned = prunedIdsSet.has(callIDLower) @@ -55,33 +44,25 @@ function minimizeMessages(messages: any[], alreadyPrunedIds?: string[], protecte tool: part.tool } - // Keep the actual output - janitor needs to see what was returned if (part.state?.output) { toolPart.output = part.state.output } - // Include minimal input for deduplication context - // Only keep resource identifiers, not full nested structures if (part.state?.input) { const input = part.state.input - // For write/edit tools, keep file path AND content (what was changed matters) - // These tools: write, edit, multiedit, patch if (input.filePath && (part.tool === 'write' || part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'patch')) { - toolPart.input = input // Keep full input (content, oldString, newString, etc.) + toolPart.input = input } - // For read-only file operations, just keep the file path else if (input.filePath) { toolPart.input = { filePath: input.filePath } } - // For batch operations, summarize instead of full array else if (input.tool_calls && Array.isArray(input.tool_calls)) { toolPart.input = { batch_summary: `${input.tool_calls.length} tool calls`, tools: input.tool_calls.map((tc: any) => tc.tool) } } - // For other operations, keep minimal input else { toolPart.input = input } @@ -90,15 +71,13 @@ function minimizeMessages(messages: any[], alreadyPrunedIds?: string[], protecte return toolPart } - // Skip all other part types (they're not relevant to pruning) return null }) - .filter(Boolean) // Remove nulls + .filter(Boolean) } return minimized }).filter(msg => { - // Filter out messages that have no parts (e.g., only contained ignored messages) return msg.parts && msg.parts.length > 0 }) } @@ -108,16 +87,12 @@ export function buildAnalysisPrompt( messages: any[], alreadyPrunedIds?: string[], protectedToolCallIds?: string[], - reason?: string // Optional reason from tool call + reason?: string ): string { - // Minimize messages to reduce token usage, passing already-pruned and protected IDs for replacement const minimizedMessages = minimizeMessages(messages, alreadyPrunedIds, protectedToolCallIds) - // Stringify with pretty-printing, then replace escaped newlines with actual newlines - // This makes the logged prompts much more readable const messagesJson = JSON.stringify(minimizedMessages, null, 2).replace(/\\n/g, '\n') - // 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.` : '' diff --git a/lib/tokenizer.ts b/lib/tokenizer.ts index 041401a..4013564 100644 --- a/lib/tokenizer.ts +++ b/lib/tokenizer.ts @@ -1,38 +1,12 @@ -/** - * Token counting utilities using gpt-tokenizer - * - * Uses gpt-tokenizer to provide token counts for text content. - * Works with any LLM provider - provides accurate counts for OpenAI models - * and reasonable approximations for other providers. - * - * NOTE: gpt-tokenizer is lazily imported to avoid loading the 53MB package - * during plugin initialization. The package is only loaded when tokenization - * is actually needed. - */ - -/** - * Batch estimates tokens for multiple text samples - * - * @param texts - Array of text strings to tokenize - * @returns Array of token counts - */ export async function estimateTokensBatch(texts: string[]): Promise { try { - // Lazy import - only load the 53MB gpt-tokenizer package when actually needed const { encode } = await import('gpt-tokenizer') return texts.map(text => encode(text).length) } catch { - // Fallback to character-based estimation if tokenizer fails return texts.map(text => Math.round(text.length / 4)) } } -/** - * Formats token count for display (e.g., 1500 -> "1.5K", 50 -> "50") - * - * @param tokens - Number of tokens - * @returns Formatted string - */ export function formatTokenCount(tokens: number): string { if (tokens >= 1000) { return `${(tokens / 1000).toFixed(1)}K`.replace('.0K', 'K') diff --git a/lib/version-checker.ts b/lib/version-checker.ts index 1132c58..0884688 100644 --- a/lib/version-checker.ts +++ b/lib/version-checker.ts @@ -1,4 +1,3 @@ -// version-checker.ts - Checks for DCP updates on npm and shows toast notification import { readFileSync } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' @@ -6,15 +5,9 @@ import { fileURLToPath } from 'url' export const PACKAGE_NAME = '@tarquinen/opencode-dcp' export const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest` -// ESM-compatible __dirname const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) -/** - * Gets the local package version from package.json - * Note: In compiled output, this file is at dist/lib/version-checker.js - * so we need to go up two levels to reach package.json - */ export function getLocalVersion(): string { try { const pkgPath = join(__dirname, '../../package.json') @@ -25,13 +18,10 @@ export function getLocalVersion(): string { } } -/** - * Fetches the latest version from npm registry - */ export async function getNpmVersion(): Promise { try { const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 5000) // 5s timeout + const timeout = setTimeout(() => controller.abort(), 5000) const res = await fetch(NPM_REGISTRY_URL, { signal: controller.signal, @@ -47,9 +37,6 @@ export async function getNpmVersion(): Promise { } } -/** - * Compares semver versions. Returns true if remote > local - */ export function isOutdated(local: string, remote: string): boolean { const parseVersion = (v: string) => v.split('.').map(n => parseInt(n, 10) || 0) const [localParts, remoteParts] = [parseVersion(local), parseVersion(remote)] @@ -63,10 +50,6 @@ export function isOutdated(local: string, remote: string): boolean { return false } -/** - * Checks for updates and shows a toast if outdated. - * Fire-and-forget: does not throw, logs errors silently. - */ export async function checkForUpdates(client: any, logger?: { info: (component: string, message: string, data?: any) => void }): Promise { try { const local = getLocalVersion() @@ -93,6 +76,5 @@ export async function checkForUpdates(client: any, logger?: { info: (component: } }) } catch { - // Silently fail - version check is non-critical } } diff --git a/package-lock.json b/package-lock.json index 560e4c8..86a6bc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tarquinen/opencode-dcp", - "version": "0.3.16", + "version": "0.3.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "0.3.16", + "version": "0.3.17", "license": "MIT", "dependencies": { "@ai-sdk/openai-compatible": "^1.0.27", diff --git a/package.json b/package.json index 13c16bb..d101121 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.16", + "version": "0.3.17", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js",