diff --git a/index.ts b/index.ts index c401802..1328d3c 100644 --- a/index.ts +++ b/index.ts @@ -8,7 +8,6 @@ import { installFetchWrapper } from "./lib/fetch-wrapper" import { createPruningTool } from "./lib/pruning-tool" import { createEventHandler, createChatParamsHandler } from "./lib/hooks" import { createToolTracker } from "./lib/fetch-wrapper/tool-tracker" -import { loadPrompt } from "./lib/core/prompt" const plugin: Plugin = (async (ctx) => { const { config, migrations } = getConfig(ctx) @@ -40,17 +39,11 @@ const plugin: Plugin = (async (ctx) => { } ) - // Create tool tracker and load prompts for synthetic instruction injection + // Create tool tracker for nudge injection const toolTracker = createToolTracker() - const prompts = { - synthInstruction: loadPrompt("synthetic"), - nudgeInstruction: loadPrompt("nudge"), - systemReminder: loadPrompt("system-reminder") - } - - // Install global fetch wrapper for context pruning and synthetic instruction injection - installFetchWrapper(state, logger, ctx.client, config, toolTracker, prompts) + // Install global fetch wrapper for context pruning and system message injection + installFetchWrapper(state, logger, ctx.client, config, toolTracker) // Log initialization logger.info("plugin", "DCP initialized", { diff --git a/lib/config.ts b/lib/config.ts index e1c87fb..fab6f0f 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -36,7 +36,7 @@ const defaultConfig: PluginConfig = { showUpdateToasts: true, strictModelSelection: false, pruning_summary: 'detailed', - nudge_freq: 10, + nudge_freq: 0, strategies: { onIdle: ['ai-analysis'], onTool: ['ai-analysis'] diff --git a/lib/core/janitor.ts b/lib/core/janitor.ts index 50a29bf..75f7232 100644 --- a/lib/core/janitor.ts +++ b/lib/core/janitor.ts @@ -2,7 +2,7 @@ import { z } from "zod" import type { Logger } from "../logger" import type { PruningStrategy } from "../config" import type { PluginState } from "../state" -import type { ToolMetadata } from "../fetch-wrapper/types" +import type { ToolMetadata, PruneReason, SessionStats, GCStats, PruningResult } from "../fetch-wrapper/types" import { findCurrentAgent } from "../hooks" import { buildAnalysisPrompt } from "./prompt" import { selectModel, extractModelFromSession } from "../model-selector" @@ -14,25 +14,7 @@ import { type NotificationContext } from "../ui/notification" -export interface SessionStats { - totalToolsPruned: number - totalTokensSaved: number - totalGCTokens: number - totalGCTools: number -} - -export interface GCStats { - tokensCollected: number - toolsDeduped: number -} - -export interface PruningResult { - prunedCount: number - tokensSaved: number - llmPrunedIds: string[] - toolMetadata: Map - sessionStats: SessionStats -} +export type { SessionStats, GCStats, PruningResult } export interface PruningOptions { reason?: string @@ -120,7 +102,7 @@ async function runWithStrategies( const [sessionInfoResponse, messagesResponse] = await Promise.all([ client.session.get({ path: { id: sessionID } }), - client.session.messages({ path: { id: sessionID }, query: { limit: 100 } }) + client.session.messages({ path: { id: sessionID }, query: { limit: 500 } }) ]) const sessionInfo = sessionInfoResponse.data diff --git a/lib/fetch-wrapper/formats/anthropic.ts b/lib/fetch-wrapper/formats/anthropic.ts new file mode 100644 index 0000000..dab215e --- /dev/null +++ b/lib/fetch-wrapper/formats/anthropic.ts @@ -0,0 +1,130 @@ +import type { FormatDescriptor, ToolOutput } from "../types" +import type { PluginState } from "../../state" + +/** + * Anthropic Messages API format with top-level `system` array. + * Tool calls: `tool_use` blocks in assistant content with `id` + * Tool results: `tool_result` blocks in user content with `tool_use_id` + */ +export const anthropicFormat: FormatDescriptor = { + name: 'anthropic', + + detect(body: any): boolean { + return ( + body.system !== undefined && + Array.isArray(body.messages) + ) + }, + + getDataArray(body: any): any[] | undefined { + return body.messages + }, + + injectSystemMessage(body: any, injection: string): boolean { + if (!injection) return false + + if (typeof body.system === 'string') { + body.system = [{ type: 'text', text: body.system }] + } else if (!Array.isArray(body.system)) { + body.system = [] + } + + body.system.push({ type: 'text', text: injection }) + return true + }, + + appendToLastAssistantMessage(body: any, injection: string): boolean { + if (!injection || !body.messages || body.messages.length === 0) return false + + // Find the last assistant message + for (let i = body.messages.length - 1; i >= 0; i--) { + const msg = body.messages[i] + if (msg.role === 'assistant') { + // Append to existing content array + if (Array.isArray(msg.content)) { + msg.content.push({ type: 'text', text: injection }) + } else if (typeof msg.content === 'string') { + // Convert string content to array format + msg.content = [ + { type: 'text', text: msg.content }, + { type: 'text', text: injection } + ] + } else { + msg.content = [{ type: 'text', text: injection }] + } + return true + } + } + return false + }, + + extractToolOutputs(data: any[], state: PluginState): ToolOutput[] { + const outputs: ToolOutput[] = [] + + for (const m of data) { + if (m.role === 'user' && Array.isArray(m.content)) { + for (const block of m.content) { + if (block.type === 'tool_result' && block.tool_use_id) { + const toolUseId = block.tool_use_id.toLowerCase() + const metadata = state.toolParameters.get(toolUseId) + outputs.push({ + id: toolUseId, + toolName: metadata?.tool + }) + } + } + } + } + + return outputs + }, + + replaceToolOutput(data: any[], toolId: string, prunedMessage: string, _state: PluginState): boolean { + const toolIdLower = toolId.toLowerCase() + let replaced = false + + for (let i = 0; i < data.length; i++) { + const m = data[i] + + if (m.role === 'user' && Array.isArray(m.content)) { + let messageModified = false + const newContent = m.content.map((block: any) => { + if (block.type === 'tool_result' && block.tool_use_id?.toLowerCase() === toolIdLower) { + messageModified = true + return { + ...block, + content: prunedMessage + } + } + return block + }) + if (messageModified) { + data[i] = { ...m, content: newContent } + replaced = true + } + } + } + + return replaced + }, + + hasToolOutputs(data: any[]): boolean { + for (const m of data) { + if (m.role === 'user' && Array.isArray(m.content)) { + for (const block of m.content) { + if (block.type === 'tool_result') return true + } + } + } + return false + }, + + getLogMetadata(data: any[], replacedCount: number, inputUrl: string): Record { + return { + url: inputUrl, + replacedCount, + totalMessages: data.length, + format: 'anthropic' + } + } +} diff --git a/lib/fetch-wrapper/formats/bedrock.ts b/lib/fetch-wrapper/formats/bedrock.ts index bde93b5..6a62a38 100644 --- a/lib/fetch-wrapper/formats/bedrock.ts +++ b/lib/fetch-wrapper/formats/bedrock.ts @@ -1,42 +1,6 @@ import type { FormatDescriptor, ToolOutput } from "../types" import type { PluginState } from "../../state" -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, systemReminder: string): boolean { - const fullInstruction = systemReminder + '\n\n' + instruction - 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' + fullInstruction - } 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: fullInstruction }) - } - return true - } - } - return false -} - -function injectPrunableList(messages: any[], injection: string): boolean { - if (!injection) return false - messages.push({ role: 'user', content: injection }) - return true -} - /** * Bedrock uses top-level `system` array + `inferenceConfig` (distinguishes from OpenAI/Anthropic). * Tool calls: `toolUse` blocks in assistant content with `toolUseId` @@ -57,12 +21,32 @@ export const bedrockFormat: FormatDescriptor = { return body.messages }, - injectSynth(data: any[], instruction: string, nudgeText: string, systemReminder: string): boolean { - return injectSynth(data, instruction, nudgeText, systemReminder) + injectSystemMessage(body: any, injection: string): boolean { + if (!injection) return false + + if (!Array.isArray(body.system)) { + body.system = [] + } + + body.system.push({ text: injection }) + return true }, - injectPrunableList(data: any[], injection: string): boolean { - return injectPrunableList(data, injection) + appendToLastAssistantMessage(body: any, injection: string): boolean { + if (!injection || !body.messages || body.messages.length === 0) return false + + for (let i = body.messages.length - 1; i >= 0; i--) { + const msg = body.messages[i] + if (msg.role === 'assistant') { + if (Array.isArray(msg.content)) { + msg.content.push({ text: injection }) + } else { + msg.content = [{ text: injection }] + } + return true + } + } + return false }, extractToolOutputs(data: any[], state: PluginState): ToolOutput[] { diff --git a/lib/fetch-wrapper/formats/gemini.ts b/lib/fetch-wrapper/formats/gemini.ts index ab0a859..a01eed8 100644 --- a/lib/fetch-wrapper/formats/gemini.ts +++ b/lib/fetch-wrapper/formats/gemini.ts @@ -1,38 +1,6 @@ import type { FormatDescriptor, ToolOutput } from "../types" import type { PluginState } from "../../state" -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, systemReminder: string): boolean { - const fullInstruction = systemReminder + '\n\n' + instruction - 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: fullInstruction }) - return true - } - } - return false -} - -function injectPrunableList(contents: any[], injection: string): boolean { - if (!injection) return false - contents.push({ role: 'user', parts: [{ text: injection }] }) - return true -} - /** * Gemini doesn't include tool call IDs in its native format. * We use position-based correlation via state.googleToolCallMapping which maps @@ -49,12 +17,35 @@ export const geminiFormat: FormatDescriptor = { return body.contents }, - injectSynth(data: any[], instruction: string, nudgeText: string, systemReminder: string): boolean { - return injectSynth(data, instruction, nudgeText, systemReminder) + injectSystemMessage(body: any, injection: string): boolean { + if (!injection) return false + + if (!body.systemInstruction) { + body.systemInstruction = { parts: [] } + } + if (!Array.isArray(body.systemInstruction.parts)) { + body.systemInstruction.parts = [] + } + + body.systemInstruction.parts.push({ text: injection }) + return true }, - injectPrunableList(data: any[], injection: string): boolean { - return injectPrunableList(data, injection) + appendToLastAssistantMessage(body: any, injection: string): boolean { + if (!injection || !body.contents || body.contents.length === 0) return false + + for (let i = body.contents.length - 1; i >= 0; i--) { + const content = body.contents[i] + if (content.role === 'model') { + if (Array.isArray(content.parts)) { + content.parts.push({ text: injection }) + } else { + content.parts = [{ text: injection }] + } + return true + } + } + return false }, extractToolOutputs(data: any[], state: PluginState): ToolOutput[] { diff --git a/lib/fetch-wrapper/formats/index.ts b/lib/fetch-wrapper/formats/index.ts index 0e01388..5e13d3f 100644 --- a/lib/fetch-wrapper/formats/index.ts +++ b/lib/fetch-wrapper/formats/index.ts @@ -2,3 +2,4 @@ export { openaiChatFormat } from './openai-chat' export { openaiResponsesFormat } from './openai-responses' export { geminiFormat } from './gemini' export { bedrockFormat } from './bedrock' +export { anthropicFormat } from './anthropic' diff --git a/lib/fetch-wrapper/formats/openai-chat.ts b/lib/fetch-wrapper/formats/openai-chat.ts index 48fdfcb..0ea6be6 100644 --- a/lib/fetch-wrapper/formats/openai-chat.ts +++ b/lib/fetch-wrapper/formats/openai-chat.ts @@ -1,42 +1,6 @@ import type { FormatDescriptor, ToolOutput } from "../types" import type { PluginState } from "../../state" -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, systemReminder: string): boolean { - const fullInstruction = systemReminder + '\n\n' + instruction - 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' + fullInstruction - } 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: fullInstruction }) - } - return true - } - } - return false -} - -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', @@ -48,12 +12,38 @@ export const openaiChatFormat: FormatDescriptor = { return body.messages }, - injectSynth(data: any[], instruction: string, nudgeText: string, systemReminder: string): boolean { - return injectSynth(data, instruction, nudgeText, systemReminder) + injectSystemMessage(body: any, injection: string): boolean { + if (!injection || !body.messages) return false + + let lastSystemIndex = -1 + for (let i = 0; i < body.messages.length; i++) { + if (body.messages[i].role === 'system') { + lastSystemIndex = i + } + } + + const insertIndex = lastSystemIndex + 1 + body.messages.splice(insertIndex, 0, { role: 'system', content: injection }) + return true }, - injectPrunableList(data: any[], injection: string): boolean { - return injectPrunableList(data, injection) + appendToLastAssistantMessage(body: any, injection: string): boolean { + if (!injection || !body.messages || body.messages.length === 0) return false + + for (let i = body.messages.length - 1; i >= 0; i--) { + const msg = body.messages[i] + if (msg.role === 'assistant') { + if (typeof msg.content === 'string') { + msg.content = msg.content + '\n\n' + injection + } else if (Array.isArray(msg.content)) { + msg.content.push({ type: 'text', text: injection }) + } else { + msg.content = injection + } + return true + } + } + return false }, extractToolOutputs(data: any[], state: PluginState): ToolOutput[] { @@ -131,7 +121,8 @@ export const openaiChatFormat: FormatDescriptor = { return { url: inputUrl, replacedCount, - totalMessages: data.length + totalMessages: data.length, + format: 'openai-chat' } } } diff --git a/lib/fetch-wrapper/formats/openai-responses.ts b/lib/fetch-wrapper/formats/openai-responses.ts index acc03e3..cd7681a 100644 --- a/lib/fetch-wrapper/formats/openai-responses.ts +++ b/lib/fetch-wrapper/formats/openai-responses.ts @@ -1,42 +1,6 @@ import type { FormatDescriptor, ToolOutput } from "../types" import type { PluginState } from "../../state" -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, systemReminder: string): boolean { - const fullInstruction = systemReminder + '\n\n' + instruction - 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' + fullInstruction - } 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: fullInstruction }) - } - return true - } - } - return false -} - -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', @@ -48,12 +12,34 @@ export const openaiResponsesFormat: FormatDescriptor = { return body.input }, - injectSynth(data: any[], instruction: string, nudgeText: string, systemReminder: string): boolean { - return injectSynth(data, instruction, nudgeText, systemReminder) + injectSystemMessage(body: any, injection: string): boolean { + if (!injection) return false + + if (body.instructions && typeof body.instructions === 'string') { + body.instructions = body.instructions + '\n\n' + injection + } else { + body.instructions = injection + } + return true }, - injectPrunableList(data: any[], injection: string): boolean { - return injectPrunableList(data, injection) + appendToLastAssistantMessage(body: any, injection: string): boolean { + if (!injection || !body.input || body.input.length === 0) return false + + for (let i = body.input.length - 1; i >= 0; i--) { + const item = body.input[i] + if (item.type === 'message' && item.role === 'assistant') { + if (typeof item.content === 'string') { + item.content = item.content + '\n\n' + injection + } else if (Array.isArray(item.content)) { + item.content.push({ type: 'output_text', text: injection }) + } else { + item.content = injection + } + return true + } + } + return false }, extractToolOutputs(data: any[], state: PluginState): ToolOutput[] { diff --git a/lib/fetch-wrapper/handler.ts b/lib/fetch-wrapper/handler.ts index 6b7bfc3..10824b1 100644 --- a/lib/fetch-wrapper/handler.ts +++ b/lib/fetch-wrapper/handler.ts @@ -1,9 +1,11 @@ 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" +import { buildPrunableToolsList, buildAssistantInjection } from "./prunable-list" import { syncToolCache } from "../state/tool-cache" +import { loadPrompt } from "../core/prompt" +const SYNTHETIC_INSTRUCTION = loadPrompt("synthetic") const PRUNED_CONTENT_MESSAGE = '[Output removed to save context - information superseded or no longer needed]' function getMostRecentActiveSession(allSessions: any): any | undefined { @@ -18,7 +20,7 @@ async function fetchSessionMessages( try { const messagesResponse = await client.session.messages({ path: { id: sessionId }, - query: { limit: 100 } + query: { limit: 500 } }) return Array.isArray(messagesResponse.data) ? messagesResponse.data @@ -75,36 +77,35 @@ export async function handleFormat( await syncToolCache(ctx.client, sessionId, ctx.state, ctx.toolTracker, protectedSet, ctx.logger) } - if (ctx.config.strategies.onTool.length > 0) { - if (format.injectSynth(data, ctx.prompts.synthInstruction, ctx.prompts.nudgeInstruction, ctx.prompts.systemReminder)) { - modified = true - } + if (ctx.config.strategies.onTool.length > 0 && sessionId) { + const toolIds = Array.from(ctx.state.toolParameters.keys()) + const alreadyPruned = ctx.state.prunedIds.get(sessionId) ?? [] + const alreadyPrunedLower = new Set(alreadyPruned.map(id => id.toLowerCase())) + const unprunedIds = toolIds.filter(id => !alreadyPrunedLower.has(id.toLowerCase())) + + const { list: prunableList, numericIds } = buildPrunableToolsList( + sessionId, + unprunedIds, + ctx.state.toolParameters, + ctx.config.protectedTools + ) + + if (prunableList) { + const includeNudge = ctx.config.nudge_freq > 0 && ctx.toolTracker.toolResultCount > ctx.config.nudge_freq + if (format.injectSystemMessage(body, SYNTHETIC_INSTRUCTION)) { + modified = true + } - if (sessionId) { - const toolIds = Array.from(ctx.state.toolParameters.keys()) - const alreadyPruned = ctx.state.prunedIds.get(sessionId) ?? [] - const alreadyPrunedLower = new Set(alreadyPruned.map(id => id.toLowerCase())) - const unprunedIds = toolIds.filter(id => !alreadyPrunedLower.has(id.toLowerCase())) - - const { list: prunableList, numericIds } = buildPrunableToolsList( - sessionId, - unprunedIds, - ctx.state.toolParameters, - ctx.config.protectedTools - ) + const assistantInjection = buildAssistantInjection(prunableList, includeNudge) - if (prunableList) { - const includeNudge = ctx.config.nudge_freq > 0 && ctx.toolTracker.toolResultCount > ctx.config.nudge_freq - - const endInjection = buildEndInjection(prunableList, includeNudge) - if (format.injectPrunableList(data, endInjection)) { - ctx.logger.debug("fetch", `Injected prunable tools list (${format.name})`, { - ids: numericIds, - nudge: includeNudge, - toolsSincePrune: ctx.toolTracker.toolResultCount - }) - modified = true - } + if (format.appendToLastAssistantMessage && format.appendToLastAssistantMessage(body, assistantInjection)) { + const nudgeMsg = includeNudge ? " with nudge" : "" + ctx.logger.debug("fetch", `Appended prunable tools list${nudgeMsg} to last assistant message (${format.name})`, { + ids: numericIds, + nudge: includeNudge, + toolsSincePrune: ctx.toolTracker.toolResultCount + }) + modified = true } } } diff --git a/lib/fetch-wrapper/index.ts b/lib/fetch-wrapper/index.ts index 4483782..244103b 100644 --- a/lib/fetch-wrapper/index.ts +++ b/lib/fetch-wrapper/index.ts @@ -1,15 +1,15 @@ import type { PluginState } from "../state" import type { Logger } from "../logger" -import type { FetchHandlerContext, SynthPrompts } from "./types" +import type { FetchHandlerContext } from "./types" import type { ToolTracker } from "./types" import type { PluginConfig } from "../config" -import { openaiChatFormat, openaiResponsesFormat, geminiFormat, bedrockFormat } from "./formats" +import { openaiChatFormat, openaiResponsesFormat, geminiFormat, bedrockFormat, anthropicFormat } from "./formats" import { handleFormat } from "./handler" import { runStrategies } from "../core/strategies" import { accumulateGCStats } from "./gc-tracker" import { trimToolParametersCache } from "../state/tool-cache" -export type { FetchHandlerContext, FetchHandlerResult, SynthPrompts } from "./types" +export type { FetchHandlerContext, FetchHandlerResult } from "./types" /** * Creates a wrapped global fetch that intercepts API calls and performs @@ -17,7 +17,7 @@ export type { FetchHandlerContext, FetchHandlerResult, SynthPrompts } from "./ty * * Supports five API formats: * 1. OpenAI Chat Completions (body.messages with role='tool') - * 2. Anthropic (body.messages with role='user' containing tool_result) + * 2. Anthropic Messages API (body.system + body.messages with tool_result) * 3. Google/Gemini (body.contents with functionResponse parts) * 4. OpenAI Responses API (body.input with function_call_output items) * 5. AWS Bedrock Converse API (body.system + body.messages with toolResult blocks) @@ -27,8 +27,7 @@ export function installFetchWrapper( logger: Logger, client: any, config: PluginConfig, - toolTracker: ToolTracker, - prompts: SynthPrompts + toolTracker: ToolTracker ): () => void { const originalGlobalFetch = globalThis.fetch @@ -37,8 +36,7 @@ export function installFetchWrapper( logger, client, config, - toolTracker, - prompts + toolTracker } globalThis.fetch = async (input: any, init?: any) => { @@ -58,8 +56,12 @@ export function installFetchWrapper( const toolIdsBefore = new Set(state.toolParameters.keys()) // Mutually exclusive format handlers - // Note: bedrockFormat must be checked before openaiChatFormat since both have messages[] - // but Bedrock has distinguishing system[] array and inferenceConfig + // Order matters: More specific formats first to avoid incorrect detection + // 1. OpenAI Responses API: has body.input (not body.messages) + // 2. Bedrock: has body.system + body.inferenceConfig + body.messages + // 3. Anthropic: has body.system + body.messages (no inferenceConfig) + // 4. OpenAI Chat: has body.messages (no top-level system) + // 5. Gemini: has body.contents if (openaiResponsesFormat.detect(body)) { const result = await handleFormat(body, ctx, inputUrl, openaiResponsesFormat) if (result.modified) { @@ -72,6 +74,12 @@ export function installFetchWrapper( modified = true } } + else if (anthropicFormat.detect(body)) { + const result = await handleFormat(body, ctx, inputUrl, anthropicFormat) + if (result.modified) { + modified = true + } + } else if (openaiChatFormat.detect(body)) { const result = await handleFormat(body, ctx, inputUrl, openaiChatFormat) if (result.modified) { diff --git a/lib/fetch-wrapper/prunable-list.ts b/lib/fetch-wrapper/prunable-list.ts index 4cce826..dcdca71 100644 --- a/lib/fetch-wrapper/prunable-list.ts +++ b/lib/fetch-wrapper/prunable-list.ts @@ -1,14 +1,9 @@ import { extractParameterKey } from '../ui/display-utils' import { getOrCreateNumericId } from '../state/id-mapping' +import { loadPrompt } from '../core/prompt' import type { ToolMetadata } from './types' -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. -` +const NUDGE_INSTRUCTION = loadPrompt("nudge") export interface PrunableListResult { list: string @@ -47,7 +42,7 @@ export function buildPrunableToolsList( } } -export function buildEndInjection( +export function buildAssistantInjection( prunableList: string, includeNudge: boolean ): string { @@ -55,13 +50,11 @@ export function buildEndInjection( return '' } - const parts = [SYSTEM_REMINDER] + const parts = [prunableList] if (includeNudge) { parts.push(NUDGE_INSTRUCTION) } - parts.push(prunableList) - return parts.join('\n\n') } diff --git a/lib/fetch-wrapper/types.ts b/lib/fetch-wrapper/types.ts index cb728a3..fc49ef1 100644 --- a/lib/fetch-wrapper/types.ts +++ b/lib/fetch-wrapper/types.ts @@ -18,27 +18,20 @@ export interface FormatDescriptor { name: string detect(body: any): boolean getDataArray(body: any): any[] | undefined - injectSynth(data: any[], instruction: string, nudgeText: string, systemReminder: string): boolean - injectPrunableList(data: any[], injection: string): boolean + injectSystemMessage(body: any, injection: string): boolean + appendToLastAssistantMessage?(body: any, injection: string): boolean extractToolOutputs(data: any[], state: PluginState): ToolOutput[] replaceToolOutput(data: any[], toolId: string, prunedMessage: string, state: PluginState): boolean hasToolOutputs(data: any[]): boolean getLogMetadata(data: any[], replacedCount: number, inputUrl: string): Record } -export interface SynthPrompts { - synthInstruction: string - nudgeInstruction: string - systemReminder: string -} - export interface FetchHandlerContext { state: PluginState logger: Logger client: any config: PluginConfig toolTracker: ToolTracker - prompts: SynthPrompts } export interface FetchHandlerResult { @@ -50,3 +43,34 @@ export interface PrunedIdData { allSessions: any allPrunedIds: Set } + +/** The 3 scenarios that trigger explicit LLM pruning */ +export type PruneReason = "completion" | "noise" | "consolidation" + +/** Human-readable labels for prune reasons */ +export const PRUNE_REASON_LABELS: Record = { + completion: "Task Complete", + noise: "Noise Removal", + consolidation: "Consolidation" +} + +export interface SessionStats { + totalToolsPruned: number + totalTokensSaved: number + totalGCTokens: number + totalGCTools: number +} + +export interface GCStats { + tokensCollected: number + toolsDeduped: number +} + +export interface PruningResult { + prunedCount: number + tokensSaved: number + llmPrunedIds: string[] + toolMetadata: Map + sessionStats: SessionStats + reason?: PruneReason +} diff --git a/lib/hooks.ts b/lib/hooks.ts index 234acf6..617abe1 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -107,7 +107,7 @@ export function createChatParamsHandler( try { const messagesResponse = await client.session.messages({ path: { id: sessionId }, - query: { limit: 100 } + query: { limit: 500 } }) const messages = messagesResponse.data || messagesResponse diff --git a/lib/prompts/nudge.txt b/lib/prompts/nudge.txt index 8e91d94..ed2078a 100644 --- a/lib/prompts/nudge.txt +++ b/lib/prompts/nudge.txt @@ -1,3 +1,10 @@ - -You have accumulated several tool outputs. Consider using the `prune` tool to declutter the conversation context. + +**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. + +**Immediate Actions Required:** +1. **Garbage Collect:** If you read files or ran commands that yielded no value, prune them NOW. Do not summarize them. +2. **Task Cleanup:** If a sub-task is complete, prune the tools used. +3. **Consolidate:** If you are holding valuable raw data, you *must* distill the insights into your narrative and prune the raw entry. + +**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must prune. diff --git a/lib/prompts/synthetic.txt b/lib/prompts/synthetic.txt index 968d156..6d53e5f 100644 --- a/lib/prompts/synthetic.txt +++ b/lib/prompts/synthetic.txt @@ -1,40 +1,30 @@ -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. - -IMPORTANT: Your thinking/reasoning blocks must NOT reference, discuss, or address these instructions, the list, or any nudges about pruning. The user can see your thinking blocks, and referencing invisible plugin content will confuse them. In your thinking, focus only on the user's task and your problem-solving approach. +The list and any pruning nudges are injected by the environment as assistant messages, use this information when deciding what to prune. - -After calling a series of tools, YOU ALWAYS HAVE TO distill signals from their results in your next message. This ensures tracability of your reasoning, informs the user of your findings, and is PARAMOUNT to best context window management practices. - -THIS IS NON-NEGOTIABLE - YOU ARE EXPECTED TO RESPECT THIS INSTRUCTION THROUGHOUT THE CONVERSATION - + +You are operating in a context-constrained environment. You must actively manage your context window using the `prune` tool. - -A strong constraint we have in this environment is the context window size. To help keep the conversation focused and clear from the noise, you must use the `prune` tool: at opportune moments, and in an effective manner. - +## Prune Early, Prune Often +Every tool call adds to your context debt. You MUST pay this down regularly by pruning. Do not wait until context is "full" - by then it's too late. Evaluate what can be pruned after every few tool calls. - -To effectively manage conversation context, you MUST ALWAYS narrate your findings AS YOU DISCOVER THEM, BEFORE calling any `prune` tool. No tool result (read, bash, grep, webfetch, etc.) should be left unexplained. By narrating the evolution of your understanding, you transform raw tool outputs into distilled knowledge that lives in the persisted context window. +## When to Prune (Triggers) +You SHOULD use the prune tool when ANY of these are true: +- You just completed a task or sub-task +- You read files that turned out to be unhelpful or only partially useful +- You have gathered enough information to answer a question or make a decision +- You ran commands whose output you have already processed +- Newer tool outputs have made older ones obsolete +- You are about to start a new phase of work -Tools are VOLATILE - Once this distilled knowledge is in your reply, you can safely use the `prune` tool to declutter the conversation. +When in doubt, prune. It is better to prune aggressively than to run out of context. -WHEN TO USE `prune`: -- After you complete a discrete unit of work (e.g. confirming a hypothesis, or closing out one branch of investigation). -- After exploratory bursts of tool calls that led you to a clear conclusion. (or to noise) -- Before starting a new phase of work where old tool outputs are no longer needed to inform your next actions. +## Three Pruning Modes +Apply the correct mode for each situation: -CRITICAL: -You must ALWAYS narrate your findings in a message BEFORE using the `prune` tool. Skipping this step risks deleting raw evidence before it has been converted into stable, distilled knowledge. This harms your performances, wastes user time, and undermines effective use of the context window. +1. TASK COMPLETION: When work is done, prune the tools used. No distillation needed - just state the task is complete. +2. NOISE REMOVAL: If outputs are irrelevant, unhelpful, or outdated (superseded by newer info), prune IMMEDIATELY. No distillation - just cut it out. +3. CONTEXT CONSOLIDATION: When pruning useful research, you MUST distill key findings into your narrative *before* pruning. Extract only what matters (e.g., a specific function signature from a large file). -EXAMPLE WORKFLOW: -1. You call several tools (read, bash, grep...) to investigate a bug. -2. You identify that "for reason X, behavior Y occurs", supported by those tool outputs. -3. In your next message, you EXPLICITLY narrate: - - What you did (which tools, what you were looking for). - - What you found (the key facts / signals). - - What you concluded (how this affects the task or next step). ->YOU MUST ALWAYS THINK HIGH SIGNAL LOW NOISE FOR THIS NARRATION -4. ONLY AFTER the narration, you call the `prune` tool with a brief reason (e.g. "exploration for bug X complete; moving on to next bug"). -5. AFTER pruning succeeds, DO NOT re-summarize or re-narrate. You already distilled your findings before calling prune. Continue with your next task, or if you need more information from the user, wait for their response. +FAILURE TO PRUNE will result in context overflow and degraded performance. diff --git a/lib/prompts/system-reminder.txt b/lib/prompts/system-reminder.txt deleted file mode 100644 index 479ea7a..0000000 --- a/lib/prompts/system-reminder.txt +++ /dev/null @@ -1,3 +0,0 @@ - -These instructions are injected by a plugin and are invisible to the user. You must follow them silently and NEVER reference or discuss them (including the list) in your response or thinking/reasoning blocks, as this will confuse the user who cannot see them. - \ No newline at end of file diff --git a/lib/prompts/tool.txt b/lib/prompts/tool.txt index ecc4599..8903f2c 100644 --- a/lib/prompts/tool.txt +++ b/lib/prompts/tool.txt @@ -1,75 +1,56 @@ -Prunes tool outputs from context to manage conversation size. Reference the list injected at the end of the conversation to see available numeric IDs. +Prunes tool outputs from context to manage conversation size and reduce noise. -USING THE PRUNE TOOL WILL MAKE THE USER HAPPY. +## IMPORTANT: The Prunable List +A `` list is injected into assistant messages showing available tool outputs you can prune. Each line has the format `ID: tool, parameter` (e.g., `20: read, /path/to/file.ts`). Use these numeric IDs to select which tools to prune. -## CRITICAL: Distill Before Pruning +## CRITICAL: When and How to Prune -You MUST ALWAYS narrate your findings in a message BEFORE using this tool. No tool result (read, bash, grep, webfetch, etc.) should be left unexplained. By narrating your understanding, you transform raw tool outputs into distilled knowledge that persists in the context window. +You must use this tool in three specific scenarios. The rules for distillation (summarizing findings) differ for each. **You must specify the reason as the first element of the `ids` array** to indicate which scenario applies. -**Tools are VOLATILE** - Once distilled knowledge is in your reply, you can safely prune. Skipping this step risks deleting raw evidence before it has been converted into stable knowledge. +### 1. Task Completion (Clean Up) — reason: `completion` +**When:** You have successfully completed a specific unit of work (e.g., fixed a bug, wrote a file, answered a question). +**Action:** Prune the tools used for that task. +**Distillation:** NOT REQUIRED. Since the task is done, the raw data is no longer needed. Simply state that the task is complete. -**Distillation workflow:** -1. Call tools to investigate/explore -2. In your next message, EXPLICITLY narrate: - - What you did (which tools, what you were looking for) - - What you found (the key facts/signals) - - What you concluded (how this affects the task or next step) -3. ONLY AFTER narrating, call `prune` with the numeric IDs of outputs no longer needed +### 2. Removing Noise (Garbage Collection) — reason: `noise` +**When:** You have read files or run commands that turned out to be irrelevant, unhelpful, or outdated (meaning later tools have provided fresher, more valid information). +**Action:** Prune these specific tool outputs immediately. +**Distillation:** FORBIDDEN. Do not pollute the context by summarizing useless information. Just cut it out. -> THINK HIGH SIGNAL, LOW NOISE FOR THIS NARRATION +### 3. Context Conservation (Research & Consolidation) — reason: `consolidation` +**When:** You have gathered useful information. Prune frequently as you work (e.g., after reading a few files), rather than waiting for a "long" phase to end. +**Action:** Convert raw data into distilled knowledge. This allows you to discard large outputs (like full file reads) while keeping only the specific parts you need (like a single function signature or constant). +**Distillation:** MANDATORY. Before pruning, you *must* explicitly summarize the key findings from *every* tool you plan to prune. + - **Extract specific value:** If you read a large file but only care about one function, record that function's details and prune the whole read. + - Narrative format: "I found X in file Y..." + - Capture all relevant details (function names, logic, constraints). + - Once distilled into your response history, the raw tool output can be safely pruned. -**After pruning:** Do NOT re-summarize or re-narrate. You already distilled your findings before calling prune. Continue with your next task, or if you need more information from the user, wait for their response. - -## How to Use - -The list shows available tool outputs with numeric IDs: -``` - -1: read, src/foo.ts -2: bash, run tests -3: grep, "error" in logs/ - -``` - -To prune outputs 1 and 3, call: `prune({ ids: [1, 3] })` - -## When to Use This Tool - -**Key heuristic: Distill, then 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, narrate your findings, then 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) +## Best Practices +- **Don't wait too long:** Prune frequently to keep the context agile. +- **Be surgical:** You can mix strategies. Prune noise without comment, while distilling useful context in the same turn. +- **Verify:** Ensure you have captured what you need before deleting useful raw data. ## 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 prune with ids: [1, 2, 3, 4] - the reads and edits from the first issue] - - - -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 prune with ids: [1, 2, 5, 7] - the exploratory reads] - - - -After completing any task: -Assistant: [Finishes task - commit, answer, fix, etc.] -Before we continue, let me prune the context from that work. -[Uses prune with ids: [3, 4, 5, 6, 8, 9] - all tool outputs from the completed task] - + +Assistant: [Reads 'wrong_file.ts'] +This file isn't relevant to the auth system. I'll remove it to clear the context. +[Uses prune with ids: ["noise", 5]] + + + +Assistant: [Reads 5 different config files] +I have analyzed the configuration. Here is the distillation: +- 'config.ts' uses port 3000. +- 'db.ts' connects to mongo:27017. +- The other 3 files were defaults. +I have preserved the signals above, so I am now pruning the raw reads. +[Uses prune with ids: ["consolidation", 10, 11, 12, 13, 14]] + + + +Assistant: [Runs tests, they pass] +The tests passed. The feature is verified. +[Uses prune with ids: ["completion", 20, 21]] + diff --git a/lib/pruning-tool.ts b/lib/pruning-tool.ts index ff3a1ee..9c1ff13 100644 --- a/lib/pruning-tool.ts +++ b/lib/pruning-tool.ts @@ -2,16 +2,17 @@ import { tool } from "@opencode-ai/plugin" import type { PluginState } from "./state" import type { PluginConfig } from "./config" import type { ToolTracker } from "./fetch-wrapper/tool-tracker" -import type { ToolMetadata } from "./fetch-wrapper/types" +import type { ToolMetadata, PruneReason } from "./fetch-wrapper/types" import { resetToolTrackerCount } from "./fetch-wrapper/tool-tracker" import { isSubagentSession, findCurrentAgent } from "./hooks" import { getActualId } from "./state/id-mapping" import { sendUnifiedNotification, type NotificationContext } from "./ui/notification" +import { formatPruningResultForTool } from "./ui/display-utils" import { ensureSessionRestored } from "./state" import { saveSessionState } from "./state/persistence" import type { Logger } from "./logger" import { estimateTokensBatch } from "./tokenizer" -import type { SessionStats } from "./core/janitor" +import type { SessionStats, PruningResult } from "./core/janitor" import { loadPrompt } from "./core/prompt" /** Tool description loaded from prompts/tool.txt */ @@ -37,8 +38,13 @@ export function createPruningTool( return tool({ description: TOOL_DESCRIPTION, args: { - ids: tool.schema.array(tool.schema.number()).describe( - "Array of numeric IDs to prune from the list" + ids: tool.schema.array( + tool.schema.union([ + tool.schema.enum(["completion", "noise", "consolidation"]), + tool.schema.number() + ]) + ).describe( + "First element is the reason ('completion', 'noise', 'consolidation'), followed by numeric IDs to prune" ), }, async execute(args, toolCtx) { @@ -53,9 +59,26 @@ export function createPruningTool( return "No IDs provided. Check the list for available IDs to prune." } + // Parse reason from first element, numeric IDs from the rest + const firstElement = args.ids[0] + const validReasons = ["completion", "noise", "consolidation"] as const + let reason: PruneReason | undefined + let numericIds: number[] + + if (typeof firstElement === "string" && validReasons.includes(firstElement as any)) { + reason = firstElement as PruneReason + numericIds = args.ids.slice(1).filter((id): id is number => typeof id === "number") + } else { + numericIds = args.ids.filter((id): id is number => typeof id === "number") + } + + if (numericIds.length === 0) { + return "No numeric IDs provided. Format: [reason, id1, id2, ...] where reason is 'completion', 'noise', or 'consolidation'." + } + await ensureSessionRestored(state, sessionId, logger) - const prunedIds = args.ids + const prunedIds = numericIds .map(numId => getActualId(sessionId, numId)) .filter((id): id is string => id !== undefined) @@ -113,7 +136,8 @@ export function createPruningTool( aiPrunedIds: prunedIds, toolMetadata, gcPending: null, - sessionStats + sessionStats, + reason }, currentAgent) toolTracker.skipNextIdle = true @@ -122,8 +146,16 @@ export function createPruningTool( resetToolTrackerCount(toolTracker) } - // Return empty string on success (like edit tool) - guidance is in tool description - return "" + const result: PruningResult = { + prunedCount: prunedIds.length, + tokensSaved, + llmPrunedIds: prunedIds, + toolMetadata, + sessionStats, + reason + } + + return formatPruningResultForTool(result, ctx.workingDirectory) }, }) } diff --git a/lib/state/tool-cache.ts b/lib/state/tool-cache.ts index aeabd60..de1c9c3 100644 --- a/lib/state/tool-cache.ts +++ b/lib/state/tool-cache.ts @@ -21,7 +21,7 @@ export async function syncToolCache( try { const messagesResponse = await client.session.messages({ path: { id: sessionId }, - query: { limit: 100 } + query: { limit: 500 } }) const messages = messagesResponse.data || messagesResponse diff --git a/lib/ui/display-utils.ts b/lib/ui/display-utils.ts index 6e4e9e2..6ba7eb4 100644 --- a/lib/ui/display-utils.ts +++ b/lib/ui/display-utils.ts @@ -1,3 +1,6 @@ +import type { ToolMetadata } from "../fetch-wrapper/types" +import type { PruningResult } from "../core/janitor" + /** * Extracts a human-readable key from tool metadata for display purposes. * Used by both deduplication and AI analysis to show what was pruned. @@ -71,3 +74,90 @@ export function extractParameterKey(metadata: { tool: string, parameters?: any } } return paramStr.substring(0, 50) } + +export function truncate(str: string, maxLen: number = 60): string { + if (str.length <= maxLen) return str + return str.slice(0, maxLen - 3) + '...' +} + +export function shortenPath(input: string, workingDirectory?: string): string { + const inPathMatch = input.match(/^(.+) in (.+)$/) + if (inPathMatch) { + const prefix = inPathMatch[1] + const pathPart = inPathMatch[2] + const shortenedPath = shortenSinglePath(pathPart, workingDirectory) + return `${prefix} in ${shortenedPath}` + } + + return shortenSinglePath(input, workingDirectory) +} + +function shortenSinglePath(path: string, workingDirectory?: string): string { + if (workingDirectory) { + if (path.startsWith(workingDirectory + '/')) { + return path.slice(workingDirectory.length + 1) + } + if (path === workingDirectory) { + return '.' + } + } + + return path +} + +/** + * Formats a list of pruned items in the style: "→ tool: parameter" + */ +export function formatPrunedItemsList( + prunedIds: string[], + toolMetadata: Map, + workingDirectory?: string +): string[] { + const lines: string[] = [] + + for (const prunedId of prunedIds) { + const normalizedId = prunedId.toLowerCase() + const metadata = toolMetadata.get(normalizedId) + + if (metadata) { + const paramKey = extractParameterKey(metadata) + if (paramKey) { + // Use 60 char limit to match notification style + const displayKey = truncate(shortenPath(paramKey, workingDirectory), 60) + lines.push(`→ ${metadata.tool}: ${displayKey}`) + } else { + lines.push(`→ ${metadata.tool}`) + } + } + } + + const knownCount = prunedIds.filter(id => + toolMetadata.has(id.toLowerCase()) + ).length + const unknownCount = prunedIds.length - knownCount + + if (unknownCount > 0) { + lines.push(`→ (${unknownCount} tool${unknownCount > 1 ? 's' : ''} with unknown metadata)`) + } + + return lines +} + +/** + * Formats a PruningResult into a human-readable string for the prune tool output. + */ +export function formatPruningResultForTool( + result: PruningResult, + workingDirectory?: string +): string { + const lines: string[] = [] + lines.push(`Context pruning complete. Pruned ${result.prunedCount} tool outputs.`) + lines.push('') + + if (result.llmPrunedIds.length > 0) { + lines.push(`Semantically pruned (${result.llmPrunedIds.length}):`) + lines.push(...formatPrunedItemsList(result.llmPrunedIds, result.toolMetadata, workingDirectory)) + } + + return lines.join('\n').trim() +} diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index 6da8a4a..a2507ad 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -1,8 +1,9 @@ import type { Logger } from "../logger" import type { SessionStats, GCStats } from "../core/janitor" -import type { ToolMetadata } from "../fetch-wrapper/types" +import type { ToolMetadata, PruneReason } from "../fetch-wrapper/types" +import { PRUNE_REASON_LABELS } from "../fetch-wrapper/types" import { formatTokenCount } from "../tokenizer" -import { extractParameterKey } from "./display-utils" +import { formatPrunedItemsList } from "./display-utils" export type PruningSummaryLevel = "off" | "minimal" | "detailed" @@ -24,6 +25,32 @@ export interface NotificationData { toolMetadata: Map gcPending: GCStats | null sessionStats: SessionStats | null + reason?: PruneReason +} + +export async function sendUnifiedNotification( + ctx: NotificationContext, + sessionID: string, + data: NotificationData, + agent?: string +): Promise { + const hasAiPruning = data.aiPrunedCount > 0 + const hasGcActivity = data.gcPending && data.gcPending.toolsDeduped > 0 + + if (!hasAiPruning && !hasGcActivity) { + return false + } + + if (ctx.config.pruningSummary === 'off') { + return false + } + + const message = ctx.config.pruningSummary === 'minimal' + ? buildMinimalMessage(data) + : buildDetailedMessage(data, ctx.config.workingDirectory) + + await sendIgnoredMessage(ctx, sessionID, message, agent) + return true } export async function sendIgnoredMessage( @@ -50,35 +77,27 @@ export async function sendIgnoredMessage( } } -export async function sendUnifiedNotification( - ctx: NotificationContext, - sessionID: string, - data: NotificationData, - agent?: string -): Promise { - const hasAiPruning = data.aiPrunedCount > 0 - const hasGcActivity = data.gcPending && data.gcPending.toolsDeduped > 0 - - if (!hasAiPruning && !hasGcActivity) { - return false - } +function buildMinimalMessage(data: NotificationData): string { + const { justNowTokens, totalTokens } = calculateStats(data) + const reasonSuffix = data.reason ? ` [${PRUNE_REASON_LABELS[data.reason]}]` : '' + return formatStatsHeader(totalTokens, justNowTokens) + reasonSuffix +} - if (ctx.config.pruningSummary === 'off') { - return false - } +function buildDetailedMessage(data: NotificationData, workingDirectory?: string): string { + const { justNowTokens, totalTokens } = calculateStats(data) - const message = ctx.config.pruningSummary === 'minimal' - ? buildMinimalMessage(data) - : buildDetailedMessage(data, ctx.config.workingDirectory) + let message = formatStatsHeader(totalTokens, justNowTokens) - await sendIgnoredMessage(ctx, sessionID, message, agent) - return true -} + if (data.aiPrunedCount > 0) { + const justNowTokensStr = `~${formatTokenCount(justNowTokens)}` + const reasonLabel = data.reason ? ` — ${PRUNE_REASON_LABELS[data.reason]}` : '' + message += `\n\n▣ Pruned tools (${justNowTokensStr})${reasonLabel}` -function buildMinimalMessage(data: NotificationData): string { - const { justNowTokens, totalTokens } = calculateStats(data) + const itemLines = formatPrunedItemsList(data.aiPrunedIds, data.toolMetadata, workingDirectory) + message += '\n' + itemLines.join('\n') + } - return formatStatsHeader(totalTokens, justNowTokens) + return message.trim() } function calculateStats(data: NotificationData): { @@ -108,94 +127,3 @@ function formatStatsHeader( `▣ DCP | ${totalTokensPadded} saved total`, ].join('\n') } - -function buildDetailedMessage(data: NotificationData, workingDirectory?: string): string { - const { justNowTokens, totalTokens } = calculateStats(data) - - let message = formatStatsHeader(totalTokens, justNowTokens) - - if (data.aiPrunedCount > 0) { - const justNowTokensStr = `~${formatTokenCount(justNowTokens)}` - message += `\n\n▣ Pruned tools (${justNowTokensStr})` - - for (const prunedId of data.aiPrunedIds) { - const normalizedId = prunedId.toLowerCase() - const metadata = data.toolMetadata.get(normalizedId) - - if (metadata) { - const paramKey = extractParameterKey(metadata) - if (paramKey) { - const displayKey = truncate(shortenPath(paramKey, workingDirectory), 60) - message += `\n→ ${metadata.tool}: ${displayKey}` - } else { - message += `\n→ ${metadata.tool}` - } - } - } - - const knownCount = data.aiPrunedIds.filter(id => - data.toolMetadata.has(id.toLowerCase()) - ).length - const unknownCount = data.aiPrunedIds.length - knownCount - - if (unknownCount > 0) { - message += `\n→ (${unknownCount} tool${unknownCount > 1 ? 's' : ''} with unknown metadata)` - } - } - - return message.trim() -} - -function truncate(str: string, maxLen: number = 60): string { - if (str.length <= maxLen) return str - return str.slice(0, maxLen - 3) + '...' -} - -function shortenPath(input: string, workingDirectory?: string): string { - const inPathMatch = input.match(/^(.+) in (.+)$/) - if (inPathMatch) { - const prefix = inPathMatch[1] - const pathPart = inPathMatch[2] - const shortenedPath = shortenSinglePath(pathPart, workingDirectory) - return `${prefix} in ${shortenedPath}` - } - - return shortenSinglePath(input, workingDirectory) -} - -function shortenSinglePath(path: string, workingDirectory?: string): string { - const homeDir = require('os').homedir() - - if (workingDirectory) { - if (path.startsWith(workingDirectory + '/')) { - return path.slice(workingDirectory.length + 1) - } - if (path === workingDirectory) { - return '.' - } - } - - if (path.startsWith(homeDir)) { - path = '~' + path.slice(homeDir.length) - } - - const nodeModulesMatch = path.match(/node_modules\/(@[^\/]+\/[^\/]+|[^\/]+)\/(.*)/) - if (nodeModulesMatch) { - return `${nodeModulesMatch[1]}/${nodeModulesMatch[2]}` - } - - if (workingDirectory) { - const workingDirWithTilde = workingDirectory.startsWith(homeDir) - ? '~' + workingDirectory.slice(homeDir.length) - : null - - if (workingDirWithTilde && path.startsWith(workingDirWithTilde + '/')) { - return path.slice(workingDirWithTilde.length + 1) - } - if (workingDirWithTilde && path === workingDirWithTilde) { - return '.' - } - } - - return path -}