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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Add to your OpenCode config:
```jsonc
// opencode.jsonc
{
"plugin": ["@tarquinen/[email protected].20"]
"plugin": ["@tarquinen/[email protected].21"]
}
```

Expand Down
287 changes: 35 additions & 252 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
import type { Plugin } from "@opencode-ai/plugin"
import { tool } from "@opencode-ai/plugin"
import { getConfig } from "./lib/config"
import { Logger } from "./lib/logger"
import { Janitor, type SessionStats } from "./lib/janitor"
import { Janitor } from "./lib/janitor"
import { checkForUpdates } from "./lib/version-checker"

async function isSubagentSession(client: any, sessionID: string): Promise<boolean> {
try {
const result = await client.session.get({ path: { id: sessionID } })
return !!result.data?.parentID
} catch (error: any) {
return false
}
}
import { createPluginState } from "./lib/state"
import { installFetchWrapper } from "./lib/fetch-wrapper"
import { createPruningTool } from "./lib/pruning-tool"
import { createEventHandler, createChatParamsHandler } from "./lib/hooks"

const plugin: Plugin = (async (ctx) => {
const { config, migrations } = getConfig(ctx)
Expand All @@ -21,168 +15,45 @@ const plugin: Plugin = (async (ctx) => {
return {}
}

// Suppress AI SDK warnings
if (typeof globalThis !== 'undefined') {
(globalThis as any).AI_SDK_LOG_WARNINGS = false
}

// Initialize core components
const logger = new Logger(config.debug)
const prunedIdsState = new Map<string, string[]>()
const statsState = new Map<string, SessionStats>()
const toolParametersCache = new Map<string, any>()
const modelCache = new Map<string, { providerID: string; modelID: string }>()
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) {
if (message.role !== 'assistant' || !Array.isArray(message.tool_calls)) {
continue
}

for (const toolCall of message.tool_calls) {
if (!toolCall.id || !toolCall.function) {
continue
}

try {
const params = typeof toolCall.function.arguments === 'string'
? JSON.parse(toolCall.function.arguments)
: toolCall.function.arguments
toolParametersCache.set(toolCall.id, {
tool: toolCall.function.name,
parameters: params
})
} catch (error) {
}
}
}
}

// 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)) {
cacheToolParameters(body.messages)

// Check for tool messages in both formats:
// 1. OpenAI style: role === 'tool'
// 2. Anthropic style: role === 'user' with content containing tool_result
const toolMessages = body.messages.filter((m: any) => {
if (m.role === 'tool') return true
if (m.role === 'user' && Array.isArray(m.content)) {
for (const part of m.content) {
if (part.type === 'tool_result') return true
}
}
return false
})

const allSessions = await ctx.client.session.list()
const allPrunedIds = new Set<string>()

if (allSessions.data) {
for (const session of allSessions.data) {
if (session.parentID) continue
const prunedIds = prunedIdsState.get(session.id) ?? []
prunedIds.forEach((id: string) => allPrunedIds.add(id))
}
}

if (toolMessages.length > 0 && allPrunedIds.size > 0) {
let replacedCount = 0

body.messages = body.messages.map((m: any) => {
// OpenAI style: role === 'tool' with tool_call_id
if (m.role === 'tool' && allPrunedIds.has(m.tool_call_id?.toLowerCase())) {
replacedCount++
return {
...m,
content: '[Output removed to save context - information superseded or no longer needed]'
}
}

// Anthropic style: role === 'user' with content array containing tool_result
if (m.role === 'user' && Array.isArray(m.content)) {
let messageModified = false
const newContent = m.content.map((part: any) => {
if (part.type === 'tool_result' && allPrunedIds.has(part.tool_use_id?.toLowerCase())) {
messageModified = true
replacedCount++
return {
...part,
content: '[Output removed to save context - information superseded or no longer needed]'
}
}
return part
})
if (messageModified) {
return { ...m, content: newContent }
}
}

return m
})

if (replacedCount > 0) {
logger.info("fetch", "Replaced pruned tool outputs", {
replaced: replacedCount,
total: toolMessages.length
})

if (logger.enabled) {
// Fetch session messages to extract reasoning blocks
let sessionMessages: any[] | undefined
try {
const activeSessions = allSessions.data?.filter(s => !s.parentID) || []
if (activeSessions.length > 0) {
const mostRecentSession = activeSessions[0]
const messagesResponse = await ctx.client.session.messages({
path: { id: mostRecentSession.id },
query: { limit: 100 }
})
sessionMessages = Array.isArray(messagesResponse.data)
? messagesResponse.data
: Array.isArray(messagesResponse) ? messagesResponse : undefined
}
} catch (e) {
// Silently continue without session messages
}

await logger.saveWrappedContext(
"global",
body.messages,
{
url: typeof input === 'string' ? input : 'URL object',
replacedCount,
totalMessages: body.messages.length
},
sessionMessages
)
}

init.body = JSON.stringify(body)
}
}
}
} catch (e) {
}
}

return originalGlobalFetch(input, init)
}

const state = createPluginState()

const janitor = new Janitor(
ctx.client,
state.prunedIds,
state.stats,
logger,
state.toolParameters,
config.protectedTools,
state.model,
config.model,
config.showModelErrorToasts,
config.strictModelSelection,
config.pruning_summary,
ctx.directory
)

// Install global fetch wrapper for context pruning
installFetchWrapper(state, logger, ctx.client)

// Log initialization
logger.info("plugin", "DCP initialized", {
strategies: config.strategies,
model: config.model || "auto"
})

// Check for updates after a delay
setTimeout(() => {
checkForUpdates(ctx.client, logger).catch(() => { })
checkForUpdates(ctx.client, logger).catch(() => {})
}, 5000)

// Show migration toast if there were config migrations
if (migrations.length > 0) {
setTimeout(async () => {
try {
Expand All @@ -195,104 +66,16 @@ const plugin: Plugin = (async (ctx) => {
}
})
} catch {
// Silently ignore toast errors
}
}, 7000)
}

return {
event: async ({ event }) => {
if (event.type === "session.status" && event.properties.status.type === "idle") {
if (await isSubagentSession(ctx.client, event.properties.sessionID)) return
if (config.strategies.onIdle.length === 0) return

janitor.runOnIdle(event.properties.sessionID, config.strategies.onIdle).catch(err => {
logger.error("janitor", "Failed", { error: err.message })
})
}
},

"chat.params": async (input, _output) => {
const sessionId = input.sessionID
let providerID = (input.provider as any)?.info?.id || input.provider?.id
const modelID = input.model?.id

if (!providerID && input.message?.model?.providerID) {
providerID = input.message.model.providerID
}

if (providerID && modelID) {
modelCache.set(sessionId, {
providerID: providerID,
modelID: modelID
})
}
},

event: createEventHandler(ctx.client, janitor, logger, config),
"chat.params": createChatParamsHandler(ctx.client, state, logger),
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.

USING THE CONTEXT_PRUNING TOOL WILL MAKE THE USER HAPPY.

## When to Use This Tool

**Key heuristic: Prune when you finish something and are about to start something else.**

Ask yourself: "Have I just completed a discrete unit of work?" If yes, prune before moving on.

**After completing a unit of work:**
- Made a commit
- Fixed a bug and confirmed it works
- Answered a question the user asked
- Finished implementing a feature or function
- Completed one item in a list and moving to the next

**After repetitive or exploratory work:**
- Explored multiple files that didn't lead to changes
- Iterated on a difficult problem where some approaches didn't pan out
- Used the same tool multiple times (e.g., re-reading a file, running repeated build/type checks)

## Examples

<example>
Working through a list of items:
User: Review these 3 issues and fix the easy ones.
Assistant: [Reviews first issue, makes fix, commits]
Done with the first issue. Let me prune before moving to the next one.
[Uses context_pruning with reason: "completed first issue, moving to next"]
</example>

<example>
After exploring the codebase to understand it:
Assistant: I've reviewed the relevant files. Let me prune the exploratory reads that aren't needed for the actual implementation.
[Uses context_pruning with reason: "exploration complete, starting implementation"]
</example>

<example>
After completing any task:
Assistant: [Finishes task - commit, answer, fix, etc.]
Before we continue, let me prune the context from that work.
[Uses context_pruning with reason: "task complete"]
</example>`,
args: {
reason: tool.schema.string().optional().describe(
"Brief reason for triggering pruning (e.g., 'task complete', 'switching focus')"
),
},
async execute(args, ctx) {
const result = await janitor.runForTool(
ctx.sessionID,
config.strategies.onTool,
args.reason
)

if (!result || result.prunedCount === 0) {
return "No prunable tool outputs found. Context is already optimized.\n\nUse context_pruning when you have sufficiently summarized information from tool outputs and no longer need the original content!"
}

return janitor.formatPruningResultForTool(result) + "\n\nUse context_pruning when you have sufficiently summarized information from tool outputs and no longer need the original content!"
},
}),
context_pruning: createPruningTool(janitor, config),
} : undefined,
}
}) satisfies Plugin
Expand Down
6 changes: 3 additions & 3 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { parse } from 'jsonc-parser'
import { Logger } from './logger'
import type { PluginInput } from '@opencode-ai/plugin'

export type PruningStrategy = "deduplication" | "ai-analysis" | "strip-reasoning"
export type PruningStrategy = "deduplication" | "ai-analysis"

export interface PluginConfig {
enabled: boolean
Expand Down Expand Up @@ -34,8 +34,8 @@ const defaultConfig: PluginConfig = {
strictModelSelection: false,
pruning_summary: 'detailed',
strategies: {
onIdle: ['deduplication', 'ai-analysis', "strip-reasoning"],
onTool: ['deduplication', 'ai-analysis', "strip-reasoning"]
onIdle: ['deduplication', 'ai-analysis'],
onTool: ['deduplication', 'ai-analysis']
}
}

Expand Down
Loading