Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 13 additions & 95 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -72,95 +67,18 @@ 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:

```json
{
"plugin": [
"@tarquinen/opencode-dcp@0.2.7"
"@tarquinen/opencode-dcp@0.3.10"
]
}
```
Expand Down
Binary file added dcp-demo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 6 additions & 6 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<string, string[]>()
const statsState = new Map<string, SessionStats>()
const toolParametersCache = new Map<string, any>() // callID -> parameters
const modelCache = new Map<string, { providerID: string; modelID: string }>() // 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) {
Expand Down Expand Up @@ -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))
}
}

Expand Down
70 changes: 53 additions & 17 deletions lib/janitor.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>,
private statsState: Map<string, SessionStats>,
private logger: Logger,
private toolParametersCache: Map<string, any>,
private protectedTools: string[],
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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)
}
Expand All @@ -331,21 +370,18 @@ 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

if (workingDirWithTilde && path.startsWith(workingDirWithTilde + '/')) {
return path.slice(workingDirWithTilde.length + 1)
}
if (workingDirWithTilde && path === workingDirWithTilde) {
return '.'
}
}

return path
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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'

Expand Down Expand Up @@ -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'

Expand Down
33 changes: 0 additions & 33 deletions lib/state.ts

This file was deleted.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down