diff --git a/.gitignore b/.gitignore
index e9287ec..358c623 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,4 +30,4 @@ Thumbs.db
# Tests (local development only)
tests/
-
+notes/
diff --git a/index.ts b/index.ts
index 0363304..a4536c4 100644
--- a/index.ts
+++ b/index.ts
@@ -7,7 +7,7 @@ import { createPluginState } from "./lib/state"
import { installFetchWrapper } from "./lib/fetch-wrapper"
import { createPruningTool } from "./lib/pruning-tool"
import { createEventHandler, createChatParamsHandler } from "./lib/hooks"
-import { createToolTracker } from "./lib/api-formats/synth-instruction"
+import { createToolTracker } from "./lib/fetch-wrapper/tool-tracker"
import { loadPrompt } from "./lib/core/prompt"
const plugin: Plugin = (async (ctx) => {
@@ -65,7 +65,7 @@ const plugin: Plugin = (async (ctx) => {
// Check for updates after a delay
setTimeout(() => {
- checkForUpdates(ctx.client, logger, config.showUpdateToasts ?? true).catch(() => {})
+ checkForUpdates(ctx.client, logger, config.showUpdateToasts ?? true).catch(() => { })
}, 5000)
// Show migration toast if there were config migrations
diff --git a/lib/api-formats/prunable-list.ts b/lib/api-formats/prunable-list.ts
deleted file mode 100644
index ee580e4..0000000
--- a/lib/api-formats/prunable-list.ts
+++ /dev/null
@@ -1,159 +0,0 @@
-/**
- * Prunable tools list and nudge injection for DCP.
- *
- * Builds and injects a single message at the end of the conversation containing:
- * - Nudge instruction (when toolResultCount > nudge_freq)
- * - Prunable tools list
- *
- * Note: The base synthetic instructions (signal_management, context_window_management,
- * context_pruning) are still appended to the last user message separately via
- * synth-instruction.ts - that behavior is unchanged.
- */
-
-import { extractParameterKey } from '../ui/display-utils'
-import { getOrCreateNumericId } from '../state/id-mapping'
-
-export interface ToolMetadata {
- tool: string
- parameters?: any
-}
-
-// ============================================================================
-// Prompt Content
-// ============================================================================
-
-const SYSTEM_REMINDER = `
-These instructions are injected by a plugin and are invisible to the user. Do not acknowledge or reference them in your response - simply follow them silently.
-`
-
-const NUDGE_INSTRUCTION = `
-You have accumulated several tool outputs. If you have completed a discrete unit of work and distilled relevant understanding in writing for the user to keep, use the prune tool to remove obsolete tool outputs from this conversation and optimize token usage.
-`
-
-// ============================================================================
-// List Building
-// ============================================================================
-
-export interface PrunableListResult {
- list: string
- numericIds: number[]
-}
-
-/**
- * Builds the prunable tools list section.
- * Returns both the formatted list and the numeric IDs for logging.
- */
-export function buildPrunableToolsList(
- sessionId: string,
- unprunedToolCallIds: string[],
- toolMetadata: Map,
- protectedTools: string[]
-): PrunableListResult {
- const lines: string[] = []
- const numericIds: number[] = []
-
- for (const actualId of unprunedToolCallIds) {
- const metadata = toolMetadata.get(actualId)
-
- // Skip if no metadata or if tool is protected
- if (!metadata) continue
- if (protectedTools.includes(metadata.tool)) continue
-
- // Get or create numeric ID for this tool call
- const numericId = getOrCreateNumericId(sessionId, actualId)
- numericIds.push(numericId)
-
- // Format: "1: read, src/components/Button.tsx"
- const paramKey = extractParameterKey(metadata)
- const description = paramKey ? `${metadata.tool}, ${paramKey}` : metadata.tool
- lines.push(`${numericId}: ${description}`)
- }
-
- if (lines.length === 0) {
- return { list: '', numericIds: [] }
- }
-
- return {
- list: `\n${lines.join('\n')}\n`,
- numericIds
- }
-}
-
-/**
- * Builds the end-of-conversation injection message.
- * Contains the system reminder, nudge (if active), and the prunable tools list.
- *
- * @param prunableList - The prunable tools list string (or empty string if none)
- * @param includeNudge - Whether to include the nudge instruction
- * @returns The injection string, or empty string if nothing to inject
- */
-export function buildEndInjection(
- prunableList: string,
- includeNudge: boolean
-): string {
- // If no prunable tools, don't inject anything
- if (!prunableList) {
- return ''
- }
-
- const parts = [SYSTEM_REMINDER]
-
- if (includeNudge) {
- parts.push(NUDGE_INSTRUCTION)
- }
-
- parts.push(prunableList)
-
- return parts.join('\n\n')
-}
-
-// ============================================================================
-// OpenAI Chat / Anthropic Format
-// ============================================================================
-
-/**
- * Injects the prunable list (and optionally nudge) at the end of OpenAI/Anthropic messages.
- * Appends a new user message at the end.
- */
-export function injectPrunableList(
- messages: any[],
- injection: string
-): boolean {
- if (!injection) return false
- messages.push({ role: 'user', content: injection })
- return true
-}
-
-// ============================================================================
-// Google/Gemini Format
-// ============================================================================
-
-/**
- * Injects the prunable list (and optionally nudge) at the end of Gemini contents.
- * Appends a new user content at the end.
- */
-export function injectPrunableListGemini(
- contents: any[],
- injection: string
-): boolean {
- if (!injection) return false
- contents.push({ role: 'user', parts: [{ text: injection }] })
- return true
-}
-
-// ============================================================================
-// OpenAI Responses API Format
-// ============================================================================
-
-/**
- * Injects the prunable list (and optionally nudge) at the end of OpenAI Responses API input.
- * Appends a new user message at the end.
- */
-export function injectPrunableListResponses(
- input: any[],
- injection: string
-): boolean {
- if (!injection) return false
- input.push({ type: 'message', role: 'user', content: injection })
- return true
-}
diff --git a/lib/api-formats/synth-instruction.ts b/lib/api-formats/synth-instruction.ts
deleted file mode 100644
index 7301c17..0000000
--- a/lib/api-formats/synth-instruction.ts
+++ /dev/null
@@ -1,184 +0,0 @@
-export interface ToolTracker {
- seenToolResultIds: Set
- toolResultCount: number // Tools since last prune
- skipNextIdle: boolean
- getToolName?: (callId: string) => string | undefined
-}
-
-export function createToolTracker(): ToolTracker {
- return { seenToolResultIds: new Set(), toolResultCount: 0, skipNextIdle: false }
-}
-
-export function resetToolTrackerCount(tracker: ToolTracker): void {
- tracker.toolResultCount = 0
-}
-
-/**
- * Track new tool results in OpenAI/Anthropic messages.
- * Increments toolResultCount only for tools not already seen and not protected.
- * Returns the number of NEW tools found (since last call).
- */
-export function trackNewToolResults(messages: any[], tracker: ToolTracker, protectedTools: Set): number {
- let newCount = 0
- for (const m of messages) {
- if (m.role === 'tool' && m.tool_call_id) {
- if (!tracker.seenToolResultIds.has(m.tool_call_id)) {
- tracker.seenToolResultIds.add(m.tool_call_id)
- const toolName = tracker.getToolName?.(m.tool_call_id)
- if (!toolName || !protectedTools.has(toolName)) {
- tracker.toolResultCount++
- newCount++
- }
- }
- } else if (m.role === 'user' && Array.isArray(m.content)) {
- for (const part of m.content) {
- if (part.type === 'tool_result' && part.tool_use_id) {
- if (!tracker.seenToolResultIds.has(part.tool_use_id)) {
- tracker.seenToolResultIds.add(part.tool_use_id)
- const toolName = tracker.getToolName?.(part.tool_use_id)
- if (!toolName || !protectedTools.has(toolName)) {
- tracker.toolResultCount++
- newCount++
- }
- }
- }
- }
- }
- }
- return newCount
-}
-
-/**
- * Track new tool results in Gemini contents.
- * Uses position-based tracking since Gemini doesn't have tool call IDs.
- * Returns the number of NEW tools found (since last call).
- */
-export function trackNewToolResultsGemini(contents: any[], tracker: ToolTracker, protectedTools: Set): number {
- let newCount = 0
- let positionCounter = 0
- for (const content of contents) {
- if (!Array.isArray(content.parts)) continue
- for (const part of content.parts) {
- if (part.functionResponse) {
- const positionId = `gemini_pos_${positionCounter}`
- positionCounter++
- if (!tracker.seenToolResultIds.has(positionId)) {
- tracker.seenToolResultIds.add(positionId)
- const toolName = part.functionResponse.name
- if (!toolName || !protectedTools.has(toolName)) {
- tracker.toolResultCount++
- newCount++
- }
- }
- }
- }
- }
- return newCount
-}
-
-/**
- * Track new tool results in OpenAI Responses API input.
- * Returns the number of NEW tools found (since last call).
- */
-export function trackNewToolResultsResponses(input: any[], tracker: ToolTracker, protectedTools: Set): number {
- let newCount = 0
- for (const item of input) {
- if (item.type === 'function_call_output' && item.call_id) {
- if (!tracker.seenToolResultIds.has(item.call_id)) {
- tracker.seenToolResultIds.add(item.call_id)
- const toolName = tracker.getToolName?.(item.call_id)
- if (!toolName || !protectedTools.has(toolName)) {
- tracker.toolResultCount++
- newCount++
- }
- }
- }
- }
- return newCount
-}
-
-function isNudgeMessage(msg: any, nudgeText: string): boolean {
- if (typeof msg.content === 'string') {
- return msg.content === nudgeText
- }
- return false
-}
-
-export function injectSynth(messages: any[], instruction: string, nudgeText: string): boolean {
- for (let i = messages.length - 1; i >= 0; i--) {
- const msg = messages[i]
- if (msg.role === 'user') {
- // Skip nudge messages - find real user message
- if (isNudgeMessage(msg, nudgeText)) continue
-
- if (typeof msg.content === 'string') {
- if (msg.content.includes(instruction)) return false
- msg.content = msg.content + '\n\n' + instruction
- } else if (Array.isArray(msg.content)) {
- const alreadyInjected = msg.content.some(
- (part: any) => part?.type === 'text' && typeof part.text === 'string' && part.text.includes(instruction)
- )
- if (alreadyInjected) return false
- msg.content.push({ type: 'text', text: instruction })
- }
- return true
- }
- }
- return false
-}
-
-function isNudgeContentGemini(content: any, nudgeText: string): boolean {
- if (Array.isArray(content.parts) && content.parts.length === 1) {
- const part = content.parts[0]
- return part?.text === nudgeText
- }
- return false
-}
-
-export function injectSynthGemini(contents: any[], instruction: string, nudgeText: string): boolean {
- for (let i = contents.length - 1; i >= 0; i--) {
- const content = contents[i]
- if (content.role === 'user' && Array.isArray(content.parts)) {
- // Skip nudge messages - find real user message
- if (isNudgeContentGemini(content, nudgeText)) continue
-
- const alreadyInjected = content.parts.some(
- (part: any) => part?.text && typeof part.text === 'string' && part.text.includes(instruction)
- )
- if (alreadyInjected) return false
- content.parts.push({ text: instruction })
- return true
- }
- }
- return false
-}
-
-function isNudgeItemResponses(item: any, nudgeText: string): boolean {
- if (typeof item.content === 'string') {
- return item.content === nudgeText
- }
- return false
-}
-
-export function injectSynthResponses(input: any[], instruction: string, nudgeText: string): boolean {
- for (let i = input.length - 1; i >= 0; i--) {
- const item = input[i]
- if (item.type === 'message' && item.role === 'user') {
- // Skip nudge messages - find real user message
- if (isNudgeItemResponses(item, nudgeText)) continue
-
- if (typeof item.content === 'string') {
- if (item.content.includes(instruction)) return false
- item.content = item.content + '\n\n' + instruction
- } else if (Array.isArray(item.content)) {
- const alreadyInjected = item.content.some(
- (part: any) => part?.type === 'input_text' && typeof part.text === 'string' && part.text.includes(instruction)
- )
- if (alreadyInjected) return false
- item.content.push({ type: 'input_text', text: instruction })
- }
- return true
- }
- }
- return false
-}
diff --git a/lib/fetch-wrapper/formats/bedrock.ts b/lib/fetch-wrapper/formats/bedrock.ts
index 26c1ca5..405e9d7 100644
--- a/lib/fetch-wrapper/formats/bedrock.ts
+++ b/lib/fetch-wrapper/formats/bedrock.ts
@@ -1,28 +1,82 @@
-import type { FormatDescriptor, ToolOutput } from "../types"
+import type { FormatDescriptor, ToolOutput, ToolTracker } from "../types"
import type { PluginState } from "../../state"
import type { Logger } from "../../logger"
-import type { ToolTracker } from "../../api-formats/synth-instruction"
import { cacheToolParametersFromMessages } from "../../state/tool-cache"
-import { injectSynth, trackNewToolResults } from "../../api-formats/synth-instruction"
-import { injectPrunableList } from "../../api-formats/prunable-list"
+
+function isNudgeMessage(msg: any, nudgeText: string): boolean {
+ if (typeof msg.content === 'string') {
+ return msg.content === nudgeText
+ }
+ return false
+}
+
+function injectSynth(messages: any[], instruction: string, nudgeText: string): boolean {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const msg = messages[i]
+ if (msg.role === 'user') {
+ if (isNudgeMessage(msg, nudgeText)) continue
+
+ if (typeof msg.content === 'string') {
+ if (msg.content.includes(instruction)) return false
+ msg.content = msg.content + '\n\n' + instruction
+ } else if (Array.isArray(msg.content)) {
+ const alreadyInjected = msg.content.some(
+ (part: any) => part?.type === 'text' && typeof part.text === 'string' && part.text.includes(instruction)
+ )
+ if (alreadyInjected) return false
+ msg.content.push({ type: 'text', text: instruction })
+ }
+ return true
+ }
+ }
+ return false
+}
+
+function trackNewToolResults(messages: any[], tracker: ToolTracker, protectedTools: Set): number {
+ let newCount = 0
+ for (const m of messages) {
+ if (m.role === 'tool' && m.tool_call_id) {
+ if (!tracker.seenToolResultIds.has(m.tool_call_id)) {
+ tracker.seenToolResultIds.add(m.tool_call_id)
+ const toolName = tracker.getToolName?.(m.tool_call_id)
+ if (!toolName || !protectedTools.has(toolName)) {
+ tracker.toolResultCount++
+ newCount++
+ }
+ }
+ } else if (m.role === 'user' && Array.isArray(m.content)) {
+ for (const part of m.content) {
+ if (part.type === 'tool_result' && part.tool_use_id) {
+ if (!tracker.seenToolResultIds.has(part.tool_use_id)) {
+ tracker.seenToolResultIds.add(part.tool_use_id)
+ const toolName = tracker.getToolName?.(part.tool_use_id)
+ if (!toolName || !protectedTools.has(toolName)) {
+ tracker.toolResultCount++
+ newCount++
+ }
+ }
+ }
+ }
+ }
+ }
+ return newCount
+}
+
+function injectPrunableList(messages: any[], injection: string): boolean {
+ if (!injection) return false
+ messages.push({ role: 'user', content: injection })
+ return true
+}
/**
- * Format descriptor for AWS Bedrock Converse API.
- *
- * Bedrock format characteristics:
- * - Top-level `system` array for system messages
- * - `messages` array with only 'user' and 'assistant' roles
- * - `inferenceConfig` for model parameters (maxTokens, temperature, etc.)
- * - Tool calls: `toolUse` blocks in assistant content with `toolUseId`
- * - Tool results: `toolResult` blocks in user content with `toolUseId`
- * - Cache points: `cachePoint` blocks that should be preserved
+ * Bedrock uses top-level `system` array + `inferenceConfig` (distinguishes from OpenAI/Anthropic).
+ * Tool calls: `toolUse` blocks in assistant content with `toolUseId`
+ * Tool results: `toolResult` blocks in user content with `toolUseId`
*/
export const bedrockFormat: FormatDescriptor = {
name: 'bedrock',
detect(body: any): boolean {
- // Bedrock has a top-level system array AND inferenceConfig (not model params in messages)
- // This distinguishes it from OpenAI/Anthropic which put system in messages
return (
Array.isArray(body.system) &&
body.inferenceConfig !== undefined &&
@@ -35,8 +89,7 @@ export const bedrockFormat: FormatDescriptor = {
},
cacheToolParameters(data: any[], state: PluginState, logger?: Logger): void {
- // Bedrock stores tool calls in assistant message content as toolUse blocks
- // We need to extract toolUseId and tool name for later correlation
+ // Extract toolUseId and tool name from assistant toolUse blocks
for (const m of data) {
if (m.role === 'assistant' && Array.isArray(m.content)) {
for (const block of m.content) {
@@ -54,7 +107,6 @@ export const bedrockFormat: FormatDescriptor = {
}
}
}
- // Also use the generic message caching for any compatible structures
cacheToolParametersFromMessages(data, state, logger)
},
@@ -74,7 +126,6 @@ export const bedrockFormat: FormatDescriptor = {
const outputs: ToolOutput[] = []
for (const m of data) {
- // Bedrock tool results are in user messages as toolResult blocks
if (m.role === 'user' && Array.isArray(m.content)) {
for (const block of m.content) {
if (block.toolResult && block.toolResult.toolUseId) {
@@ -99,13 +150,11 @@ export const bedrockFormat: FormatDescriptor = {
for (let i = 0; i < data.length; i++) {
const m = data[i]
- // Tool results are in user messages as toolResult blocks
if (m.role === 'user' && Array.isArray(m.content)) {
let messageModified = false
const newContent = m.content.map((block: any) => {
if (block.toolResult && block.toolResult.toolUseId?.toLowerCase() === toolIdLower) {
messageModified = true
- // Replace the content array inside toolResult with pruned message
return {
...block,
toolResult: {
diff --git a/lib/fetch-wrapper/formats/gemini.ts b/lib/fetch-wrapper/formats/gemini.ts
index 0eee5d6..4c0508b 100644
--- a/lib/fetch-wrapper/formats/gemini.ts
+++ b/lib/fetch-wrapper/formats/gemini.ts
@@ -1,19 +1,63 @@
-import type { FormatDescriptor, ToolOutput } from "../types"
-import { PRUNED_CONTENT_MESSAGE } from "../types"
+import type { FormatDescriptor, ToolOutput, ToolTracker } from "../types"
import type { PluginState } from "../../state"
import type { Logger } from "../../logger"
-import type { ToolTracker } from "../../api-formats/synth-instruction"
-import { injectSynthGemini, trackNewToolResultsGemini } from "../../api-formats/synth-instruction"
-import { injectPrunableListGemini } from "../../api-formats/prunable-list"
+
+function isNudgeContent(content: any, nudgeText: string): boolean {
+ if (Array.isArray(content.parts) && content.parts.length === 1) {
+ const part = content.parts[0]
+ return part?.text === nudgeText
+ }
+ return false
+}
+
+function injectSynth(contents: any[], instruction: string, nudgeText: string): boolean {
+ for (let i = contents.length - 1; i >= 0; i--) {
+ const content = contents[i]
+ if (content.role === 'user' && Array.isArray(content.parts)) {
+ if (isNudgeContent(content, nudgeText)) continue
+
+ const alreadyInjected = content.parts.some(
+ (part: any) => part?.text && typeof part.text === 'string' && part.text.includes(instruction)
+ )
+ if (alreadyInjected) return false
+ content.parts.push({ text: instruction })
+ return true
+ }
+ }
+ return false
+}
+
+function trackNewToolResults(contents: any[], tracker: ToolTracker, protectedTools: Set): number {
+ let newCount = 0
+ let positionCounter = 0
+ for (const content of contents) {
+ if (!Array.isArray(content.parts)) continue
+ for (const part of content.parts) {
+ if (part.functionResponse) {
+ const positionId = `gemini_pos_${positionCounter}`
+ positionCounter++
+ if (!tracker.seenToolResultIds.has(positionId)) {
+ tracker.seenToolResultIds.add(positionId)
+ const toolName = part.functionResponse.name
+ if (!toolName || !protectedTools.has(toolName)) {
+ tracker.toolResultCount++
+ newCount++
+ }
+ }
+ }
+ }
+ }
+ return newCount
+}
+
+function injectPrunableList(contents: any[], injection: string): boolean {
+ if (!injection) return false
+ contents.push({ role: 'user', parts: [{ text: injection }] })
+ return true
+}
/**
- * Format descriptor for Google/Gemini API.
- *
- * Uses body.contents array with:
- * - parts[].functionCall for tool invocations
- * - parts[].functionResponse for tool results
- *
- * IMPORTANT: Gemini doesn't include tool call IDs in its native format.
+ * Gemini doesn't include tool call IDs in its native format.
* We use position-based correlation via state.googleToolCallMapping which maps
* "toolName:index" -> "toolCallId" (populated by hooks.ts from message events).
*/
@@ -29,22 +73,19 @@ export const geminiFormat: FormatDescriptor = {
},
cacheToolParameters(_data: any[], _state: PluginState, _logger?: Logger): void {
- // Gemini format doesn't include tool parameters in the request body.
- // Tool parameters are captured via message events in hooks.ts and stored
- // in state.googleToolCallMapping for position-based correlation.
- // No-op here.
+ // No-op: Gemini tool parameters are captured via message events in hooks.ts
},
injectSynth(data: any[], instruction: string, nudgeText: string): boolean {
- return injectSynthGemini(data, instruction, nudgeText)
+ return injectSynth(data, instruction, nudgeText)
},
trackNewToolResults(data: any[], tracker: ToolTracker, protectedTools: Set): number {
- return trackNewToolResultsGemini(data, tracker, protectedTools)
+ return trackNewToolResults(data, tracker, protectedTools)
},
injectPrunableList(data: any[], injection: string): boolean {
- return injectPrunableListGemini(data, injection)
+ return injectPrunableList(data, injection)
},
extractToolOutputs(data: any[], state: PluginState): ToolOutput[] {
diff --git a/lib/fetch-wrapper/formats/openai-chat.ts b/lib/fetch-wrapper/formats/openai-chat.ts
index b481046..481da4d 100644
--- a/lib/fetch-wrapper/formats/openai-chat.ts
+++ b/lib/fetch-wrapper/formats/openai-chat.ts
@@ -1,23 +1,73 @@
-import type { FormatDescriptor, ToolOutput } from "../types"
-import { PRUNED_CONTENT_MESSAGE } from "../types"
+import type { FormatDescriptor, ToolOutput, ToolTracker } from "../types"
import type { PluginState } from "../../state"
import type { Logger } from "../../logger"
-import type { ToolTracker } from "../../api-formats/synth-instruction"
import { cacheToolParametersFromMessages } from "../../state/tool-cache"
-import { injectSynth, trackNewToolResults } from "../../api-formats/synth-instruction"
-import { injectPrunableList } from "../../api-formats/prunable-list"
-
-/**
- * Format descriptor for OpenAI Chat Completions and Anthropic APIs.
- *
- * OpenAI Chat format:
- * - Messages with role='tool' and tool_call_id
- * - Assistant messages with tool_calls[] array
- *
- * Anthropic format:
- * - Messages with role='user' containing content[].type='tool_result' and tool_use_id
- * - Assistant messages with content[].type='tool_use'
- */
+
+function isNudgeMessage(msg: any, nudgeText: string): boolean {
+ if (typeof msg.content === 'string') {
+ return msg.content === nudgeText
+ }
+ return false
+}
+
+function injectSynth(messages: any[], instruction: string, nudgeText: string): boolean {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const msg = messages[i]
+ if (msg.role === 'user') {
+ if (isNudgeMessage(msg, nudgeText)) continue
+
+ if (typeof msg.content === 'string') {
+ if (msg.content.includes(instruction)) return false
+ msg.content = msg.content + '\n\n' + instruction
+ } else if (Array.isArray(msg.content)) {
+ const alreadyInjected = msg.content.some(
+ (part: any) => part?.type === 'text' && typeof part.text === 'string' && part.text.includes(instruction)
+ )
+ if (alreadyInjected) return false
+ msg.content.push({ type: 'text', text: instruction })
+ }
+ return true
+ }
+ }
+ return false
+}
+
+function trackNewToolResults(messages: any[], tracker: ToolTracker, protectedTools: Set): number {
+ let newCount = 0
+ for (const m of messages) {
+ if (m.role === 'tool' && m.tool_call_id) {
+ if (!tracker.seenToolResultIds.has(m.tool_call_id)) {
+ tracker.seenToolResultIds.add(m.tool_call_id)
+ const toolName = tracker.getToolName?.(m.tool_call_id)
+ if (!toolName || !protectedTools.has(toolName)) {
+ tracker.toolResultCount++
+ newCount++
+ }
+ }
+ } else if (m.role === 'user' && Array.isArray(m.content)) {
+ for (const part of m.content) {
+ if (part.type === 'tool_result' && part.tool_use_id) {
+ if (!tracker.seenToolResultIds.has(part.tool_use_id)) {
+ tracker.seenToolResultIds.add(part.tool_use_id)
+ const toolName = tracker.getToolName?.(part.tool_use_id)
+ if (!toolName || !protectedTools.has(toolName)) {
+ tracker.toolResultCount++
+ newCount++
+ }
+ }
+ }
+ }
+ }
+ }
+ return newCount
+}
+
+function injectPrunableList(messages: any[], injection: string): boolean {
+ if (!injection) return false
+ messages.push({ role: 'user', content: injection })
+ return true
+}
+
export const openaiChatFormat: FormatDescriptor = {
name: 'openai-chat',
diff --git a/lib/fetch-wrapper/formats/openai-responses.ts b/lib/fetch-wrapper/formats/openai-responses.ts
index 67ac5b7..96ee858 100644
--- a/lib/fetch-wrapper/formats/openai-responses.ts
+++ b/lib/fetch-wrapper/formats/openai-responses.ts
@@ -1,20 +1,60 @@
-import type { FormatDescriptor, ToolOutput } from "../types"
-import { PRUNED_CONTENT_MESSAGE } from "../types"
+import type { FormatDescriptor, ToolOutput, ToolTracker } from "../types"
import type { PluginState } from "../../state"
import type { Logger } from "../../logger"
-import type { ToolTracker } from "../../api-formats/synth-instruction"
import { cacheToolParametersFromInput } from "../../state/tool-cache"
-import { injectSynthResponses, trackNewToolResultsResponses } from "../../api-formats/synth-instruction"
-import { injectPrunableListResponses } from "../../api-formats/prunable-list"
-
-/**
- * Format descriptor for OpenAI Responses API (GPT-5 models via sdk.responses()).
- *
- * Uses body.input array with:
- * - type='function_call' items for tool calls
- * - type='function_call_output' items for tool results
- * - type='message' items for user/assistant messages
- */
+
+function isNudgeItem(item: any, nudgeText: string): boolean {
+ if (typeof item.content === 'string') {
+ return item.content === nudgeText
+ }
+ return false
+}
+
+function injectSynth(input: any[], instruction: string, nudgeText: string): boolean {
+ for (let i = input.length - 1; i >= 0; i--) {
+ const item = input[i]
+ if (item.type === 'message' && item.role === 'user') {
+ if (isNudgeItem(item, nudgeText)) continue
+
+ if (typeof item.content === 'string') {
+ if (item.content.includes(instruction)) return false
+ item.content = item.content + '\n\n' + instruction
+ } else if (Array.isArray(item.content)) {
+ const alreadyInjected = item.content.some(
+ (part: any) => part?.type === 'input_text' && typeof part.text === 'string' && part.text.includes(instruction)
+ )
+ if (alreadyInjected) return false
+ item.content.push({ type: 'input_text', text: instruction })
+ }
+ return true
+ }
+ }
+ return false
+}
+
+function trackNewToolResults(input: any[], tracker: ToolTracker, protectedTools: Set): number {
+ let newCount = 0
+ for (const item of input) {
+ if (item.type === 'function_call_output' && item.call_id) {
+ if (!tracker.seenToolResultIds.has(item.call_id)) {
+ tracker.seenToolResultIds.add(item.call_id)
+ const toolName = tracker.getToolName?.(item.call_id)
+ if (!toolName || !protectedTools.has(toolName)) {
+ tracker.toolResultCount++
+ newCount++
+ }
+ }
+ }
+ }
+ return newCount
+}
+
+function injectPrunableList(input: any[], injection: string): boolean {
+ if (!injection) return false
+ input.push({ type: 'message', role: 'user', content: injection })
+ return true
+}
+
export const openaiResponsesFormat: FormatDescriptor = {
name: 'openai-responses',
@@ -31,15 +71,15 @@ export const openaiResponsesFormat: FormatDescriptor = {
},
injectSynth(data: any[], instruction: string, nudgeText: string): boolean {
- return injectSynthResponses(data, instruction, nudgeText)
+ return injectSynth(data, instruction, nudgeText)
},
trackNewToolResults(data: any[], tracker: ToolTracker, protectedTools: Set): number {
- return trackNewToolResultsResponses(data, tracker, protectedTools)
+ return trackNewToolResults(data, tracker, protectedTools)
},
injectPrunableList(data: any[], injection: string): boolean {
- return injectPrunableListResponses(data, injection)
+ return injectPrunableList(data, injection)
},
extractToolOutputs(data: any[], state: PluginState): ToolOutput[] {
diff --git a/lib/fetch-wrapper/gc-tracker.ts b/lib/fetch-wrapper/gc-tracker.ts
index 9119d89..950a21a 100644
--- a/lib/fetch-wrapper/gc-tracker.ts
+++ b/lib/fetch-wrapper/gc-tracker.ts
@@ -14,7 +14,7 @@ export function accumulateGCStats(
const tokensCollected = estimateTokensFromOutputs(toolOutputs)
const existing = state.gcPending.get(sessionId) ?? { tokensCollected: 0, toolsDeduped: 0 }
-
+
state.gcPending.set(sessionId, {
tokensCollected: existing.tokensCollected + tokensCollected,
toolsDeduped: existing.toolsDeduped + prunedIds.length
diff --git a/lib/fetch-wrapper/handler.ts b/lib/fetch-wrapper/handler.ts
index 004378d..1231608 100644
--- a/lib/fetch-wrapper/handler.ts
+++ b/lib/fetch-wrapper/handler.ts
@@ -1,21 +1,57 @@
-import type { FetchHandlerContext, FetchHandlerResult, FormatDescriptor } from "./types"
-import {
- PRUNED_CONTENT_MESSAGE,
- getAllPrunedIds,
- fetchSessionMessages
-} from "./types"
-import { buildPrunableToolsList, buildEndInjection } from "../api-formats/prunable-list"
-
-/**
- * Generic format handler that processes any API format using a FormatDescriptor.
- *
- * This consolidates the common logic from all format-specific handlers:
- * 1. Cache tool parameters
- * 2. Inject synthetic instructions (if strategies enabled)
- * 3. Build and inject prunable tools list
- * 4. Replace pruned tool outputs
- * 5. Log and save context
- */
+import type { FetchHandlerContext, FetchHandlerResult, FormatDescriptor, PrunedIdData } from "./types"
+import { type PluginState, ensureSessionRestored } from "../state"
+import type { Logger } from "../logger"
+import { buildPrunableToolsList, buildEndInjection } from "./prunable-list"
+
+const PRUNED_CONTENT_MESSAGE = '[Output removed to save context - information superseded or no longer needed]'
+
+function getMostRecentActiveSession(allSessions: any): any | undefined {
+ const activeSessions = allSessions.data?.filter((s: any) => !s.parentID) || []
+ return activeSessions.length > 0 ? activeSessions[0] : undefined
+}
+
+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
+ }
+}
+
+async function getAllPrunedIds(
+ client: any,
+ state: PluginState,
+ logger?: Logger
+): Promise {
+ const allSessions = await client.session.list()
+ const allPrunedIds = new Set()
+
+ const currentSession = getMostRecentActiveSession(allSessions)
+ if (currentSession) {
+ await ensureSessionRestored(state, currentSession.id, logger)
+ const prunedIds = state.prunedIds.get(currentSession.id) ?? []
+ prunedIds.forEach((id: string) => allPrunedIds.add(id.toLowerCase()))
+
+ if (logger && prunedIds.length > 0) {
+ logger.debug("fetch", "Loaded pruned IDs for replacement", {
+ sessionId: currentSession.id,
+ prunedCount: prunedIds.length
+ })
+ }
+ }
+
+ return { allSessions, allPrunedIds }
+}
+
export async function handleFormat(
body: any,
ctx: FetchHandlerContext,
@@ -81,11 +117,13 @@ export async function handleFormat(
const toolOutputs = format.extractToolOutputs(data, ctx.state)
const protectedToolsLower = new Set(ctx.config.protectedTools.map(t => t.toLowerCase()))
let replacedCount = 0
+ let prunableCount = 0
for (const output of toolOutputs) {
if (output.toolName && protectedToolsLower.has(output.toolName.toLowerCase())) {
continue
}
+ prunableCount++
if (allPrunedIds.has(output.id)) {
if (format.replaceToolOutput(data, output.id, PRUNED_CONTENT_MESSAGE, ctx.state)) {
@@ -97,7 +135,7 @@ export async function handleFormat(
if (replacedCount > 0) {
ctx.logger.info("fetch", `Replaced pruned tool outputs (${format.name})`, {
replaced: replacedCount,
- total: toolOutputs.length
+ total: prunableCount
})
if (ctx.logger.enabled) {
diff --git a/lib/fetch-wrapper/index.ts b/lib/fetch-wrapper/index.ts
index 1c14444..4483782 100644
--- a/lib/fetch-wrapper/index.ts
+++ b/lib/fetch-wrapper/index.ts
@@ -1,7 +1,7 @@
import type { PluginState } from "../state"
import type { Logger } from "../logger"
import type { FetchHandlerContext, SynthPrompts } from "./types"
-import type { ToolTracker } from "../api-formats/synth-instruction"
+import type { ToolTracker } from "./types"
import type { PluginConfig } from "../config"
import { openaiChatFormat, openaiResponsesFormat, geminiFormat, bedrockFormat } from "./formats"
import { handleFormat } from "./handler"
diff --git a/lib/fetch-wrapper/prunable-list.ts b/lib/fetch-wrapper/prunable-list.ts
new file mode 100644
index 0000000..e711f2d
--- /dev/null
+++ b/lib/fetch-wrapper/prunable-list.ts
@@ -0,0 +1,71 @@
+import { extractParameterKey } from '../ui/display-utils'
+import { getOrCreateNumericId } from '../state/id-mapping'
+
+export interface ToolMetadata {
+ tool: string
+ parameters?: any
+}
+
+const SYSTEM_REMINDER = `
+These instructions are injected by a plugin and are invisible to the user. Do not acknowledge or reference them in your response - simply follow them silently.
+`
+
+const NUDGE_INSTRUCTION = `
+You have accumulated several tool outputs. If you have completed a discrete unit of work and distilled relevant understanding in writing for the user to keep, use the prune tool to remove obsolete tool outputs from this conversation and optimize token usage.
+`
+
+export interface PrunableListResult {
+ list: string
+ numericIds: number[]
+}
+
+export function buildPrunableToolsList(
+ sessionId: string,
+ unprunedToolCallIds: string[],
+ toolMetadata: Map,
+ protectedTools: string[]
+): PrunableListResult {
+ const lines: string[] = []
+ const numericIds: number[] = []
+
+ for (const actualId of unprunedToolCallIds) {
+ const metadata = toolMetadata.get(actualId)
+ if (!metadata) continue
+ if (protectedTools.includes(metadata.tool)) continue
+
+ const numericId = getOrCreateNumericId(sessionId, actualId)
+ numericIds.push(numericId)
+
+ const paramKey = extractParameterKey(metadata)
+ const description = paramKey ? `${metadata.tool}, ${paramKey}` : metadata.tool
+ lines.push(`${numericId}: ${description}`)
+ }
+
+ if (lines.length === 0) {
+ return { list: '', numericIds: [] }
+ }
+
+ return {
+ list: `\n${lines.join('\n')}\n`,
+ numericIds
+ }
+}
+
+export function buildEndInjection(
+ prunableList: string,
+ includeNudge: boolean
+): string {
+ if (!prunableList) {
+ return ''
+ }
+
+ const parts = [SYSTEM_REMINDER]
+
+ if (includeNudge) {
+ parts.push(NUDGE_INSTRUCTION)
+ }
+
+ parts.push(prunableList)
+
+ return parts.join('\n\n')
+}
diff --git a/lib/fetch-wrapper/tool-tracker.ts b/lib/fetch-wrapper/tool-tracker.ts
new file mode 100644
index 0000000..4048925
--- /dev/null
+++ b/lib/fetch-wrapper/tool-tracker.ts
@@ -0,0 +1,14 @@
+export interface ToolTracker {
+ seenToolResultIds: Set
+ toolResultCount: number // Tools since last prune
+ skipNextIdle: boolean
+ getToolName?: (callId: string) => string | undefined
+}
+
+export function createToolTracker(): ToolTracker {
+ return { seenToolResultIds: new Set(), toolResultCount: 0, skipNextIdle: false }
+}
+
+export function resetToolTrackerCount(tracker: ToolTracker): void {
+ tracker.toolResultCount = 0
+}
diff --git a/lib/fetch-wrapper/types.ts b/lib/fetch-wrapper/types.ts
index b88bf82..bde8f18 100644
--- a/lib/fetch-wrapper/types.ts
+++ b/lib/fetch-wrapper/types.ts
@@ -1,69 +1,33 @@
-import { type PluginState, ensureSessionRestored } from "../state"
+import type { PluginState } from "../state"
import type { Logger } from "../logger"
-import type { ToolTracker } from "../api-formats/synth-instruction"
import type { PluginConfig } from "../config"
+import type { ToolTracker } from "./tool-tracker"
+export type { ToolTracker } from "./tool-tracker"
-/** 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]'
-
-// ============================================================================
-// Format Descriptor Interface
-// ============================================================================
-
-/** Represents a tool output that can be pruned */
export interface ToolOutput {
- /** The tool call ID (tool_call_id, call_id, tool_use_id, or position key for Gemini) */
id: string
- /** The tool name (for protected tool checking) */
toolName?: string
}
-/**
- * Describes how to handle a specific API format (OpenAI Chat, Anthropic, Gemini, etc.)
- * Each format implements this interface to provide format-specific logic.
- */
export interface FormatDescriptor {
- /** Human-readable name for logging */
name: string
-
- /** Check if this format matches the request body */
detect(body: any): boolean
-
- /** Get the data array to process (messages, contents, input, etc.) */
getDataArray(body: any): any[] | undefined
-
- /** Cache tool parameters from the data array */
cacheToolParameters(data: any[], state: PluginState, logger?: Logger): void
-
- /** Inject synthetic instruction into the last user message */
injectSynth(data: any[], instruction: string, nudgeText: string): boolean
-
- /** Track new tool results for nudge frequency */
trackNewToolResults(data: any[], tracker: ToolTracker, protectedTools: Set): number
-
- /** Inject prunable list at end of conversation */
injectPrunableList(data: any[], injection: string): boolean
-
- /** Extract all tool outputs from the data for pruning */
extractToolOutputs(data: any[], state: PluginState): ToolOutput[]
-
- /** Replace a pruned tool output with the pruned message. Returns true if replaced. */
replaceToolOutput(data: any[], toolId: string, prunedMessage: string, state: PluginState): boolean
-
- /** Check if data has any tool outputs worth processing */
hasToolOutputs(data: any[]): boolean
-
- /** Get metadata for logging after replacements */
getLogMetadata(data: any[], replacedCount: number, inputUrl: string): Record
}
-/** Prompts used for synthetic instruction injection */
export interface SynthPrompts {
synthInstruction: string
nudgeInstruction: string
}
-/** Context passed to each format-specific handler */
export interface FetchHandlerContext {
state: PluginState
logger: Logger
@@ -73,69 +37,12 @@ export interface FetchHandlerContext {
prompts: SynthPrompts
}
-/** 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
}
-
-export async function getAllPrunedIds(
- client: any,
- state: PluginState,
- logger?: Logger
-): Promise {
- const allSessions = await client.session.list()
- const allPrunedIds = new Set()
-
- const currentSession = getMostRecentActiveSession(allSessions)
- if (currentSession) {
- await ensureSessionRestored(state, currentSession.id, logger)
- const prunedIds = state.prunedIds.get(currentSession.id) ?? []
- prunedIds.forEach((id: string) => allPrunedIds.add(id.toLowerCase()))
-
- if (logger && prunedIds.length > 0) {
- logger.debug("fetch", "Loaded pruned IDs for replacement", {
- sessionId: currentSession.id,
- prunedCount: prunedIds.length
- })
- }
- }
-
- 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
index b2e461e..1f54f34 100644
--- a/lib/hooks.ts
+++ b/lib/hooks.ts
@@ -3,8 +3,8 @@ import type { Logger } from "./logger"
import type { JanitorContext } from "./core/janitor"
import { runOnIdle } from "./core/janitor"
import type { PluginConfig, PruningStrategy } from "./config"
-import type { ToolTracker } from "./api-formats/synth-instruction"
-import { resetToolTrackerCount } from "./api-formats/synth-instruction"
+import type { ToolTracker } from "./fetch-wrapper/tool-tracker"
+import { resetToolTrackerCount } from "./fetch-wrapper/tool-tracker"
import { clearAllMappings } from "./state/id-mapping"
export async function isSubagentSession(client: any, sessionID: string): Promise {
@@ -116,12 +116,12 @@ export function createChatParamsHandler(
if (part.type === 'tool' && part.callID && part.tool) {
const toolName = part.tool.toLowerCase()
const callId = part.callID.toLowerCase()
-
+
if (!toolCallsByName.has(toolName)) {
toolCallsByName.set(toolName, [])
}
toolCallsByName.get(toolName)!.push(callId)
-
+
if (!state.toolParameters.has(callId)) {
state.toolParameters.set(callId, {
tool: part.tool,
diff --git a/lib/pruning-tool.ts b/lib/pruning-tool.ts
index 13605e8..89f9a03 100644
--- a/lib/pruning-tool.ts
+++ b/lib/pruning-tool.ts
@@ -1,8 +1,8 @@
import { tool } from "@opencode-ai/plugin"
import type { PluginState } from "./state"
import type { PluginConfig } from "./config"
-import type { ToolTracker } from "./api-formats/synth-instruction"
-import { resetToolTrackerCount } from "./api-formats/synth-instruction"
+import type { ToolTracker } from "./fetch-wrapper/tool-tracker"
+import { resetToolTrackerCount } from "./fetch-wrapper/tool-tracker"
import { isSubagentSession } from "./hooks"
import { getActualId } from "./state/id-mapping"
import { formatPruningResultForTool, sendUnifiedNotification, type NotificationContext } from "./ui/notification"
diff --git a/lib/state/tool-cache.ts b/lib/state/tool-cache.ts
index f0ae3c6..eadaba3 100644
--- a/lib/state/tool-cache.ts
+++ b/lib/state/tool-cache.ts
@@ -32,10 +32,10 @@ export function cacheToolParametersFromMessages(
const params = typeof toolCall.function.arguments === 'string'
? JSON.parse(toolCall.function.arguments)
: toolCall.function.arguments
- state.toolParameters.set(toolCall.id.toLowerCase(), {
- tool: toolCall.function.name,
- parameters: params
- })
+ state.toolParameters.set(toolCall.id.toLowerCase(), {
+ tool: toolCall.function.name,
+ parameters: params
+ })
openaiCached++
} catch (error) {
}
@@ -48,10 +48,10 @@ export function cacheToolParametersFromMessages(
continue
}
- state.toolParameters.set(part.id.toLowerCase(), {
- tool: part.name,
- parameters: part.input ?? {}
- })
+ state.toolParameters.set(part.id.toLowerCase(), {
+ tool: part.name,
+ parameters: part.input ?? {}
+ })
anthropicCached++
}
}