diff --git a/README.md b/README.md index 6ea7077..323bf91 100644 --- a/README.md +++ b/README.md @@ -6,23 +6,9 @@ Automatically reduces token usage in OpenCode by removing obsolete tool outputs ## What It Does -This plugin automatically optimizes token usage by identifying and removing redundant or obsolete tool outputs from your conversation history. It operates in two modes: +This plugin automatically optimizes token usage by identifying and removing redundant or obsolete tool outputs from your conversation history. -### Pruning Modes - -**Auto Mode** (`"auto"`): Fast, deterministic duplicate removal -- Removes duplicate tool calls (same tool + identical parameters) -- Keeps only the most recent occurrence of each duplicate -- Zero LLM inference costs -- Instant, predictable results - -**Smart Mode** (`"smart"`): Comprehensive intelligent pruning (recommended) -- Phase 1: Automatic duplicate removal (same as auto mode) -- Phase 2: AI analysis to identify obsolete outputs (superseded information, dead-end exploration, etc.) -- Maximum token savings -- Small LLM cost for analysis (reduced by deduplication first) - -When your session becomes idle, the plugin analyzes your conversation and prunes tool outputs that are no longer relevant, saving tokens and reducing costs. +![DCP in action](dcp-demo.png) ## Installation @@ -39,10 +25,19 @@ Add to your OpenCode configuration: } ``` +> **Note:** OpenCode's `plugin` arrays are not merged between global and project configs—project config completely overrides global. If you have plugins in your global config and add a project config, include all desired plugins in the project config. + Restart OpenCode. The plugin will automatically start optimizing your sessions. ## Configuration +DCP uses its own configuration file, separate from OpenCode's `opencode.json`: + +- **Global:** `~/.config/opencode/dcp.jsonc` +- **Project:** `.opencode/dcp.jsonc` + +The global config is automatically created on first run. Create a project config to override settings per-project. + ### Available Options - **`enabled`** (boolean, default: `true`) - Enable/disable the plugin @@ -72,87 +67,10 @@ Example configuration: ### Configuration Hierarchy -1. **Built-in defaults** → 2. **Global config** (`~/.config/opencode/dcp.jsonc`) → 3. **Project config** (`.opencode/dcp.jsonc`) - -The global config is automatically created on first run. Create project configs manually to override settings per-project: - -```bash -mkdir -p .opencode -cat > .opencode/dcp.jsonc << 'EOF' -{ - "debug": true, - "pruningMode": "auto" -} -EOF -``` +Settings are merged in order: **Built-in defaults** → **Global config** → **Project config** After modifying configuration, restart OpenCode for changes to take effect. -### Choosing a Pruning Mode - -**Use Auto Mode (`"auto"`) when:** -- Minimizing costs is critical (zero LLM inference for pruning) -- You have many repetitive tool calls (file re-reads, repeated commands) -- You want predictable, deterministic behavior -- You're debugging or testing and need consistent results - -**Use Smart Mode (`"smart"`) when:** -- You want maximum token savings (recommended for most users) -- Your workflow has both duplicates and obsolete exploration -- You're willing to incur small LLM costs for comprehensive pruning -- You want the plugin to intelligently identify superseded information - -**Example notification formats:** - -**Detailed mode** (default): - -Auto mode: -``` -🧹 DCP: Saved ~1.2K tokens (5 duplicate tools removed) - -read (3 duplicates): - ~/project/src/index.ts (2× duplicate) - ~/project/lib/utils.ts (1× duplicate) - -bash (2 duplicates): - Run tests (2× duplicate) -``` - -Smart mode: -``` -🧹 DCP: Saved ~3.4K tokens (8 tools pruned) - -📦 Duplicates removed (5): - read: - ~/project/src/index.ts (3×) - ~/project/lib/utils.ts (2×) - bash: - Run tests (2×) - -🤖 LLM analysis (3): - grep (2): - pattern: "old.*function" - pattern: "deprecated" - list (1): - ~/project/temp -``` - -**Minimal mode** (`"pruning_summary": "minimal"`): -``` -🧹 DCP: Saved ~3.4K tokens (8 tools pruned) -``` - -**Off mode** (`"pruning_summary": "off"`): -``` -(No notification displayed - silent pruning) -``` - -To check the latest available version: - -```bash -npm view @tarquinen/opencode-dcp version -``` - ### Version Pinning If you want to ensure a specific version is always used or update your version, you can pin it in your config: @@ -160,7 +78,7 @@ If you want to ensure a specific version is always used or update your version, ```json { "plugin": [ - "@tarquinen/opencode-dcp@0.2.7" + "@tarquinen/opencode-dcp@0.3.10" ] } ``` diff --git a/dcp-demo.png b/dcp-demo.png new file mode 100644 index 0000000..b0bf776 Binary files /dev/null and b/dcp-demo.png differ diff --git a/index.ts b/index.ts index 77c73fa..2e33359 100644 --- a/index.ts +++ b/index.ts @@ -2,8 +2,7 @@ import type { Plugin } from "@opencode-ai/plugin" import { getConfig } from "./lib/config" import { Logger } from "./lib/logger" -import { StateManager } from "./lib/state" -import { Janitor } from "./lib/janitor" +import { Janitor, type SessionStats } from "./lib/janitor" /** * Checks if a session is a subagent (child session) @@ -34,10 +33,11 @@ const plugin: Plugin = (async (ctx) => { // Logger uses ~/.config/opencode/logs/dcp/ for consistent log location const logger = new Logger(config.debug) - const stateManager = new StateManager() + 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, stateManager, 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.pruningMode, config.pruning_summary, ctx.directory) const cacheToolParameters = (messages: any[]) => { for (const message of messages) { @@ -87,8 +87,8 @@ const plugin: Plugin = (async (ctx) => { if (allSessions.data) { for (const session of allSessions.data) { if (session.parentID) continue // Skip subagent sessions - const prunedIds = await stateManager.get(session.id) - prunedIds.forEach(id => allPrunedIds.add(id)) + const prunedIds = prunedIdsState.get(session.id) ?? [] + prunedIds.forEach((id: string) => allPrunedIds.add(id)) } } diff --git a/lib/janitor.ts b/lib/janitor.ts index 5015ede..5895adf 100644 --- a/lib/janitor.ts +++ b/lib/janitor.ts @@ -1,15 +1,20 @@ import { z } from "zod" import type { Logger } from "./logger" -import type { StateManager, SessionStats } from "./state" import { buildAnalysisPrompt } from "./prompt" import { selectModel, extractModelFromSession } from "./model-selector" import { estimateTokensBatch, formatTokenCount } from "./tokenizer" import { detectDuplicates, extractParameterKey } from "./deduplicator" +export interface SessionStats { + totalToolsPruned: number + totalTokensSaved: number +} + export class Janitor { constructor( private client: any, - private stateManager: StateManager, + private prunedIdsState: Map, + private statsState: Map, private logger: Logger, private toolParametersCache: Map, private protectedTools: string[], @@ -114,7 +119,7 @@ export class Janitor { } // Get already pruned IDs to filter them out - const alreadyPrunedIds = await this.stateManager.get(sessionID) + const alreadyPrunedIds = this.prunedIdsState.get(sessionID) ?? [] const unprunedToolCallIds = toolCallIds.filter(id => !alreadyPrunedIds.includes(id)) // If there are no unpruned tool calls, skip analysis @@ -269,7 +274,12 @@ export class Janitor { const tokensSaved = await this.calculateTokensSaved(finalNewlyPrunedIds, toolOutputs) // Accumulate session stats (for showing cumulative totals in UI) - const sessionStats = await this.stateManager.addStats(sessionID, finalNewlyPrunedIds.length, tokensSaved) + const currentStats = this.statsState.get(sessionID) ?? { totalToolsPruned: 0, totalTokensSaved: 0 } + const sessionStats: SessionStats = { + totalToolsPruned: currentStats.totalToolsPruned + finalNewlyPrunedIds.length, + totalTokensSaved: currentStats.totalTokensSaved + tokensSaved + } + this.statsState.set(sessionID, sessionStats) if (this.pruningMode === "auto") { await this.sendAutoModeNotification( @@ -296,7 +306,7 @@ export class Janitor { // ============================================================ // Merge newly pruned IDs with existing ones (using expanded IDs) const allPrunedIds = [...new Set([...alreadyPrunedIds, ...finalPrunedIds])] - await this.stateManager.set(sessionID, allPrunedIds) + 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 @@ -318,9 +328,38 @@ export class Janitor { /** * Helper function to shorten paths for display */ - private shortenPath(path: string): string { - // Replace home directory with ~ + 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] + const pathPart = inPathMatch[2] + const shortenedPath = this.shortenSinglePath(pathPart) + return `${prefix} in ${shortenedPath}` + } + + 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) } @@ -331,14 +370,8 @@ export class Janitor { return `${nodeModulesMatch[1]}/${nodeModulesMatch[2]}` } - // Strip working directory to show relative paths + // Try matching against ~ version of working directory (for paths already with ~) if (this.workingDirectory) { - // Try to match against the absolute working directory first - if (path.startsWith(this.workingDirectory + '/')) { - return path.slice(this.workingDirectory.length + 1) - } - - // Also try matching against ~ version of working directory const workingDirWithTilde = this.workingDirectory.startsWith(homeDir) ? '~' + this.workingDirectory.slice(homeDir.length) : null @@ -346,6 +379,9 @@ export class Janitor { if (workingDirWithTilde && path.startsWith(workingDirWithTilde + '/')) { return path.slice(workingDirWithTilde.length + 1) } + if (workingDirWithTilde && path === workingDirWithTilde) { + return '.' + } } return path @@ -468,7 +504,7 @@ export class Janitor { // Add session totals if there's been more than one pruning run if (sessionStats.totalToolsPruned > totalPruned) { - message += ` │ Session: ${sessionStats.totalToolsPruned} tools, ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens` + message += ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools` } await this.sendIgnoredMessage(sessionID, message) @@ -503,7 +539,7 @@ export class Janitor { // Add session totals if there's been more than one pruning run if (sessionStats.totalToolsPruned > deduplicatedIds.length) { - message += ` │ Session: ${sessionStats.totalToolsPruned} tools, ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens` + message += ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools` } message += '\n' @@ -570,7 +606,7 @@ export class Janitor { // Add session totals if there's been more than one pruning run if (sessionStats.totalToolsPruned > totalPruned) { - message += ` │ Session: ${sessionStats.totalToolsPruned} tools, ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens` + message += ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools` } message += '\n' diff --git a/lib/state.ts b/lib/state.ts deleted file mode 100644 index 8ffa89c..0000000 --- a/lib/state.ts +++ /dev/null @@ -1,33 +0,0 @@ -// lib/state.ts - -export interface SessionStats { - totalToolsPruned: number - totalTokensSaved: number -} - -export class StateManager { - private state: Map = new Map() - private stats: Map = new Map() - - async get(sessionID: string): Promise { - return this.state.get(sessionID) ?? [] - } - - async set(sessionID: string, prunedIds: string[]): Promise { - this.state.set(sessionID, prunedIds) - } - - async getStats(sessionID: string): Promise { - return this.stats.get(sessionID) ?? { totalToolsPruned: 0, totalTokensSaved: 0 } - } - - async addStats(sessionID: string, toolsPruned: number, tokensSaved: number): Promise { - const current = await this.getStats(sessionID) - const updated: SessionStats = { - totalToolsPruned: current.totalToolsPruned + toolsPruned, - totalTokensSaved: current.totalTokensSaved + tokensSaved - } - this.stats.set(sessionID, updated) - return updated - } -} diff --git a/package-lock.json b/package-lock.json index eaed22d..c3032a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tarquinen/opencode-dcp", - "version": "0.3.10", + "version": "0.3.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "0.3.10", + "version": "0.3.11", "license": "MIT", "dependencies": { "@ai-sdk/openai-compatible": "^1.0.27", diff --git a/package.json b/package.json index fe8fb45..9f2c382 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.10", + "version": "0.3.11", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js",