From 56c5ff3f5b6c9fc1a9c789f69100f7ddf2c684b2 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Fri, 28 Nov 2025 20:53:51 -0500 Subject: [PATCH 1/4] feat: add Google/Gemini and OpenAI Responses API format support for context pruning - Add position-based tool call correlation for Google/Gemini format (body.contents with functionResponse) - Add OpenAI Responses API support (body.input with function_call_output and call_id) - Extract getAllPrunedIds helper for reuse across all format handlers - Build position mapping in chat.params hook for google/google-vertex providers - Remove incomplete strip-reasoning strategy from default config --- index.ts | 289 +++++++++++++++++++++++++++++++++++++++++++++++-- lib/config.ts | 6 +- lib/janitor.ts | 6 - 3 files changed, 282 insertions(+), 19 deletions(-) diff --git a/index.ts b/index.ts index 254e27f..1656943 100644 --- a/index.ts +++ b/index.ts @@ -30,6 +30,9 @@ const plugin: Plugin = (async (ctx) => { const statsState = new Map() const toolParametersCache = new Map() const modelCache = new Map() + // Maps Google/Gemini tool positions to OpenCode tool call IDs for correlation + // Key: sessionID, Value: Map where positionKey is "toolName:index" + const googleToolCallMapping = 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[]) => { @@ -57,6 +60,26 @@ const plugin: Plugin = (async (ctx) => { } } + // Cache tool parameters from OpenAI Responses API format (input array with function_call items) + const cacheToolParametersFromInput = (input: any[]) => { + for (const item of input) { + if (item.type !== 'function_call' || !item.call_id || !item.name) { + continue + } + + try { + const params = typeof item.arguments === 'string' + ? JSON.parse(item.arguments) + : item.arguments + toolParametersCache.set(item.call_id, { + tool: item.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) => { @@ -64,6 +87,23 @@ const plugin: Plugin = (async (ctx) => { try { const body = JSON.parse(init.body) + // Helper to get all pruned IDs across sessions + const getAllPrunedIds = async () => { + const allSessions = await ctx.client.session.list() + const allPrunedIds = new Set() + + 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)) + } + } + + return { allSessions, allPrunedIds } + } + + // OpenAI Chat Completions & Anthropic style (body.messages) if (body.messages && Array.isArray(body.messages)) { cacheToolParameters(body.messages) @@ -80,16 +120,7 @@ const plugin: Plugin = (async (ctx) => { return false }) - const allSessions = await ctx.client.session.list() - const allPrunedIds = new Set() - - 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)) - } - } + const { allSessions, allPrunedIds } = await getAllPrunedIds() if (toolMessages.length > 0 && allPrunedIds.size > 0) { let replacedCount = 0 @@ -167,6 +198,195 @@ const plugin: Plugin = (async (ctx) => { } } } + + // Google/Gemini style (body.contents array with parts containing functionResponse) + // Used by Gemini models including thinking models + // Note: Google's native format doesn't include tool call IDs, so we use position-based correlation + if (body.contents && Array.isArray(body.contents)) { + // Check for functionResponse parts in any content item + const hasFunctionResponses = body.contents.some((content: any) => + Array.isArray(content.parts) && + content.parts.some((part: any) => part.functionResponse) + ) + + if (hasFunctionResponses) { + const { allSessions, allPrunedIds } = await getAllPrunedIds() + + if (allPrunedIds.size > 0) { + // Find the active session to get the position mapping + const activeSessions = allSessions.data?.filter((s: any) => !s.parentID) || [] + let positionMapping: Map | undefined + + for (const session of activeSessions) { + const mapping = googleToolCallMapping.get(session.id) + if (mapping && mapping.size > 0) { + positionMapping = mapping + break + } + } + + if (!positionMapping) { + logger.info("fetch", "No Google tool call mapping found, skipping pruning for Gemini format") + } else { + // Build position counters to track occurrence of each tool name + const toolPositionCounters = new Map() + let replacedCount = 0 + let totalFunctionResponses = 0 + + body.contents = body.contents.map((content: any) => { + if (!Array.isArray(content.parts)) return content + + let contentModified = false + const newParts = content.parts.map((part: any) => { + if (part.functionResponse) { + totalFunctionResponses++ + const funcName = part.functionResponse.name?.toLowerCase() + + if (funcName) { + // Get current position for this tool name and increment counter + const currentIndex = toolPositionCounters.get(funcName) || 0 + toolPositionCounters.set(funcName, currentIndex + 1) + + // Look up the tool call ID using position + const positionKey = `${funcName}:${currentIndex}` + const toolCallId = positionMapping!.get(positionKey) + + if (toolCallId && allPrunedIds.has(toolCallId)) { + contentModified = true + replacedCount++ + return { + ...part, + functionResponse: { + ...part.functionResponse, + response: { + name: part.functionResponse.name, + content: '[Output removed to save context - information superseded or no longer needed]' + } + } + } + } + } + } + return part + }) + + if (contentModified) { + return { ...content, parts: newParts } + } + return content + }) + + if (replacedCount > 0) { + logger.info("fetch", "Replaced pruned tool outputs (Google/Gemini)", { + replaced: replacedCount, + total: totalFunctionResponses + }) + + if (logger.enabled) { + let sessionMessages: any[] | undefined + try { + 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.contents, + { + url: typeof input === 'string' ? input : 'URL object', + replacedCount, + totalContents: body.contents.length, + format: 'google-gemini' + }, + sessionMessages + ) + } + + init.body = JSON.stringify(body) + } + } + } + } + } + + // OpenAI Responses API style (body.input array with function_call and function_call_output) + // Used by GPT-5 models via sdk.responses() + if (body.input && Array.isArray(body.input)) { + cacheToolParametersFromInput(body.input) + + // Check for function_call_output items + const functionOutputs = body.input.filter((item: any) => item.type === 'function_call_output') + + if (functionOutputs.length > 0) { + const { allSessions, allPrunedIds } = await getAllPrunedIds() + + if (allPrunedIds.size > 0) { + let replacedCount = 0 + + body.input = body.input.map((item: any) => { + if (item.type === 'function_call_output' && allPrunedIds.has(item.call_id?.toLowerCase())) { + replacedCount++ + return { + ...item, + output: '[Output removed to save context - information superseded or no longer needed]' + } + } + return item + }) + + if (replacedCount > 0) { + logger.info("fetch", "Replaced pruned tool outputs (Responses API)", { + replaced: replacedCount, + total: functionOutputs.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.input, + { + url: typeof input === 'string' ? input : 'URL object', + replacedCount, + totalItems: body.input.length, + format: 'openai-responses-api' + }, + sessionMessages + ) + } + + init.body = JSON.stringify(body) + } + } + } + } } catch (e) { } } @@ -226,6 +446,55 @@ const plugin: Plugin = (async (ctx) => { modelID: modelID }) } + + // Build Google/Gemini tool call mapping for position-based correlation + // This is needed because Google's native format loses tool call IDs + if (providerID === 'google' || providerID === 'google-vertex') { + try { + const messagesResponse = await ctx.client.session.messages({ + path: { id: sessionId }, + query: { limit: 100 } + }) + const messages = messagesResponse.data || messagesResponse + + if (Array.isArray(messages)) { + // Build position mapping: track tool calls by name and occurrence index + const toolCallsByName = new Map() + + for (const msg of messages) { + if (msg.parts) { + for (const part of msg.parts) { + if (part.type === 'tool' && part.callID && part.tool) { + const toolName = part.tool.toLowerCase() + if (!toolCallsByName.has(toolName)) { + toolCallsByName.set(toolName, []) + } + toolCallsByName.get(toolName)!.push(part.callID.toLowerCase()) + } + } + } + } + + // Create position mapping: "toolName:index" -> toolCallId + const positionMapping = new Map() + for (const [toolName, callIds] of toolCallsByName) { + callIds.forEach((callId, index) => { + positionMapping.set(`${toolName}:${index}`, callId) + }) + } + + googleToolCallMapping.set(sessionId, positionMapping) + logger.info("chat.params", "Built Google tool call mapping", { + sessionId: sessionId.substring(0, 8), + toolCount: positionMapping.size + }) + } + } catch (error: any) { + logger.error("chat.params", "Failed to build Google tool call mapping", { + error: error.message + }) + } + } }, tool: config.strategies.onTool.length > 0 ? { diff --git a/lib/config.ts b/lib/config.ts index 5c7e501..b3e6e79 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -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 @@ -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'] } } diff --git a/lib/janitor.ts b/lib/janitor.ts index b082b99..94208a1 100644 --- a/lib/janitor.ts +++ b/lib/janitor.ts @@ -156,12 +156,6 @@ export class Janitor { return !metadata || !this.protectedTools.includes(metadata.tool) }).length - // PHASE 1.5: STRIP-REASONING - let reasoningPrunedIds: string[] = [] - - if (strategies.includes('strip-reasoning')) { - } - // PHASE 2: LLM ANALYSIS let llmPrunedIds: string[] = [] From 8083b330cdd2f57ea27a10773021e6adc15b32b8 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Fri, 28 Nov 2025 20:55:25 -0500 Subject: [PATCH 2/4] fix: simplify Gemini response structure for API compatibility - Use string instead of nested object for functionResponse.response - Preserves thoughtSignature via spread operator (required for Gemini 3 Pro) - Aligns with Google's flexible response format --- index.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/index.ts b/index.ts index 1656943..03b3851 100644 --- a/index.ts +++ b/index.ts @@ -254,14 +254,13 @@ const plugin: Plugin = (async (ctx) => { if (toolCallId && allPrunedIds.has(toolCallId)) { contentModified = true replacedCount++ + // Preserve thoughtSignature if present (required for Gemini 3 Pro) + // Only replace the response content, not the structure return { ...part, functionResponse: { ...part.functionResponse, - response: { - name: part.functionResponse.name, - content: '[Output removed to save context - information superseded or no longer needed]' - } + response: '[Output removed to save context - information superseded or no longer needed]' } } } From 623696a8f96cb08c0566d843410e0f1ebafe85b4 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Fri, 28 Nov 2025 21:36:41 -0500 Subject: [PATCH 3/4] refactor: modularize fetch wrapper and extract shared state into separate modules - Split monolithic index.ts into focused modules for better maintainability - Extract fetch wrapper into lib/fetch-wrapper/ with format-specific handlers: - openai-chat.ts: OpenAI Chat Completions & Anthropic format - openai-responses.ts: OpenAI Responses API format (GPT-5) - gemini.ts: Google/Gemini format with position-based correlation - types.ts: Shared types and utility functions - Create lib/state.ts for centralized plugin state management - Create lib/hooks.ts for event and chat.params handlers - Create lib/pruning-tool.ts for context_pruning tool definition - Create lib/tool-cache.ts for tool parameter caching utilities - Reduce index.ts from 500+ lines to ~80 lines of initialization code --- index.ts | 555 ++------------------------ lib/fetch-wrapper/gemini.ts | 131 ++++++ lib/fetch-wrapper/index.ts | 80 ++++ lib/fetch-wrapper/openai-chat.ts | 107 +++++ lib/fetch-wrapper/openai-responses.ts | 81 ++++ lib/fetch-wrapper/types.ts | 75 ++++ lib/hooks.ts | 113 ++++++ lib/pruning-tool.ts | 77 ++++ lib/state.ts | 44 ++ lib/tool-cache.ts | 61 +++ 10 files changed, 804 insertions(+), 520 deletions(-) create mode 100644 lib/fetch-wrapper/gemini.ts create mode 100644 lib/fetch-wrapper/index.ts create mode 100644 lib/fetch-wrapper/openai-chat.ts create mode 100644 lib/fetch-wrapper/openai-responses.ts create mode 100644 lib/fetch-wrapper/types.ts create mode 100644 lib/hooks.ts create mode 100644 lib/pruning-tool.ts create mode 100644 lib/state.ts create mode 100644 lib/tool-cache.ts diff --git a/index.ts b/index.ts index 03b3851..e8ae42c 100644 --- a/index.ts +++ b/index.ts @@ -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 { - 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) @@ -21,387 +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() - const statsState = new Map() - const toolParametersCache = new Map() - const modelCache = new Map() - // Maps Google/Gemini tool positions to OpenCode tool call IDs for correlation - // Key: sessionID, Value: Map where positionKey is "toolName:index" - const googleToolCallMapping = 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) { - 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) { - } - } - } - } - - // Cache tool parameters from OpenAI Responses API format (input array with function_call items) - const cacheToolParametersFromInput = (input: any[]) => { - for (const item of input) { - if (item.type !== 'function_call' || !item.call_id || !item.name) { - continue - } - - try { - const params = typeof item.arguments === 'string' - ? JSON.parse(item.arguments) - : item.arguments - toolParametersCache.set(item.call_id, { - tool: item.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) - - // Helper to get all pruned IDs across sessions - const getAllPrunedIds = async () => { - const allSessions = await ctx.client.session.list() - const allPrunedIds = new Set() - - 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)) - } - } - - return { allSessions, allPrunedIds } - } - - // OpenAI Chat Completions & Anthropic style (body.messages) - 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, allPrunedIds } = await getAllPrunedIds() - - 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) - } - } - } - - // Google/Gemini style (body.contents array with parts containing functionResponse) - // Used by Gemini models including thinking models - // Note: Google's native format doesn't include tool call IDs, so we use position-based correlation - if (body.contents && Array.isArray(body.contents)) { - // Check for functionResponse parts in any content item - const hasFunctionResponses = body.contents.some((content: any) => - Array.isArray(content.parts) && - content.parts.some((part: any) => part.functionResponse) - ) - - if (hasFunctionResponses) { - const { allSessions, allPrunedIds } = await getAllPrunedIds() - - if (allPrunedIds.size > 0) { - // Find the active session to get the position mapping - const activeSessions = allSessions.data?.filter((s: any) => !s.parentID) || [] - let positionMapping: Map | undefined - - for (const session of activeSessions) { - const mapping = googleToolCallMapping.get(session.id) - if (mapping && mapping.size > 0) { - positionMapping = mapping - break - } - } - - if (!positionMapping) { - logger.info("fetch", "No Google tool call mapping found, skipping pruning for Gemini format") - } else { - // Build position counters to track occurrence of each tool name - const toolPositionCounters = new Map() - let replacedCount = 0 - let totalFunctionResponses = 0 - - body.contents = body.contents.map((content: any) => { - if (!Array.isArray(content.parts)) return content - - let contentModified = false - const newParts = content.parts.map((part: any) => { - if (part.functionResponse) { - totalFunctionResponses++ - const funcName = part.functionResponse.name?.toLowerCase() - - if (funcName) { - // Get current position for this tool name and increment counter - const currentIndex = toolPositionCounters.get(funcName) || 0 - toolPositionCounters.set(funcName, currentIndex + 1) - - // Look up the tool call ID using position - const positionKey = `${funcName}:${currentIndex}` - const toolCallId = positionMapping!.get(positionKey) - - if (toolCallId && allPrunedIds.has(toolCallId)) { - contentModified = true - replacedCount++ - // Preserve thoughtSignature if present (required for Gemini 3 Pro) - // Only replace the response content, not the structure - return { - ...part, - functionResponse: { - ...part.functionResponse, - response: '[Output removed to save context - information superseded or no longer needed]' - } - } - } - } - } - return part - }) - - if (contentModified) { - return { ...content, parts: newParts } - } - return content - }) - - if (replacedCount > 0) { - logger.info("fetch", "Replaced pruned tool outputs (Google/Gemini)", { - replaced: replacedCount, - total: totalFunctionResponses - }) - - if (logger.enabled) { - let sessionMessages: any[] | undefined - try { - 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.contents, - { - url: typeof input === 'string' ? input : 'URL object', - replacedCount, - totalContents: body.contents.length, - format: 'google-gemini' - }, - sessionMessages - ) - } - - init.body = JSON.stringify(body) - } - } - } - } - } - - // OpenAI Responses API style (body.input array with function_call and function_call_output) - // Used by GPT-5 models via sdk.responses() - if (body.input && Array.isArray(body.input)) { - cacheToolParametersFromInput(body.input) - - // Check for function_call_output items - const functionOutputs = body.input.filter((item: any) => item.type === 'function_call_output') - - if (functionOutputs.length > 0) { - const { allSessions, allPrunedIds } = await getAllPrunedIds() - - if (allPrunedIds.size > 0) { - let replacedCount = 0 - - body.input = body.input.map((item: any) => { - if (item.type === 'function_call_output' && allPrunedIds.has(item.call_id?.toLowerCase())) { - replacedCount++ - return { - ...item, - output: '[Output removed to save context - information superseded or no longer needed]' - } - } - return item - }) - - if (replacedCount > 0) { - logger.info("fetch", "Replaced pruned tool outputs (Responses API)", { - replaced: replacedCount, - total: functionOutputs.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.input, - { - url: typeof input === 'string' ? input : 'URL object', - replacedCount, - totalItems: body.input.length, - format: 'openai-responses-api' - }, - 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 { @@ -414,153 +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 - }) - } - - // Build Google/Gemini tool call mapping for position-based correlation - // This is needed because Google's native format loses tool call IDs - if (providerID === 'google' || providerID === 'google-vertex') { - try { - const messagesResponse = await ctx.client.session.messages({ - path: { id: sessionId }, - query: { limit: 100 } - }) - const messages = messagesResponse.data || messagesResponse - - if (Array.isArray(messages)) { - // Build position mapping: track tool calls by name and occurrence index - const toolCallsByName = new Map() - - for (const msg of messages) { - if (msg.parts) { - for (const part of msg.parts) { - if (part.type === 'tool' && part.callID && part.tool) { - const toolName = part.tool.toLowerCase() - if (!toolCallsByName.has(toolName)) { - toolCallsByName.set(toolName, []) - } - toolCallsByName.get(toolName)!.push(part.callID.toLowerCase()) - } - } - } - } - - // Create position mapping: "toolName:index" -> toolCallId - const positionMapping = new Map() - for (const [toolName, callIds] of toolCallsByName) { - callIds.forEach((callId, index) => { - positionMapping.set(`${toolName}:${index}`, callId) - }) - } - - googleToolCallMapping.set(sessionId, positionMapping) - logger.info("chat.params", "Built Google tool call mapping", { - sessionId: sessionId.substring(0, 8), - toolCount: positionMapping.size - }) - } - } catch (error: any) { - logger.error("chat.params", "Failed to build Google tool call mapping", { - error: error.message - }) - } - } - }, - + 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 - - -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"] - - - -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"] - - - -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"] -`, - 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 diff --git a/lib/fetch-wrapper/gemini.ts b/lib/fetch-wrapper/gemini.ts new file mode 100644 index 0000000..527c00a --- /dev/null +++ b/lib/fetch-wrapper/gemini.ts @@ -0,0 +1,131 @@ +import type { FetchHandlerContext, FetchHandlerResult } from "./types" +import { + PRUNED_CONTENT_MESSAGE, + getAllPrunedIds, + fetchSessionMessages +} from "./types" + +/** + * Handles Google/Gemini format (body.contents array with functionResponse parts). + * Uses position-based correlation since Google's native format doesn't include tool call IDs. + */ +export async function handleGemini( + body: any, + ctx: FetchHandlerContext, + inputUrl: string +): Promise { + if (!body.contents || !Array.isArray(body.contents)) { + return { modified: false, body } + } + + // Check for functionResponse parts in any content item + const hasFunctionResponses = body.contents.some((content: any) => + Array.isArray(content.parts) && + content.parts.some((part: any) => part.functionResponse) + ) + + if (!hasFunctionResponses) { + return { modified: false, body } + } + + const { allSessions, allPrunedIds } = await getAllPrunedIds(ctx.client, ctx.state) + + if (allPrunedIds.size === 0) { + return { modified: false, body } + } + + // Find the active session to get the position mapping + const activeSessions = allSessions.data?.filter((s: any) => !s.parentID) || [] + let positionMapping: Map | undefined + + for (const session of activeSessions) { + const mapping = ctx.state.googleToolCallMapping.get(session.id) + if (mapping && mapping.size > 0) { + positionMapping = mapping + break + } + } + + if (!positionMapping) { + ctx.logger.info("fetch", "No Google tool call mapping found, skipping pruning for Gemini format") + return { modified: false, body } + } + + // Build position counters to track occurrence of each tool name + const toolPositionCounters = new Map() + let replacedCount = 0 + let totalFunctionResponses = 0 + + body.contents = body.contents.map((content: any) => { + if (!Array.isArray(content.parts)) return content + + let contentModified = false + const newParts = content.parts.map((part: any) => { + if (part.functionResponse) { + totalFunctionResponses++ + const funcName = part.functionResponse.name?.toLowerCase() + + if (funcName) { + // Get current position for this tool name and increment counter + const currentIndex = toolPositionCounters.get(funcName) || 0 + toolPositionCounters.set(funcName, currentIndex + 1) + + // Look up the tool call ID using position + const positionKey = `${funcName}:${currentIndex}` + const toolCallId = positionMapping!.get(positionKey) + + if (toolCallId && allPrunedIds.has(toolCallId)) { + contentModified = true + replacedCount++ + // Preserve thoughtSignature if present (required for Gemini 3 Pro) + // Only replace the response content, not the structure + return { + ...part, + functionResponse: { + ...part.functionResponse, + response: PRUNED_CONTENT_MESSAGE + } + } + } + } + } + return part + }) + + if (contentModified) { + return { ...content, parts: newParts } + } + return content + }) + + if (replacedCount > 0) { + ctx.logger.info("fetch", "Replaced pruned tool outputs (Google/Gemini)", { + replaced: replacedCount, + total: totalFunctionResponses + }) + + if (ctx.logger.enabled) { + let sessionMessages: any[] | undefined + if (activeSessions.length > 0) { + const mostRecentSession = activeSessions[0] + sessionMessages = await fetchSessionMessages(ctx.client, mostRecentSession.id) + } + + await ctx.logger.saveWrappedContext( + "global", + body.contents, + { + url: inputUrl, + replacedCount, + totalContents: body.contents.length, + format: 'google-gemini' + }, + sessionMessages + ) + } + + return { modified: true, body } + } + + return { modified: false, body } +} diff --git a/lib/fetch-wrapper/index.ts b/lib/fetch-wrapper/index.ts new file mode 100644 index 0000000..b7fdadb --- /dev/null +++ b/lib/fetch-wrapper/index.ts @@ -0,0 +1,80 @@ +import type { PluginState } from "../state" +import type { Logger } from "../logger" +import type { FetchHandlerContext } from "./types" +import { handleOpenAIChatAndAnthropic } from "./openai-chat" +import { handleGemini } from "./gemini" +import { handleOpenAIResponses } from "./openai-responses" + +export type { FetchHandlerContext, FetchHandlerResult } from "./types" + +/** + * Creates a wrapped global fetch that intercepts API calls and performs + * context pruning on tool outputs that have been marked for removal. + * + * Supports four API formats: + * 1. OpenAI Chat Completions (body.messages with role='tool') + * 2. Anthropic (body.messages with role='user' containing tool_result) + * 3. Google/Gemini (body.contents with functionResponse parts) + * 4. OpenAI Responses API (body.input with function_call_output items) + */ +export function installFetchWrapper( + state: PluginState, + logger: Logger, + client: any +): () => void { + const originalGlobalFetch = globalThis.fetch + + const ctx: FetchHandlerContext = { + state, + logger, + client + } + + globalThis.fetch = async (input: any, init?: any) => { + if (init?.body && typeof init.body === 'string') { + try { + const body = JSON.parse(init.body) + const inputUrl = typeof input === 'string' ? input : 'URL object' + let modified = false + + // Try each format handler in order + // OpenAI Chat Completions & Anthropic style (body.messages) + if (body.messages && Array.isArray(body.messages)) { + const result = await handleOpenAIChatAndAnthropic(body, ctx, inputUrl) + if (result.modified) { + modified = true + } + } + + // Google/Gemini style (body.contents) + if (body.contents && Array.isArray(body.contents)) { + const result = await handleGemini(body, ctx, inputUrl) + if (result.modified) { + modified = true + } + } + + // OpenAI Responses API style (body.input) + if (body.input && Array.isArray(body.input)) { + const result = await handleOpenAIResponses(body, ctx, inputUrl) + if (result.modified) { + modified = true + } + } + + if (modified) { + init.body = JSON.stringify(body) + } + } catch (e) { + // Silently ignore parsing errors - pass through to original fetch + } + } + + return originalGlobalFetch(input, init) + } + + // Return cleanup function to restore original fetch + return () => { + globalThis.fetch = originalGlobalFetch + } +} diff --git a/lib/fetch-wrapper/openai-chat.ts b/lib/fetch-wrapper/openai-chat.ts new file mode 100644 index 0000000..b0df063 --- /dev/null +++ b/lib/fetch-wrapper/openai-chat.ts @@ -0,0 +1,107 @@ +import type { FetchHandlerContext, FetchHandlerResult } from "./types" +import { + PRUNED_CONTENT_MESSAGE, + getAllPrunedIds, + fetchSessionMessages, + getMostRecentActiveSession +} from "./types" +import { cacheToolParametersFromMessages } from "../tool-cache" + +/** + * Handles OpenAI Chat Completions format (body.messages with role='tool'). + * Also handles Anthropic format (role='user' with tool_result content parts). + */ +export async function handleOpenAIChatAndAnthropic( + body: any, + ctx: FetchHandlerContext, + inputUrl: string +): Promise { + if (!body.messages || !Array.isArray(body.messages)) { + return { modified: false, body } + } + + // Cache tool parameters from messages + cacheToolParametersFromMessages(body.messages, ctx.state) + + // 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, allPrunedIds } = await getAllPrunedIds(ctx.client, ctx.state) + + if (toolMessages.length === 0 || allPrunedIds.size === 0) { + return { modified: false, body } + } + + 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: PRUNED_CONTENT_MESSAGE + } + } + + // 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: PRUNED_CONTENT_MESSAGE + } + } + return part + }) + if (messageModified) { + return { ...m, content: newContent } + } + } + + return m + }) + + if (replacedCount > 0) { + ctx.logger.info("fetch", "Replaced pruned tool outputs", { + replaced: replacedCount, + total: toolMessages.length + }) + + if (ctx.logger.enabled) { + const mostRecentSession = getMostRecentActiveSession(allSessions) + const sessionMessages = mostRecentSession + ? await fetchSessionMessages(ctx.client, mostRecentSession.id) + : undefined + + await ctx.logger.saveWrappedContext( + "global", + body.messages, + { + url: inputUrl, + replacedCount, + totalMessages: body.messages.length + }, + sessionMessages + ) + } + + return { modified: true, body } + } + + return { modified: false, body } +} diff --git a/lib/fetch-wrapper/openai-responses.ts b/lib/fetch-wrapper/openai-responses.ts new file mode 100644 index 0000000..f8305eb --- /dev/null +++ b/lib/fetch-wrapper/openai-responses.ts @@ -0,0 +1,81 @@ +import type { FetchHandlerContext, FetchHandlerResult } from "./types" +import { + PRUNED_CONTENT_MESSAGE, + getAllPrunedIds, + fetchSessionMessages, + getMostRecentActiveSession +} from "./types" +import { cacheToolParametersFromInput } from "../tool-cache" + +/** + * Handles OpenAI Responses API format (body.input array with function_call_output items). + * Used by GPT-5 models via sdk.responses(). + */ +export async function handleOpenAIResponses( + body: any, + ctx: FetchHandlerContext, + inputUrl: string +): Promise { + if (!body.input || !Array.isArray(body.input)) { + return { modified: false, body } + } + + // Cache tool parameters from input + cacheToolParametersFromInput(body.input, ctx.state) + + // Check for function_call_output items + const functionOutputs = body.input.filter((item: any) => item.type === 'function_call_output') + + if (functionOutputs.length === 0) { + return { modified: false, body } + } + + const { allSessions, allPrunedIds } = await getAllPrunedIds(ctx.client, ctx.state) + + if (allPrunedIds.size === 0) { + return { modified: false, body } + } + + let replacedCount = 0 + + body.input = body.input.map((item: any) => { + if (item.type === 'function_call_output' && allPrunedIds.has(item.call_id?.toLowerCase())) { + replacedCount++ + return { + ...item, + output: PRUNED_CONTENT_MESSAGE + } + } + return item + }) + + if (replacedCount > 0) { + ctx.logger.info("fetch", "Replaced pruned tool outputs (Responses API)", { + replaced: replacedCount, + total: functionOutputs.length + }) + + if (ctx.logger.enabled) { + const mostRecentSession = getMostRecentActiveSession(allSessions) + const sessionMessages = mostRecentSession + ? await fetchSessionMessages(ctx.client, mostRecentSession.id) + : undefined + + await ctx.logger.saveWrappedContext( + "global", + body.input, + { + url: inputUrl, + replacedCount, + totalItems: body.input.length, + format: 'openai-responses-api' + }, + sessionMessages + ) + } + + return { modified: true, body } + } + + return { modified: false, body } +} diff --git a/lib/fetch-wrapper/types.ts b/lib/fetch-wrapper/types.ts new file mode 100644 index 0000000..c7f5b52 --- /dev/null +++ b/lib/fetch-wrapper/types.ts @@ -0,0 +1,75 @@ +import type { PluginState } from "../state" +import type { Logger } from "../logger" + +/** The message used to replace pruned tool output content */ +export const PRUNED_CONTENT_MESSAGE = '[Output removed to save context - information superseded or no longer needed]' + +/** Context passed to each format-specific handler */ +export interface FetchHandlerContext { + state: PluginState + logger: Logger + client: any +} + +/** Result from a format handler indicating what happened */ +export interface FetchHandlerResult { + /** Whether the body was modified and should be re-serialized */ + modified: boolean + /** The potentially modified body object */ + body: any +} + +/** Session data returned from getAllPrunedIds */ +export interface PrunedIdData { + allSessions: any + allPrunedIds: Set +} + +/** + * Get all pruned IDs across all non-subagent sessions. + */ +export async function getAllPrunedIds( + client: any, + state: PluginState +): Promise { + const allSessions = await client.session.list() + const allPrunedIds = new Set() + + if (allSessions.data) { + for (const session of allSessions.data) { + if (session.parentID) continue + const prunedIds = state.prunedIds.get(session.id) ?? [] + prunedIds.forEach((id: string) => allPrunedIds.add(id)) + } + } + + return { allSessions, allPrunedIds } +} + +/** + * Fetch session messages for logging purposes. + */ +export async function fetchSessionMessages( + client: any, + sessionId: string +): Promise { + try { + const messagesResponse = await client.session.messages({ + path: { id: sessionId }, + query: { limit: 100 } + }) + return Array.isArray(messagesResponse.data) + ? messagesResponse.data + : Array.isArray(messagesResponse) ? messagesResponse : undefined + } catch (e) { + return undefined + } +} + +/** + * Get the most recent active (non-subagent) session. + */ +export function getMostRecentActiveSession(allSessions: any): any | undefined { + const activeSessions = allSessions.data?.filter((s: any) => !s.parentID) || [] + return activeSessions.length > 0 ? activeSessions[0] : undefined +} diff --git a/lib/hooks.ts b/lib/hooks.ts new file mode 100644 index 0000000..aefb10e --- /dev/null +++ b/lib/hooks.ts @@ -0,0 +1,113 @@ +import type { PluginState } from "./state" +import type { Logger } from "./logger" +import type { Janitor } from "./janitor" +import type { PluginConfig } from "./config" + +/** + * Checks if a session is a subagent session. + */ +export 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) { + return false + } +} + +/** + * Creates the event handler for session status changes. + */ +export function createEventHandler( + client: any, + janitor: Janitor, + logger: Logger, + config: PluginConfig +) { + return async ({ event }: { event: any }) => { + if (event.type === "session.status" && event.properties.status.type === "idle") { + if (await isSubagentSession(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 }) + }) + } + } +} + +/** + * Creates the chat.params hook for model caching and Google tool call mapping. + */ +export function createChatParamsHandler( + client: any, + state: PluginState, + logger: Logger +) { + return async (input: any, _output: any) => { + 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 + } + + // Cache model info for the session + if (providerID && modelID) { + state.model.set(sessionId, { + providerID: providerID, + modelID: modelID + }) + } + + // Build Google/Gemini tool call mapping for position-based correlation + // This is needed because Google's native format loses tool call IDs + if (providerID === 'google' || providerID === 'google-vertex') { + try { + const messagesResponse = await client.session.messages({ + path: { id: sessionId }, + query: { limit: 100 } + }) + const messages = messagesResponse.data || messagesResponse + + if (Array.isArray(messages)) { + // Build position mapping: track tool calls by name and occurrence index + const toolCallsByName = new Map() + + for (const msg of messages) { + if (msg.parts) { + for (const part of msg.parts) { + if (part.type === 'tool' && part.callID && part.tool) { + const toolName = part.tool.toLowerCase() + if (!toolCallsByName.has(toolName)) { + toolCallsByName.set(toolName, []) + } + toolCallsByName.get(toolName)!.push(part.callID.toLowerCase()) + } + } + } + } + + // Create position mapping: "toolName:index" -> toolCallId + const positionMapping = new Map() + for (const [toolName, callIds] of toolCallsByName) { + callIds.forEach((callId, index) => { + positionMapping.set(`${toolName}:${index}`, callId) + }) + } + + state.googleToolCallMapping.set(sessionId, positionMapping) + logger.info("chat.params", "Built Google tool call mapping", { + sessionId: sessionId.substring(0, 8), + toolCount: positionMapping.size + }) + } + } catch (error: any) { + logger.error("chat.params", "Failed to build Google tool call mapping", { + error: error.message + }) + } + } + } +} diff --git a/lib/pruning-tool.ts b/lib/pruning-tool.ts new file mode 100644 index 0000000..81a7fa6 --- /dev/null +++ b/lib/pruning-tool.ts @@ -0,0 +1,77 @@ +import { tool } from "@opencode-ai/plugin" +import type { Janitor } from "./janitor" +import type { PluginConfig } from "./config" + +/** Tool description for the context_pruning tool */ +export const CONTEXT_PRUNING_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 + + +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"] + + + +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"] + + + +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"] +` + +/** + * Creates the context_pruning tool definition. + * Returns a tool definition that can be passed to the plugin's tool registry. + */ +export function createPruningTool(janitor: Janitor, config: PluginConfig): ReturnType { + return tool({ + description: CONTEXT_PRUNING_DESCRIPTION, + 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!" + }, + }) +} diff --git a/lib/state.ts b/lib/state.ts new file mode 100644 index 0000000..9bad263 --- /dev/null +++ b/lib/state.ts @@ -0,0 +1,44 @@ +import type { SessionStats } from "./janitor" + +/** + * Centralized state management for the DCP plugin. + * All mutable state is stored here and shared across modules. + */ +export interface PluginState { + /** Map of session IDs to arrays of pruned tool call IDs */ + prunedIds: Map + /** Map of session IDs to session statistics */ + stats: Map + /** Cache of tool call IDs to their parameters */ + toolParameters: Map + /** Cache of session IDs to their model info */ + model: Map + /** + * Maps Google/Gemini tool positions to OpenCode tool call IDs for correlation. + * Key: sessionID, Value: Map where positionKey is "toolName:index" + */ + googleToolCallMapping: Map> +} + +export interface ToolParameterEntry { + tool: string + parameters: any +} + +export interface ModelInfo { + providerID: string + modelID: string +} + +/** + * Creates a fresh plugin state instance. + */ +export function createPluginState(): PluginState { + return { + prunedIds: new Map(), + stats: new Map(), + toolParameters: new Map(), + model: new Map(), + googleToolCallMapping: new Map(), + } +} diff --git a/lib/tool-cache.ts b/lib/tool-cache.ts new file mode 100644 index 0000000..669fa0f --- /dev/null +++ b/lib/tool-cache.ts @@ -0,0 +1,61 @@ +import type { PluginState } from "./state" + +/** + * Cache tool parameters from OpenAI Chat Completions style messages. + * Extracts tool call IDs and their parameters from assistant messages with tool_calls. + */ +export function cacheToolParametersFromMessages( + messages: any[], + state: PluginState +): void { + 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 + state.toolParameters.set(toolCall.id, { + tool: toolCall.function.name, + parameters: params + }) + } catch (error) { + // Silently ignore parse errors + } + } + } +} + +/** + * Cache tool parameters from OpenAI Responses API format. + * Extracts from input array items with type='function_call'. + */ +export function cacheToolParametersFromInput( + input: any[], + state: PluginState +): void { + for (const item of input) { + if (item.type !== 'function_call' || !item.call_id || !item.name) { + continue + } + + try { + const params = typeof item.arguments === 'string' + ? JSON.parse(item.arguments) + : item.arguments + state.toolParameters.set(item.call_id, { + tool: item.name, + parameters: params + }) + } catch (error) { + // Silently ignore parse errors + } + } +} From ea3a5d91f54314d73aaefbff12d34e6236082fc9 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Fri, 28 Nov 2025 21:36:58 -0500 Subject: [PATCH 4/4] v0.3.21 - Bump version --- README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9250f78..4806c5b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Add to your OpenCode config: ```jsonc // opencode.jsonc { - "plugin": ["@tarquinen/opencode-dcp@0.3.20"] + "plugin": ["@tarquinen/opencode-dcp@0.3.21"] } ``` diff --git a/package-lock.json b/package-lock.json index 2e92bc8..92b3e11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tarquinen/opencode-dcp", - "version": "0.3.20", + "version": "0.3.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "0.3.20", + "version": "0.3.21", "license": "MIT", "dependencies": { "@ai-sdk/openai-compatible": "^1.0.27", diff --git a/package.json b/package.json index f38e7b0..ea9361c 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.20", + "version": "0.3.21", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js",