From 74431b80531fec4b916a1a7f42f973efb3c0419b Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 2 Dec 2025 02:11:01 -0500 Subject: [PATCH 01/11] refactor: convert Janitor class and notifications to functional components - Extract notification logic from Janitor into lib/notification.ts - Convert Janitor class to pure functions with JanitorContext - Extract message parsing and LLM analysis into separate functions - Rename notification functions to match config (sendPruningSummary, sendMinimalSummary, sendDetailedSummary) - Update callers (index.ts, hooks.ts, pruning-tool.ts) to use functional API --- index.ts | 22 +- lib/hooks.ts | 7 +- lib/janitor.ts | 983 +++++++++++++++++--------------------------- lib/notification.ts | 271 ++++++++++++ lib/pruning-tool.ts | 11 +- 5 files changed, 660 insertions(+), 634 deletions(-) create mode 100644 lib/notification.ts diff --git a/index.ts b/index.ts index d7c06ba6..da77e422 100644 --- a/index.ts +++ b/index.ts @@ -1,7 +1,7 @@ import type { Plugin } from "@opencode-ai/plugin" import { getConfig } from "./lib/config" import { Logger } from "./lib/logger" -import { Janitor } from "./lib/janitor" +import { createJanitorContext } from "./lib/janitor" import { checkForUpdates } from "./lib/version-checker" import { createPluginState } from "./lib/state" import { installFetchWrapper } from "./lib/fetch-wrapper" @@ -26,16 +26,18 @@ const plugin: Plugin = (async (ctx) => { const logger = new Logger(config.debug) const state = createPluginState() - const janitor = new Janitor( + const janitorCtx = createJanitorContext( ctx.client, state, logger, - config.protectedTools, - config.model, - config.showModelErrorToasts, - config.strictModelSelection, - config.pruning_summary, - ctx.directory + { + protectedTools: config.protectedTools, + model: config.model, + showModelErrorToasts: config.showModelErrorToasts ?? true, + strictModelSelection: config.strictModelSelection ?? false, + pruningSummary: config.pruning_summary, + workingDirectory: ctx.directory + } ) // Create tool tracker and load prompts for synthetic instruction injection @@ -85,10 +87,10 @@ const plugin: Plugin = (async (ctx) => { } return { - event: createEventHandler(ctx.client, janitor, logger, config, toolTracker), + event: createEventHandler(ctx.client, janitorCtx, logger, config, toolTracker), "chat.params": createChatParamsHandler(ctx.client, state, logger), tool: config.strategies.onTool.length > 0 ? { - prune: createPruningTool(ctx.client, janitor, config, toolTracker), + prune: createPruningTool(ctx.client, janitorCtx, config, toolTracker), } : undefined, } }) satisfies Plugin diff --git a/lib/hooks.ts b/lib/hooks.ts index d6d18348..ae9110f3 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -1,6 +1,7 @@ import type { PluginState } from "./state" import type { Logger } from "./logger" -import type { Janitor } from "./janitor" +import type { JanitorContext } from "./janitor" +import { runOnIdle } from "./janitor" import type { PluginConfig, PruningStrategy } from "./config" import type { ToolTracker } from "./synth-instruction" import { resetToolTrackerCount } from "./synth-instruction" @@ -20,7 +21,7 @@ function toolStrategiesCoveredByIdle(onIdle: PruningStrategy[], onTool: PruningS export function createEventHandler( client: any, - janitor: Janitor, + janitorCtx: JanitorContext, logger: Logger, config: PluginConfig, toolTracker?: ToolTracker @@ -40,7 +41,7 @@ export function createEventHandler( } try { - const result = await janitor.runOnIdle(event.properties.sessionID, config.strategies.onIdle) + const result = await runOnIdle(janitorCtx, event.properties.sessionID, config.strategies.onIdle) // Reset nudge counter if idle pruning succeeded and covers tool strategies if (result && result.prunedCount > 0 && toolTracker && config.nudge_freq > 0) { diff --git a/lib/janitor.ts b/lib/janitor.ts index 643beaea..7a07ff1c 100644 --- a/lib/janitor.ts +++ b/lib/janitor.ts @@ -5,10 +5,16 @@ import type { PluginState } from "./state" import { buildAnalysisPrompt } from "./prompt" import { selectModel, extractModelFromSession } from "./model-selector" import { estimateTokensBatch, formatTokenCount } from "./tokenizer" -import { detectDuplicates } from "./deduplicator" -import { extractParameterKey } from "./display-utils" import { saveSessionState } from "./state-persistence" import { ensureSessionRestored } from "./state" +import { + sendPruningSummary, + type NotificationContext +} from "./notification" + +// ============================================================================ +// Types +// ============================================================================ export interface SessionStats { totalToolsPruned: number @@ -18,10 +24,7 @@ export interface SessionStats { export interface PruningResult { prunedCount: number tokensSaved: number - thinkingIds: string[] - deduplicatedIds: string[] llmPrunedIds: string[] - deduplicationDetails: Map toolMetadata: Map sessionStats: SessionStats } @@ -31,690 +34,436 @@ export interface PruningOptions { trigger: 'idle' | 'tool' } -export class Janitor { - private prunedIdsState: Map - private statsState: Map - private toolParametersCache: Map - private modelCache: Map - - constructor( - private client: any, - private state: PluginState, - private logger: Logger, - private protectedTools: string[], - private configModel?: string, - private showModelErrorToasts: boolean = true, - private strictModelSelection: boolean = false, - private pruningSummary: "off" | "minimal" | "detailed" = "detailed", - private workingDirectory?: string - ) { - // Bind state references for convenience - this.prunedIdsState = state.prunedIds - this.statsState = state.stats - this.toolParametersCache = state.toolParameters - this.modelCache = state.model - } - - private async sendIgnoredMessage(sessionID: string, text: string, agent?: string) { - try { - await this.client.session.prompt({ - path: { id: sessionID }, - body: { - noReply: true, - agent: agent, - parts: [{ - type: 'text', - text: text, - ignored: true - }] - } - }) - } catch (error: any) { - this.logger.error("janitor", "Failed to send notification", { error: error.message }) - } - } - - async runOnIdle(sessionID: string, strategies: PruningStrategy[]): Promise { - return await this.runWithStrategies(sessionID, strategies, { trigger: 'idle' }) - } - - async runForTool( - sessionID: string, - strategies: PruningStrategy[], - reason?: string - ): Promise { - return await this.runWithStrategies(sessionID, strategies, { trigger: 'tool', reason }) - } - - async runWithStrategies( - sessionID: string, - strategies: PruningStrategy[], - options: PruningOptions - ): Promise { - try { - if (strategies.length === 0) { - return null - } - - // Ensure persisted state is restored before processing - await ensureSessionRestored(this.state, sessionID, this.logger) - - const [sessionInfoResponse, messagesResponse] = await Promise.all([ - this.client.session.get({ path: { id: sessionID } }), - this.client.session.messages({ path: { id: sessionID }, query: { limit: 100 } }) - ]) - - const sessionInfo = sessionInfoResponse.data - const messages = messagesResponse.data || messagesResponse - - if (!messages || messages.length < 3) { - return null - } - - let currentAgent: string | undefined = undefined - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i] - const info = msg.info - if (info?.role === 'user') { - currentAgent = info.agent || 'build' - break - } - } - - const toolCallIds: string[] = [] - const toolOutputs = new Map() - const toolMetadata = new Map() - const batchToolChildren = new Map() - let currentBatchId: string | null = null - - for (const msg of messages) { - if (msg.parts) { - for (const part of msg.parts) { - if (part.type === "tool" && part.callID) { - const normalizedId = part.callID.toLowerCase() - toolCallIds.push(normalizedId) - - const cachedData = this.toolParametersCache.get(part.callID) || this.toolParametersCache.get(normalizedId) - const parameters = cachedData?.parameters ?? part.state?.input ?? part.parameters - - toolMetadata.set(normalizedId, { - tool: part.tool, - parameters: parameters - }) - - if (part.state?.status === "completed" && part.state.output) { - toolOutputs.set(normalizedId, part.state.output) - } - - if (part.tool === "batch") { - currentBatchId = normalizedId - batchToolChildren.set(normalizedId, []) - } else if (currentBatchId && normalizedId.startsWith('prt_')) { - batchToolChildren.get(currentBatchId)!.push(normalizedId) - } else if (currentBatchId && !normalizedId.startsWith('prt_')) { - currentBatchId = null - } - } - } - } - } - - const alreadyPrunedIds = this.prunedIdsState.get(sessionID) ?? [] - const unprunedToolCallIds = toolCallIds.filter(id => !alreadyPrunedIds.includes(id)) - - if (unprunedToolCallIds.length === 0) { - return null - } +export interface JanitorConfig { + protectedTools: string[] + model?: string + showModelErrorToasts: boolean + strictModelSelection: boolean + pruningSummary: "off" | "minimal" | "detailed" + workingDirectory?: string +} - // PHASE 1: DUPLICATE DETECTION - let deduplicatedIds: string[] = [] - let deduplicationDetails = new Map() +export interface JanitorContext { + client: any + state: PluginState + logger: Logger + config: JanitorConfig + notificationCtx: NotificationContext +} - if (strategies.includes('deduplication')) { - const dedupeResult = detectDuplicates(toolMetadata, unprunedToolCallIds, this.protectedTools) - deduplicatedIds = dedupeResult.duplicateIds - deduplicationDetails = dedupeResult.deduplicationDetails +// ============================================================================ +// Context factory +// ============================================================================ + +export function createJanitorContext( + client: any, + state: PluginState, + logger: Logger, + config: JanitorConfig +): JanitorContext { + return { + client, + state, + logger, + config, + notificationCtx: { + client, + logger, + config: { + pruningSummary: config.pruningSummary, + workingDirectory: config.workingDirectory } + } + } +} - const candidateCount = unprunedToolCallIds.filter(id => { - const metadata = toolMetadata.get(id) - return !metadata || !this.protectedTools.includes(metadata.tool) - }).length - - // PHASE 2: LLM ANALYSIS - let llmPrunedIds: string[] = [] - - if (strategies.includes('ai-analysis')) { - const protectedToolCallIds: string[] = [] - const prunableToolCallIds = unprunedToolCallIds.filter(id => { - if (deduplicatedIds.includes(id)) return false - - const metadata = toolMetadata.get(id) - if (metadata && this.protectedTools.includes(metadata.tool)) { - protectedToolCallIds.push(id) - return false - } - - return true - }) - - if (prunableToolCallIds.length > 0) { - const cachedModelInfo = this.modelCache.get(sessionID) - const sessionModelInfo = extractModelFromSession(sessionInfo, this.logger) - const currentModelInfo = cachedModelInfo || sessionModelInfo - - const modelSelection = await selectModel(currentModelInfo, this.logger, this.configModel, this.workingDirectory) - - this.logger.info("janitor", `Model: ${modelSelection.modelInfo.providerID}/${modelSelection.modelInfo.modelID}`, { - source: modelSelection.source - }) - - if (modelSelection.failedModel && this.showModelErrorToasts) { - const skipAi = modelSelection.source === 'fallback' && this.strictModelSelection - try { - await this.client.tui.showToast({ - body: { - title: skipAi ? "DCP: AI analysis skipped" : "DCP: Model fallback", - message: skipAi - ? `${modelSelection.failedModel.providerID}/${modelSelection.failedModel.modelID} failed\nAI analysis skipped (strictModelSelection enabled)` - : `${modelSelection.failedModel.providerID}/${modelSelection.failedModel.modelID} failed\nUsing ${modelSelection.modelInfo.providerID}/${modelSelection.modelInfo.modelID}`, - variant: "info", - duration: 5000 - } - }) - } catch (toastError: any) { - } - } +// ============================================================================ +// Public API +// ============================================================================ - if (modelSelection.source === 'fallback' && this.strictModelSelection) { - this.logger.info("janitor", "Skipping AI analysis (fallback model, strictModelSelection enabled)") - } else { - const { generateObject } = await import('ai') - - const allPrunedSoFar = [...alreadyPrunedIds, ...deduplicatedIds] - const sanitizedMessages = this.replacePrunedToolOutputs(messages, allPrunedSoFar) - - const analysisPrompt = buildAnalysisPrompt( - prunableToolCallIds, - sanitizedMessages, - allPrunedSoFar, - protectedToolCallIds, - options.reason - ) - - await this.logger.saveWrappedContext( - "janitor-shadow", - [{ role: "user", content: analysisPrompt }], - { - sessionID, - modelProvider: modelSelection.modelInfo.providerID, - modelID: modelSelection.modelInfo.modelID, - candidateToolCount: prunableToolCallIds.length, - alreadyPrunedCount: allPrunedSoFar.length, - protectedToolCount: protectedToolCallIds.length, - trigger: options.trigger, - reason: options.reason - } - ) - - const result = await generateObject({ - model: modelSelection.model, - schema: z.object({ - pruned_tool_call_ids: z.array(z.string()), - reasoning: z.string(), - }), - prompt: analysisPrompt - }) - - const rawLlmPrunedIds = result.object.pruned_tool_call_ids - llmPrunedIds = rawLlmPrunedIds.filter(id => - prunableToolCallIds.includes(id.toLowerCase()) - ) - - if (llmPrunedIds.length > 0) { - const reasoning = result.object.reasoning.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim() - this.logger.info("janitor", `LLM reasoning: ${reasoning.substring(0, 200)}${reasoning.length > 200 ? '...' : ''}`) - } - } - } - } +export async function runOnIdle( + ctx: JanitorContext, + sessionID: string, + strategies: PruningStrategy[] +): Promise { + return runWithStrategies(ctx, sessionID, strategies, { trigger: 'idle' }) +} - // PHASE 3: COMBINE & EXPAND - const newlyPrunedIds = [...deduplicatedIds, ...llmPrunedIds] +export async function runOnTool( + ctx: JanitorContext, + sessionID: string, + strategies: PruningStrategy[], + reason?: string +): Promise { + return runWithStrategies(ctx, sessionID, strategies, { trigger: 'tool', reason }) +} - if (newlyPrunedIds.length === 0) { - return null - } +// ============================================================================ +// Core pruning logic +// ============================================================================ - const expandBatchIds = (ids: string[]): string[] => { - const expanded = new Set() - for (const id of ids) { - const normalizedId = id.toLowerCase() - expanded.add(normalizedId) - const children = batchToolChildren.get(normalizedId) - if (children) { - children.forEach(childId => expanded.add(childId)) - } - } - return Array.from(expanded) - } +async function runWithStrategies( + ctx: JanitorContext, + sessionID: string, + strategies: PruningStrategy[], + options: PruningOptions +): Promise { + const { client, state, logger, config } = ctx - const expandedPrunedIds = new Set(expandBatchIds(newlyPrunedIds)) - const expandedLlmPrunedIds = expandBatchIds(llmPrunedIds) - const finalNewlyPrunedIds = Array.from(expandedPrunedIds).filter(id => !alreadyPrunedIds.includes(id)) - const finalPrunedIds = Array.from(expandedPrunedIds) + try { + if (strategies.length === 0) { + return null + } - // PHASE 4: CALCULATE STATS & NOTIFICATION - const tokensSaved = await this.calculateTokensSaved(finalNewlyPrunedIds, toolOutputs) + // Ensure persisted state is restored before processing + await ensureSessionRestored(state, sessionID, logger) - const currentStats = this.statsState.get(sessionID) ?? { totalToolsPruned: 0, totalTokensSaved: 0 } - const sessionStats: SessionStats = { - totalToolsPruned: currentStats.totalToolsPruned + finalNewlyPrunedIds.length, - totalTokensSaved: currentStats.totalTokensSaved + tokensSaved - } - this.statsState.set(sessionID, sessionStats) - - const hasLlmAnalysis = strategies.includes('ai-analysis') - - if (hasLlmAnalysis) { - await this.sendSmartModeNotification( - sessionID, - deduplicatedIds, - deduplicationDetails, - expandedLlmPrunedIds, - toolMetadata, - tokensSaved, - sessionStats, - currentAgent - ) - } else { - await this.sendAutoModeNotification( - sessionID, - deduplicatedIds, - deduplicationDetails, - tokensSaved, - sessionStats, - currentAgent - ) - } + const [sessionInfoResponse, messagesResponse] = await Promise.all([ + client.session.get({ path: { id: sessionID } }), + client.session.messages({ path: { id: sessionID }, query: { limit: 100 } }) + ]) - // PHASE 5: STATE UPDATE - const allPrunedIds = [...new Set([...alreadyPrunedIds, ...finalPrunedIds])] - this.prunedIdsState.set(sessionID, allPrunedIds) + const sessionInfo = sessionInfoResponse.data + const messages = messagesResponse.data || messagesResponse - const sessionName = sessionInfo?.title - saveSessionState(sessionID, new Set(allPrunedIds), sessionStats, this.logger, sessionName).catch(err => { - this.logger.error("janitor", "Failed to persist state", { error: err.message }) - }) + if (!messages || messages.length < 3) { + return null + } - const prunedCount = finalNewlyPrunedIds.length - const keptCount = candidateCount - prunedCount - const hasBoth = deduplicatedIds.length > 0 && llmPrunedIds.length > 0 - const breakdown = hasBoth ? ` (${deduplicatedIds.length} duplicate, ${llmPrunedIds.length} llm)` : "" + const currentAgent = findCurrentAgent(messages) + const { toolCallIds, toolOutputs, toolMetadata, batchToolChildren } = parseMessages(messages, state.toolParameters) - const logMeta: Record = { trigger: options.trigger } - if (options.reason) { - logMeta.reason = options.reason - } + const alreadyPrunedIds = state.prunedIds.get(sessionID) ?? [] + const unprunedToolCallIds = toolCallIds.filter(id => !alreadyPrunedIds.includes(id)) - this.logger.info("janitor", `Pruned ${prunedCount}/${candidateCount} tools${breakdown}, ${keptCount} kept (~${formatTokenCount(tokensSaved)} tokens)`, logMeta) + if (unprunedToolCallIds.length === 0) { + return null + } - return { - prunedCount: finalNewlyPrunedIds.length, - tokensSaved, - thinkingIds: [], - deduplicatedIds, - llmPrunedIds: expandedLlmPrunedIds, - deduplicationDetails, + const candidateCount = unprunedToolCallIds.filter(id => { + const metadata = toolMetadata.get(id) + return !metadata || !config.protectedTools.includes(metadata.tool) + }).length + + // PHASE 1: LLM ANALYSIS + let llmPrunedIds: string[] = [] + + if (strategies.includes('ai-analysis')) { + llmPrunedIds = await runLlmAnalysis( + ctx, + sessionID, + sessionInfo, + messages, + unprunedToolCallIds, + alreadyPrunedIds, toolMetadata, - sessionStats - } - - } catch (error: any) { - this.logger.error("janitor", "Analysis failed", { - error: error.message, - trigger: options.trigger - }) - return null + options + ) } - } - private shortenPath(input: string): string { - const inPathMatch = input.match(/^(.+) in (.+)$/) - if (inPathMatch) { - const prefix = inPathMatch[1] - const pathPart = inPathMatch[2] - const shortenedPath = this.shortenSinglePath(pathPart) - return `${prefix} in ${shortenedPath}` + // PHASE 2: EXPAND BATCH CHILDREN + if (llmPrunedIds.length === 0) { + return null } - return this.shortenSinglePath(input) - } + const expandedPrunedIds = expandBatchIds(llmPrunedIds, batchToolChildren) + const finalNewlyPrunedIds = expandedPrunedIds.filter(id => !alreadyPrunedIds.includes(id)) - private shortenSinglePath(path: string): string { - const homeDir = require('os').homedir() + // PHASE 3: CALCULATE STATS & NOTIFICATION + const tokensSaved = await calculateTokensSaved(finalNewlyPrunedIds, toolOutputs) - if (this.workingDirectory) { - if (path.startsWith(this.workingDirectory + '/')) { - return path.slice(this.workingDirectory.length + 1) - } - if (path === this.workingDirectory) { - return '.' - } + const currentStats = state.stats.get(sessionID) ?? { totalToolsPruned: 0, totalTokensSaved: 0 } + const sessionStats: SessionStats = { + totalToolsPruned: currentStats.totalToolsPruned + finalNewlyPrunedIds.length, + totalTokensSaved: currentStats.totalTokensSaved + tokensSaved } + state.stats.set(sessionID, sessionStats) + + await sendPruningSummary( + ctx.notificationCtx, + sessionID, + expandedPrunedIds, + toolMetadata, + tokensSaved, + sessionStats, + currentAgent + ) + + // PHASE 4: STATE UPDATE + const allPrunedIds = [...new Set([...alreadyPrunedIds, ...expandedPrunedIds])] + state.prunedIds.set(sessionID, allPrunedIds) + + const sessionName = sessionInfo?.title + saveSessionState(sessionID, new Set(allPrunedIds), sessionStats, logger, sessionName).catch(err => { + logger.error("janitor", "Failed to persist state", { error: err.message }) + }) - if (path.startsWith(homeDir)) { - path = '~' + path.slice(homeDir.length) - } + const prunedCount = finalNewlyPrunedIds.length + const keptCount = candidateCount - prunedCount - const nodeModulesMatch = path.match(/node_modules\/(@[^\/]+\/[^\/]+|[^\/]+)\/(.*)/) - if (nodeModulesMatch) { - return `${nodeModulesMatch[1]}/${nodeModulesMatch[2]}` + const logMeta: Record = { trigger: options.trigger } + if (options.reason) { + logMeta.reason = options.reason } - if (this.workingDirectory) { - const workingDirWithTilde = this.workingDirectory.startsWith(homeDir) - ? '~' + this.workingDirectory.slice(homeDir.length) - : null + logger.info("janitor", `Pruned ${prunedCount}/${candidateCount} tools, ${keptCount} kept (~${formatTokenCount(tokensSaved)} tokens)`, logMeta) - if (workingDirWithTilde && path.startsWith(workingDirWithTilde + '/')) { - return path.slice(workingDirWithTilde.length + 1) - } - if (workingDirWithTilde && path === workingDirWithTilde) { - return '.' - } + return { + prunedCount: finalNewlyPrunedIds.length, + tokensSaved, + llmPrunedIds: expandedPrunedIds, + toolMetadata, + sessionStats } - return path - } - - private replacePrunedToolOutputs(messages: any[], prunedIds: string[]): any[] { - if (prunedIds.length === 0) return messages - - const prunedIdsSet = new Set(prunedIds.map(id => id.toLowerCase())) - - return messages.map(msg => { - if (!msg.parts) return msg - - return { - ...msg, - parts: msg.parts.map((part: any) => { - if (part.type === 'tool' && - part.callID && - prunedIdsSet.has(part.callID.toLowerCase()) && - part.state?.output) { - return { - ...part, - state: { - ...part.state, - output: '[Output removed to save context - information superseded or no longer needed]' - } - } - } - return part - }) - } + } catch (error: any) { + ctx.logger.error("janitor", "Analysis failed", { + error: error.message, + trigger: options.trigger }) + return null } +} - private async calculateTokensSaved(prunedIds: string[], toolOutputs: Map): Promise { - const outputsToTokenize: string[] = [] - - for (const prunedId of prunedIds) { - const output = toolOutputs.get(prunedId) - if (output) { - outputsToTokenize.push(output) - } - } - - if (outputsToTokenize.length > 0) { - const tokenCounts = await estimateTokensBatch(outputsToTokenize) - return tokenCounts.reduce((sum, count) => sum + count, 0) +// ============================================================================ +// LLM Analysis +// ============================================================================ + +async function runLlmAnalysis( + ctx: JanitorContext, + sessionID: string, + sessionInfo: any, + messages: any[], + unprunedToolCallIds: string[], + alreadyPrunedIds: string[], + toolMetadata: Map, + options: PruningOptions +): Promise { + const { client, state, logger, config } = ctx + + const protectedToolCallIds: string[] = [] + const prunableToolCallIds = unprunedToolCallIds.filter(id => { + const metadata = toolMetadata.get(id) + if (metadata && config.protectedTools.includes(metadata.tool)) { + protectedToolCallIds.push(id) + return false } + return true + }) - return 0 + if (prunableToolCallIds.length === 0) { + return [] } - private buildToolsSummary(prunedIds: string[], toolMetadata: Map): Map { - const toolsSummary = new Map() + const cachedModelInfo = state.model.get(sessionID) + const sessionModelInfo = extractModelFromSession(sessionInfo, logger) + const currentModelInfo = cachedModelInfo || sessionModelInfo - const truncate = (str: string, maxLen: number = 60): string => { - if (str.length <= maxLen) return str - return str.slice(0, maxLen - 3) + '...' - } + const modelSelection = await selectModel(currentModelInfo, logger, config.model, config.workingDirectory) - for (const prunedId of prunedIds) { - const normalizedId = prunedId.toLowerCase() - const metadata = toolMetadata.get(normalizedId) - if (metadata) { - const toolName = metadata.tool - if (toolName === 'batch') continue - if (!toolsSummary.has(toolName)) { - toolsSummary.set(toolName, []) - } + logger.info("janitor", `Model: ${modelSelection.modelInfo.providerID}/${modelSelection.modelInfo.modelID}`, { + source: modelSelection.source + }) - const paramKey = extractParameterKey(metadata) - if (paramKey) { - const displayKey = truncate(this.shortenPath(paramKey), 80) - toolsSummary.get(toolName)!.push(displayKey) - } else { - toolsSummary.get(toolName)!.push('(default)') + if (modelSelection.failedModel && config.showModelErrorToasts) { + const skipAi = modelSelection.source === 'fallback' && config.strictModelSelection + try { + await client.tui.showToast({ + body: { + title: skipAi ? "DCP: AI analysis skipped" : "DCP: Model fallback", + message: skipAi + ? `${modelSelection.failedModel.providerID}/${modelSelection.failedModel.modelID} failed\nAI analysis skipped (strictModelSelection enabled)` + : `${modelSelection.failedModel.providerID}/${modelSelection.failedModel.modelID} failed\nUsing ${modelSelection.modelInfo.providerID}/${modelSelection.modelInfo.modelID}`, + variant: "info", + duration: 5000 } - } - } - - return toolsSummary - } - - private groupDeduplicationDetails( - deduplicationDetails: Map - ): Map> { - const grouped = new Map>() - - for (const [_, details] of deduplicationDetails) { - const { toolName, parameterKey, duplicateCount } = details - if (toolName === 'batch') continue - if (!grouped.has(toolName)) { - grouped.set(toolName, []) - } - grouped.get(toolName)!.push({ - count: duplicateCount, - key: this.shortenPath(parameterKey) }) + } catch (toastError: any) { + // Ignore toast errors } - - return grouped } - private formatDeduplicationLines( - grouped: Map>, - indent: string = ' ' - ): string[] { - const lines: string[] = [] - - for (const [toolName, items] of grouped.entries()) { - for (const item of items) { - const removedCount = item.count - 1 - lines.push(`${indent}${toolName}: ${item.key} (${removedCount}Ɨ duplicate)`) - } - } - - return lines + if (modelSelection.source === 'fallback' && config.strictModelSelection) { + logger.info("janitor", "Skipping AI analysis (fallback model, strictModelSelection enabled)") + return [] } - private formatToolSummaryLines( - toolsSummary: Map, - indent: string = ' ' - ): string[] { - const lines: string[] = [] - - for (const [toolName, params] of toolsSummary.entries()) { - if (params.length === 1) { - lines.push(`${indent}${toolName}: ${params[0]}`) - } else if (params.length > 1) { - lines.push(`${indent}${toolName} (${params.length}):`) - for (const param of params) { - lines.push(`${indent} ${param}`) - } - } - } - - return lines - } - - private async sendMinimalNotification( - sessionID: string, - totalPruned: number, - tokensSaved: number, - sessionStats: SessionStats, - agent?: string - ) { - if (totalPruned === 0) return - - const tokensFormatted = formatTokenCount(tokensSaved) - const toolText = totalPruned === 1 ? 'tool' : 'tools' - - let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${totalPruned} ${toolText} pruned)` - - if (sessionStats.totalToolsPruned > totalPruned) { - message += ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools` + const { generateObject } = await import('ai') + + const sanitizedMessages = replacePrunedToolOutputs(messages, alreadyPrunedIds) + + const analysisPrompt = buildAnalysisPrompt( + prunableToolCallIds, + sanitizedMessages, + alreadyPrunedIds, + protectedToolCallIds, + options.reason + ) + + await logger.saveWrappedContext( + "janitor-shadow", + [{ role: "user", content: analysisPrompt }], + { + sessionID, + modelProvider: modelSelection.modelInfo.providerID, + modelID: modelSelection.modelInfo.modelID, + candidateToolCount: prunableToolCallIds.length, + alreadyPrunedCount: alreadyPrunedIds.length, + protectedToolCount: protectedToolCallIds.length, + trigger: options.trigger, + reason: options.reason } - - await this.sendIgnoredMessage(sessionID, message, agent) + ) + + const result = await generateObject({ + model: modelSelection.model, + schema: z.object({ + pruned_tool_call_ids: z.array(z.string()), + reasoning: z.string(), + }), + prompt: analysisPrompt + }) + + const rawLlmPrunedIds = result.object.pruned_tool_call_ids + const llmPrunedIds = rawLlmPrunedIds.filter(id => + prunableToolCallIds.includes(id.toLowerCase()) + ) + + if (llmPrunedIds.length > 0) { + const reasoning = result.object.reasoning.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim() + logger.info("janitor", `LLM reasoning: ${reasoning.substring(0, 200)}${reasoning.length > 200 ? '...' : ''}`) } - private async sendAutoModeNotification( - sessionID: string, - deduplicatedIds: string[], - deduplicationDetails: Map, - tokensSaved: number, - sessionStats: SessionStats, - agent?: string - ) { - if (deduplicatedIds.length === 0) return - if (this.pruningSummary === 'off') return - - if (this.pruningSummary === 'minimal') { - await this.sendMinimalNotification(sessionID, deduplicatedIds.length, tokensSaved, sessionStats, agent) - return - } - - const tokensFormatted = formatTokenCount(tokensSaved) - const toolText = deduplicatedIds.length === 1 ? 'tool' : 'tools' - let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${deduplicatedIds.length} duplicate ${toolText} removed)` + return llmPrunedIds +} - if (sessionStats.totalToolsPruned > deduplicatedIds.length) { - message += ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools` - } - message += '\n' +// ============================================================================ +// Message parsing +// ============================================================================ - const grouped = this.groupDeduplicationDetails(deduplicationDetails) +interface ParsedMessages { + toolCallIds: string[] + toolOutputs: Map + toolMetadata: Map + batchToolChildren: Map +} - for (const [toolName, items] of grouped.entries()) { - const totalDupes = items.reduce((sum, item) => sum + (item.count - 1), 0) - message += `\n${toolName} (${totalDupes} duplicate${totalDupes > 1 ? 's' : ''}):\n` +function parseMessages( + messages: any[], + toolParametersCache: Map +): ParsedMessages { + const toolCallIds: string[] = [] + const toolOutputs = new Map() + const toolMetadata = new Map() + const batchToolChildren = new Map() + let currentBatchId: string | null = null + + for (const msg of messages) { + if (msg.parts) { + for (const part of msg.parts) { + if (part.type === "tool" && part.callID) { + const normalizedId = part.callID.toLowerCase() + toolCallIds.push(normalizedId) + + const cachedData = toolParametersCache.get(part.callID) || toolParametersCache.get(normalizedId) + const parameters = cachedData?.parameters ?? part.state?.input ?? part.parameters + + toolMetadata.set(normalizedId, { + tool: part.tool, + parameters: parameters + }) - for (const item of items.slice(0, 5)) { - const dupeCount = item.count - 1 - message += ` ${item.key} (${dupeCount}Ɨ duplicate)\n` - } + if (part.state?.status === "completed" && part.state.output) { + toolOutputs.set(normalizedId, part.state.output) + } - if (items.length > 5) { - message += ` ... and ${items.length - 5} more\n` + if (part.tool === "batch") { + currentBatchId = normalizedId + batchToolChildren.set(normalizedId, []) + } else if (currentBatchId && normalizedId.startsWith('prt_')) { + batchToolChildren.get(currentBatchId)!.push(normalizedId) + } else if (currentBatchId && !normalizedId.startsWith('prt_')) { + currentBatchId = null + } + } } } - - await this.sendIgnoredMessage(sessionID, message.trim(), agent) } - formatPruningResultForTool(result: PruningResult): string { - const lines: string[] = [] - lines.push(`Context pruning complete. Pruned ${result.prunedCount} tool outputs.`) - lines.push('') - - if (result.deduplicatedIds.length > 0 && result.deduplicationDetails.size > 0) { - lines.push(`Duplicates removed (${result.deduplicatedIds.length}):`) - const grouped = this.groupDeduplicationDetails(result.deduplicationDetails) - lines.push(...this.formatDeduplicationLines(grouped)) - lines.push('') - } + return { toolCallIds, toolOutputs, toolMetadata, batchToolChildren } +} - if (result.llmPrunedIds.length > 0) { - lines.push(`Semantically pruned (${result.llmPrunedIds.length}):`) - const toolsSummary = this.buildToolsSummary(result.llmPrunedIds, result.toolMetadata) - lines.push(...this.formatToolSummaryLines(toolsSummary)) +function findCurrentAgent(messages: any[]): string | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + const info = msg.info + if (info?.role === 'user') { + return info.agent || 'build' } - - return lines.join('\n').trim() } + return undefined +} - private async sendSmartModeNotification( - sessionID: string, - deduplicatedIds: string[], - deduplicationDetails: Map, - llmPrunedIds: string[], - toolMetadata: Map, - tokensSaved: number, - sessionStats: SessionStats, - agent?: string - ) { - const totalPruned = deduplicatedIds.length + llmPrunedIds.length - if (totalPruned === 0) return - if (this.pruningSummary === 'off') return - - if (this.pruningSummary === 'minimal') { - await this.sendMinimalNotification(sessionID, totalPruned, tokensSaved, sessionStats, agent) - return - } - - const tokensFormatted = formatTokenCount(tokensSaved) - - let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${totalPruned} tool${totalPruned > 1 ? 's' : ''} pruned)` - - if (sessionStats.totalToolsPruned > totalPruned) { - message += ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools` - } - message += '\n' - - if (deduplicatedIds.length > 0 && deduplicationDetails) { - message += `\nšŸ“¦ Duplicates removed (${deduplicatedIds.length}):\n` - const grouped = this.groupDeduplicationDetails(deduplicationDetails) - - for (const [toolName, items] of grouped.entries()) { - message += ` ${toolName}:\n` - for (const item of items) { - const removedCount = item.count - 1 - message += ` ${item.key} (${removedCount}Ɨ duplicate)\n` - } - } +// ============================================================================ +// Helpers +// ============================================================================ + +function expandBatchIds(ids: string[], batchToolChildren: Map): string[] { + const expanded = new Set() + for (const id of ids) { + const normalizedId = id.toLowerCase() + expanded.add(normalizedId) + const children = batchToolChildren.get(normalizedId) + if (children) { + children.forEach(childId => expanded.add(childId)) } + } + return Array.from(expanded) +} - if (llmPrunedIds.length > 0) { - message += `\nšŸ¤– LLM analysis (${llmPrunedIds.length}):\n` - const toolsSummary = this.buildToolsSummary(llmPrunedIds, toolMetadata) - - for (const [toolName, params] of toolsSummary.entries()) { - if (params.length > 0) { - message += ` ${toolName} (${params.length}):\n` - for (const param of params) { - message += ` ${param}\n` +function replacePrunedToolOutputs(messages: any[], prunedIds: string[]): any[] { + if (prunedIds.length === 0) return messages + + const prunedIdsSet = new Set(prunedIds.map(id => id.toLowerCase())) + + return messages.map(msg => { + if (!msg.parts) return msg + + return { + ...msg, + parts: msg.parts.map((part: any) => { + if (part.type === 'tool' && + part.callID && + prunedIdsSet.has(part.callID.toLowerCase()) && + part.state?.output) { + return { + ...part, + state: { + ...part.state, + output: '[Output removed to save context - information superseded or no longer needed]' + } } } - } - - const foundToolNames = new Set(toolsSummary.keys()) - const missingTools = llmPrunedIds.filter(id => { - const normalizedId = id.toLowerCase() - const metadata = toolMetadata.get(normalizedId) - if (metadata?.tool === 'batch') return false - return !metadata || !foundToolNames.has(metadata.tool) + return part }) + } + }) +} - if (missingTools.length > 0) { - message += ` (${missingTools.length} tool${missingTools.length > 1 ? 's' : ''} with unknown metadata)\n` - } +async function calculateTokensSaved(prunedIds: string[], toolOutputs: Map): Promise { + const outputsToTokenize: string[] = [] + + for (const prunedId of prunedIds) { + const output = toolOutputs.get(prunedId) + if (output) { + outputsToTokenize.push(output) } + } - await this.sendIgnoredMessage(sessionID, message.trim(), agent) + if (outputsToTokenize.length > 0) { + const tokenCounts = await estimateTokensBatch(outputsToTokenize) + return tokenCounts.reduce((sum, count) => sum + count, 0) } + + return 0 } diff --git a/lib/notification.ts b/lib/notification.ts new file mode 100644 index 00000000..0817fb94 --- /dev/null +++ b/lib/notification.ts @@ -0,0 +1,271 @@ +import type { Logger } from "./logger" +import type { SessionStats, PruningResult } from "./janitor" +import { formatTokenCount } from "./tokenizer" +import { extractParameterKey } from "./display-utils" + +export type PruningSummaryLevel = "off" | "minimal" | "detailed" + +export interface NotificationConfig { + pruningSummary: PruningSummaryLevel + workingDirectory?: string +} + +export interface NotificationContext { + client: any + logger: Logger + config: NotificationConfig +} + +// ============================================================================ +// Core notification sending +// ============================================================================ + +export async function sendIgnoredMessage( + ctx: NotificationContext, + sessionID: string, + text: string, + agent?: string +): Promise { + try { + await ctx.client.session.prompt({ + path: { id: sessionID }, + body: { + noReply: true, + agent: agent, + parts: [{ + type: 'text', + text: text, + ignored: true + }] + } + }) + } catch (error: any) { + ctx.logger.error("notification", "Failed to send notification", { error: error.message }) + } +} + +// ============================================================================ +// Pruning notifications +// ============================================================================ + +export async function sendPruningSummary( + ctx: NotificationContext, + sessionID: string, + llmPrunedIds: string[], + toolMetadata: Map, + tokensSaved: number, + sessionStats: SessionStats, + agent?: string +): Promise { + const totalPruned = llmPrunedIds.length + if (totalPruned === 0) return + if (ctx.config.pruningSummary === 'off') return + + if (ctx.config.pruningSummary === 'minimal') { + await sendMinimalSummary(ctx, sessionID, totalPruned, tokensSaved, sessionStats, agent) + return + } + + await sendDetailedSummary(ctx, sessionID, llmPrunedIds, toolMetadata, tokensSaved, sessionStats, agent) +} + +async function sendMinimalSummary( + ctx: NotificationContext, + sessionID: string, + totalPruned: number, + tokensSaved: number, + sessionStats: SessionStats, + agent?: string +): Promise { + if (totalPruned === 0) return + + const tokensFormatted = formatTokenCount(tokensSaved) + const toolText = totalPruned === 1 ? 'tool' : 'tools' + + let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${totalPruned} ${toolText} pruned)` + + if (sessionStats.totalToolsPruned > totalPruned) { + message += ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools` + } + + await sendIgnoredMessage(ctx, sessionID, message, agent) +} + +async function sendDetailedSummary( + ctx: NotificationContext, + sessionID: string, + llmPrunedIds: string[], + toolMetadata: Map, + tokensSaved: number, + sessionStats: SessionStats, + agent?: string +): Promise { + const totalPruned = llmPrunedIds.length + const tokensFormatted = formatTokenCount(tokensSaved) + + let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${totalPruned} tool${totalPruned > 1 ? 's' : ''} pruned)` + + if (sessionStats.totalToolsPruned > totalPruned) { + message += ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools` + } + message += '\n' + + message += `\nšŸ¤– LLM analysis (${llmPrunedIds.length}):\n` + const toolsSummary = buildToolsSummary(llmPrunedIds, toolMetadata, ctx.config.workingDirectory) + + for (const [toolName, params] of toolsSummary.entries()) { + if (params.length > 0) { + message += ` ${toolName} (${params.length}):\n` + for (const param of params) { + message += ` ${param}\n` + } + } + } + + const foundToolNames = new Set(toolsSummary.keys()) + const missingTools = llmPrunedIds.filter(id => { + const normalizedId = id.toLowerCase() + const metadata = toolMetadata.get(normalizedId) + if (metadata?.tool === 'batch') return false + return !metadata || !foundToolNames.has(metadata.tool) + }) + + if (missingTools.length > 0) { + message += ` (${missingTools.length} tool${missingTools.length > 1 ? 's' : ''} with unknown metadata)\n` + } + + await sendIgnoredMessage(ctx, sessionID, message.trim(), agent) +} + +// ============================================================================ +// Formatting for 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}):`) + const toolsSummary = buildToolsSummary(result.llmPrunedIds, result.toolMetadata, workingDirectory) + lines.push(...formatToolSummaryLines(toolsSummary)) + } + + return lines.join('\n').trim() +} + +// ============================================================================ +// Summary building helpers +// ============================================================================ + +export function buildToolsSummary( + prunedIds: string[], + toolMetadata: Map, + workingDirectory?: string +): Map { + const toolsSummary = new Map() + + for (const prunedId of prunedIds) { + const normalizedId = prunedId.toLowerCase() + const metadata = toolMetadata.get(normalizedId) + if (metadata) { + const toolName = metadata.tool + if (toolName === 'batch') continue + if (!toolsSummary.has(toolName)) { + toolsSummary.set(toolName, []) + } + + const paramKey = extractParameterKey(metadata) + if (paramKey) { + const displayKey = truncate(shortenPath(paramKey, workingDirectory), 80) + toolsSummary.get(toolName)!.push(displayKey) + } else { + toolsSummary.get(toolName)!.push('(default)') + } + } + } + + return toolsSummary +} + +export function formatToolSummaryLines( + toolsSummary: Map, + indent: string = ' ' +): string[] { + const lines: string[] = [] + + for (const [toolName, params] of toolsSummary.entries()) { + if (params.length === 1) { + lines.push(`${indent}${toolName}: ${params[0]}`) + } else if (params.length > 1) { + lines.push(`${indent}${toolName} (${params.length}):`) + for (const param of params) { + lines.push(`${indent} ${param}`) + } + } + } + + return lines +} + +// ============================================================================ +// Path utilities +// ============================================================================ + +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 +} diff --git a/lib/pruning-tool.ts b/lib/pruning-tool.ts index 54019887..45797c7d 100644 --- a/lib/pruning-tool.ts +++ b/lib/pruning-tool.ts @@ -1,5 +1,7 @@ import { tool } from "@opencode-ai/plugin" -import type { Janitor } from "./janitor" +import type { JanitorContext } from "./janitor" +import { runOnTool } from "./janitor" +import { formatPruningResultForTool } from "./notification" import type { PluginConfig } from "./config" import type { ToolTracker } from "./synth-instruction" import { resetToolTrackerCount } from "./synth-instruction" @@ -13,7 +15,7 @@ export const CONTEXT_PRUNING_DESCRIPTION = loadPrompt("tool") * Creates the prune tool definition. * Returns a tool definition that can be passed to the plugin's tool registry. */ -export function createPruningTool(client: any, janitor: Janitor, config: PluginConfig, toolTracker: ToolTracker): ReturnType { +export function createPruningTool(client: any, janitorCtx: JanitorContext, config: PluginConfig, toolTracker: ToolTracker): ReturnType { return tool({ description: CONTEXT_PRUNING_DESCRIPTION, args: { @@ -28,7 +30,8 @@ export function createPruningTool(client: any, janitor: Janitor, config: PluginC return "Pruning is unavailable in subagent sessions. Do not call this tool again. Continue with your current task - if you were in the middle of work, proceed with your next step. If you had just finished, provide your final summary/findings to return to the main agent." } - const result = await janitor.runForTool( + const result = await runOnTool( + janitorCtx, ctx.sessionID, config.strategies.onTool, args.reason @@ -48,7 +51,7 @@ export function createPruningTool(client: any, janitor: Janitor, config: PluginC return "No prunable tool outputs found. Context is already optimized." + postPruneGuidance } - return janitor.formatPruningResultForTool(result) + postPruneGuidance + return formatPruningResultForTool(result, janitorCtx.config.workingDirectory) + postPruneGuidance }, }) } From 811f6b2532663e2911155270efe8a8fe91f1aade Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 2 Dec 2025 02:17:13 -0500 Subject: [PATCH 02/11] refactor: make batch a protected tool, remove cascade pruning - Add 'batch' to default protectedTools in config - Remove batchToolChildren tracking from parseMessages() - Remove expandBatchIds() function - Batch children are now pruned individually by LLM decision --- lib/config.ts | 2 +- lib/janitor.ts | 43 ++++++++----------------------------------- 2 files changed, 9 insertions(+), 36 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index 2d0500bc..74e173ec 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -30,7 +30,7 @@ export interface ConfigResult { const defaultConfig: PluginConfig = { enabled: true, debug: false, - protectedTools: ['task', 'todowrite', 'todoread', 'prune'], + protectedTools: ['task', 'todowrite', 'todoread', 'prune', 'batch'], showModelErrorToasts: true, strictModelSelection: false, pruning_summary: 'detailed', diff --git a/lib/janitor.ts b/lib/janitor.ts index 7a07ff1c..b1330c87 100644 --- a/lib/janitor.ts +++ b/lib/janitor.ts @@ -131,7 +131,7 @@ async function runWithStrategies( } const currentAgent = findCurrentAgent(messages) - const { toolCallIds, toolOutputs, toolMetadata, batchToolChildren } = parseMessages(messages, state.toolParameters) + const { toolCallIds, toolOutputs, toolMetadata } = parseMessages(messages, state.toolParameters) const alreadyPrunedIds = state.prunedIds.get(sessionID) ?? [] const unprunedToolCallIds = toolCallIds.filter(id => !alreadyPrunedIds.includes(id)) @@ -161,15 +161,13 @@ async function runWithStrategies( ) } - // PHASE 2: EXPAND BATCH CHILDREN if (llmPrunedIds.length === 0) { return null } - const expandedPrunedIds = expandBatchIds(llmPrunedIds, batchToolChildren) - const finalNewlyPrunedIds = expandedPrunedIds.filter(id => !alreadyPrunedIds.includes(id)) + const finalNewlyPrunedIds = llmPrunedIds.filter(id => !alreadyPrunedIds.includes(id)) - // PHASE 3: CALCULATE STATS & NOTIFICATION + // PHASE 2: CALCULATE STATS & NOTIFICATION const tokensSaved = await calculateTokensSaved(finalNewlyPrunedIds, toolOutputs) const currentStats = state.stats.get(sessionID) ?? { totalToolsPruned: 0, totalTokensSaved: 0 } @@ -182,15 +180,15 @@ async function runWithStrategies( await sendPruningSummary( ctx.notificationCtx, sessionID, - expandedPrunedIds, + llmPrunedIds, toolMetadata, tokensSaved, sessionStats, currentAgent ) - // PHASE 4: STATE UPDATE - const allPrunedIds = [...new Set([...alreadyPrunedIds, ...expandedPrunedIds])] + // PHASE 3: STATE UPDATE + const allPrunedIds = [...new Set([...alreadyPrunedIds, ...llmPrunedIds])] state.prunedIds.set(sessionID, allPrunedIds) const sessionName = sessionInfo?.title @@ -211,7 +209,7 @@ async function runWithStrategies( return { prunedCount: finalNewlyPrunedIds.length, tokensSaved, - llmPrunedIds: expandedPrunedIds, + llmPrunedIds, toolMetadata, sessionStats } @@ -345,7 +343,6 @@ interface ParsedMessages { toolCallIds: string[] toolOutputs: Map toolMetadata: Map - batchToolChildren: Map } function parseMessages( @@ -355,8 +352,6 @@ function parseMessages( const toolCallIds: string[] = [] const toolOutputs = new Map() const toolMetadata = new Map() - const batchToolChildren = new Map() - let currentBatchId: string | null = null for (const msg of messages) { if (msg.parts) { @@ -376,21 +371,12 @@ function parseMessages( if (part.state?.status === "completed" && part.state.output) { toolOutputs.set(normalizedId, part.state.output) } - - if (part.tool === "batch") { - currentBatchId = normalizedId - batchToolChildren.set(normalizedId, []) - } else if (currentBatchId && normalizedId.startsWith('prt_')) { - batchToolChildren.get(currentBatchId)!.push(normalizedId) - } else if (currentBatchId && !normalizedId.startsWith('prt_')) { - currentBatchId = null - } } } } } - return { toolCallIds, toolOutputs, toolMetadata, batchToolChildren } + return { toolCallIds, toolOutputs, toolMetadata } } function findCurrentAgent(messages: any[]): string | undefined { @@ -408,19 +394,6 @@ function findCurrentAgent(messages: any[]): string | undefined { // Helpers // ============================================================================ -function expandBatchIds(ids: string[], batchToolChildren: Map): string[] { - const expanded = new Set() - for (const id of ids) { - const normalizedId = id.toLowerCase() - expanded.add(normalizedId) - const children = batchToolChildren.get(normalizedId) - if (children) { - children.forEach(childId => expanded.add(childId)) - } - } - return Array.from(expanded) -} - function replacePrunedToolOutputs(messages: any[], prunedIds: string[]): any[] { if (prunedIds.length === 0) return messages From 38917e3d5b628bf031906fbf9f48d5fa40e32580 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 2 Dec 2025 02:24:32 -0500 Subject: [PATCH 03/11] chore: remove dead batch checks from notification.ts --- lib/notification.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/notification.ts b/lib/notification.ts index 0817fb94..e44923d1 100644 --- a/lib/notification.ts +++ b/lib/notification.ts @@ -126,7 +126,6 @@ async function sendDetailedSummary( const missingTools = llmPrunedIds.filter(id => { const normalizedId = id.toLowerCase() const metadata = toolMetadata.get(normalizedId) - if (metadata?.tool === 'batch') return false return !metadata || !foundToolNames.has(metadata.tool) }) @@ -174,7 +173,6 @@ export function buildToolsSummary( const metadata = toolMetadata.get(normalizedId) if (metadata) { const toolName = metadata.tool - if (toolName === 'batch') continue if (!toolsSummary.has(toolName)) { toolsSummary.set(toolName, []) } From 9c6a5b83adaf1a2b296b00d8fad1878d7ef25513 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 2 Dec 2025 02:28:15 -0500 Subject: [PATCH 04/11] chore: remove batch display logic from display-utils --- lib/display-utils.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/display-utils.ts b/lib/display-utils.ts index 80068304..6e4e9e2f 100644 --- a/lib/display-utils.ts +++ b/lib/display-utils.ts @@ -64,9 +64,6 @@ export function extractParameterKey(metadata: { tool: string, parameters?: any } if (tool === "task" && parameters.description) { return parameters.description } - if (tool === "batch") { - return `${parameters.tool_calls?.length || 0} parallel tools` - } const paramStr = JSON.stringify(parameters) if (paramStr === '{}' || paramStr === '[]' || paramStr === 'null') { From 710a4ccab335a1279a075b021f571fd23121f507 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 2 Dec 2025 02:35:47 -0500 Subject: [PATCH 05/11] refactor: reorganize lib/ into core/, state/, api-formats/, ui/ subdirectories --- index.ts | 6 +++--- lib/{ => api-formats}/synth-instruction.ts | 0 lib/{ => core}/deduplicator.ts | 2 +- lib/{ => core}/janitor.ts | 16 ++++++++-------- lib/{ => core}/prompt.ts | 2 +- lib/fetch-wrapper/gemini.ts | 2 +- lib/fetch-wrapper/index.ts | 4 ++-- lib/fetch-wrapper/openai-chat.ts | 4 ++-- lib/fetch-wrapper/openai-responses.ts | 4 ++-- lib/fetch-wrapper/types.ts | 2 +- lib/hooks.ts | 8 ++++---- lib/pruning-tool.ts | 12 ++++++------ lib/{state.ts => state/index.ts} | 6 +++--- .../persistence.ts} | 4 ++-- lib/{ => state}/tool-cache.ts | 2 +- lib/{ => ui}/display-utils.ts | 0 lib/{ => ui}/notification.ts | 6 +++--- 17 files changed, 40 insertions(+), 40 deletions(-) rename lib/{ => api-formats}/synth-instruction.ts (100%) rename lib/{ => core}/deduplicator.ts (98%) rename lib/{ => core}/janitor.ts (97%) rename lib/{ => core}/prompt.ts (98%) rename lib/{state.ts => state/index.ts} (94%) rename lib/{state-persistence.ts => state/persistence.ts} (96%) rename lib/{ => state}/tool-cache.ts (97%) rename lib/{ => ui}/display-utils.ts (100%) rename lib/{ => ui}/notification.ts (98%) diff --git a/index.ts b/index.ts index da77e422..1ccb82e6 100644 --- a/index.ts +++ b/index.ts @@ -1,14 +1,14 @@ import type { Plugin } from "@opencode-ai/plugin" import { getConfig } from "./lib/config" import { Logger } from "./lib/logger" -import { createJanitorContext } from "./lib/janitor" +import { createJanitorContext } from "./lib/core/janitor" import { checkForUpdates } from "./lib/version-checker" 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/synth-instruction" -import { loadPrompt } from "./lib/prompt" +import { createToolTracker } from "./lib/api-formats/synth-instruction" +import { loadPrompt } from "./lib/core/prompt" const plugin: Plugin = (async (ctx) => { const { config, migrations } = getConfig(ctx) diff --git a/lib/synth-instruction.ts b/lib/api-formats/synth-instruction.ts similarity index 100% rename from lib/synth-instruction.ts rename to lib/api-formats/synth-instruction.ts diff --git a/lib/deduplicator.ts b/lib/core/deduplicator.ts similarity index 98% rename from lib/deduplicator.ts rename to lib/core/deduplicator.ts index 1d649404..3be8a4bc 100644 --- a/lib/deduplicator.ts +++ b/lib/core/deduplicator.ts @@ -1,4 +1,4 @@ -import { extractParameterKey } from "./display-utils" +import { extractParameterKey } from "../ui/display-utils" export interface DuplicateDetectionResult { duplicateIds: string[] // IDs to prune (older duplicates) diff --git a/lib/janitor.ts b/lib/core/janitor.ts similarity index 97% rename from lib/janitor.ts rename to lib/core/janitor.ts index b1330c87..454eb2fa 100644 --- a/lib/janitor.ts +++ b/lib/core/janitor.ts @@ -1,16 +1,16 @@ import { z } from "zod" -import type { Logger } from "./logger" -import type { PruningStrategy } from "./config" -import type { PluginState } from "./state" +import type { Logger } from "../logger" +import type { PruningStrategy } from "../config" +import type { PluginState } from "../state" import { buildAnalysisPrompt } from "./prompt" -import { selectModel, extractModelFromSession } from "./model-selector" -import { estimateTokensBatch, formatTokenCount } from "./tokenizer" -import { saveSessionState } from "./state-persistence" -import { ensureSessionRestored } from "./state" +import { selectModel, extractModelFromSession } from "../model-selector" +import { estimateTokensBatch, formatTokenCount } from "../tokenizer" +import { saveSessionState } from "../state/persistence" +import { ensureSessionRestored } from "../state" import { sendPruningSummary, type NotificationContext -} from "./notification" +} from "../ui/notification" // ============================================================================ // Types diff --git a/lib/prompt.ts b/lib/core/prompt.ts similarity index 98% rename from lib/prompt.ts rename to lib/core/prompt.ts index e2102843..e7f44d4a 100644 --- a/lib/prompt.ts +++ b/lib/core/prompt.ts @@ -2,7 +2,7 @@ import { readFileSync } from "fs" import { join } from "path" export function loadPrompt(name: string, vars?: Record): string { - const filePath = join(__dirname, "prompts", `${name}.txt`) + const filePath = join(__dirname, "..", "prompts", `${name}.txt`) let content = readFileSync(filePath, "utf8").trim() if (vars) { for (const [key, value] of Object.entries(vars)) { diff --git a/lib/fetch-wrapper/gemini.ts b/lib/fetch-wrapper/gemini.ts index d02bbd00..abc1bd61 100644 --- a/lib/fetch-wrapper/gemini.ts +++ b/lib/fetch-wrapper/gemini.ts @@ -4,7 +4,7 @@ import { getAllPrunedIds, fetchSessionMessages } from "./types" -import { injectNudgeGemini, injectSynthGemini } from "../synth-instruction" +import { injectNudgeGemini, injectSynthGemini } from "../api-formats/synth-instruction" /** * Handles Google/Gemini format (body.contents array with functionResponse parts). diff --git a/lib/fetch-wrapper/index.ts b/lib/fetch-wrapper/index.ts index 75fd7c8e..1b93fb2e 100644 --- a/lib/fetch-wrapper/index.ts +++ b/lib/fetch-wrapper/index.ts @@ -1,12 +1,12 @@ import type { PluginState } from "../state" import type { Logger } from "../logger" import type { FetchHandlerContext, SynthPrompts } from "./types" -import type { ToolTracker } from "../synth-instruction" +import type { ToolTracker } from "../api-formats/synth-instruction" import type { PluginConfig } from "../config" import { handleOpenAIChatAndAnthropic } from "./openai-chat" import { handleGemini } from "./gemini" import { handleOpenAIResponses } from "./openai-responses" -import { detectDuplicates } from "../deduplicator" +import { detectDuplicates } from "../core/deduplicator" export type { FetchHandlerContext, FetchHandlerResult, SynthPrompts } from "./types" diff --git a/lib/fetch-wrapper/openai-chat.ts b/lib/fetch-wrapper/openai-chat.ts index 9aeb6d0a..78b522e5 100644 --- a/lib/fetch-wrapper/openai-chat.ts +++ b/lib/fetch-wrapper/openai-chat.ts @@ -5,8 +5,8 @@ import { fetchSessionMessages, getMostRecentActiveSession } from "./types" -import { cacheToolParametersFromMessages } from "../tool-cache" -import { injectNudge, injectSynth } from "../synth-instruction" +import { cacheToolParametersFromMessages } from "../state/tool-cache" +import { injectNudge, injectSynth } from "../api-formats/synth-instruction" /** * Handles OpenAI Chat Completions format (body.messages with role='tool'). diff --git a/lib/fetch-wrapper/openai-responses.ts b/lib/fetch-wrapper/openai-responses.ts index 77416176..b8a1dbd7 100644 --- a/lib/fetch-wrapper/openai-responses.ts +++ b/lib/fetch-wrapper/openai-responses.ts @@ -5,8 +5,8 @@ import { fetchSessionMessages, getMostRecentActiveSession } from "./types" -import { cacheToolParametersFromInput } from "../tool-cache" -import { injectNudgeResponses, injectSynthResponses } from "../synth-instruction" +import { cacheToolParametersFromInput } from "../state/tool-cache" +import { injectNudgeResponses, injectSynthResponses } from "../api-formats/synth-instruction" /** * Handles OpenAI Responses API format (body.input array with function_call_output items). diff --git a/lib/fetch-wrapper/types.ts b/lib/fetch-wrapper/types.ts index f23baf97..d6cf4aba 100644 --- a/lib/fetch-wrapper/types.ts +++ b/lib/fetch-wrapper/types.ts @@ -1,6 +1,6 @@ import { type PluginState, ensureSessionRestored } from "../state" import type { Logger } from "../logger" -import type { ToolTracker } from "../synth-instruction" +import type { ToolTracker } from "../api-formats/synth-instruction" import type { PluginConfig } from "../config" /** The message used to replace pruned tool output content */ diff --git a/lib/hooks.ts b/lib/hooks.ts index ae9110f3..dac0b540 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -1,10 +1,10 @@ import type { PluginState } from "./state" import type { Logger } from "./logger" -import type { JanitorContext } from "./janitor" -import { runOnIdle } from "./janitor" +import type { JanitorContext } from "./core/janitor" +import { runOnIdle } from "./core/janitor" import type { PluginConfig, PruningStrategy } from "./config" -import type { ToolTracker } from "./synth-instruction" -import { resetToolTrackerCount } from "./synth-instruction" +import type { ToolTracker } from "./api-formats/synth-instruction" +import { resetToolTrackerCount } from "./api-formats/synth-instruction" export async function isSubagentSession(client: any, sessionID: string): Promise { try { diff --git a/lib/pruning-tool.ts b/lib/pruning-tool.ts index 45797c7d..20db977a 100644 --- a/lib/pruning-tool.ts +++ b/lib/pruning-tool.ts @@ -1,11 +1,11 @@ import { tool } from "@opencode-ai/plugin" -import type { JanitorContext } from "./janitor" -import { runOnTool } from "./janitor" -import { formatPruningResultForTool } from "./notification" +import type { JanitorContext } from "./core/janitor" +import { runOnTool } from "./core/janitor" +import { formatPruningResultForTool } from "./ui/notification" import type { PluginConfig } from "./config" -import type { ToolTracker } from "./synth-instruction" -import { resetToolTrackerCount } from "./synth-instruction" -import { loadPrompt } from "./prompt" +import type { ToolTracker } from "./api-formats/synth-instruction" +import { resetToolTrackerCount } from "./api-formats/synth-instruction" +import { loadPrompt } from "./core/prompt" import { isSubagentSession } from "./hooks" /** Tool description for the prune tool, loaded from prompts/tool.txt */ diff --git a/lib/state.ts b/lib/state/index.ts similarity index 94% rename from lib/state.ts rename to lib/state/index.ts index 3bdb4223..167202e3 100644 --- a/lib/state.ts +++ b/lib/state/index.ts @@ -1,6 +1,6 @@ -import type { SessionStats } from "./janitor" -import type { Logger } from "./logger" -import { loadSessionState } from "./state-persistence" +import type { SessionStats } from "../core/janitor" +import type { Logger } from "../logger" +import { loadSessionState } from "./persistence" /** * Centralized state management for the DCP plugin. diff --git a/lib/state-persistence.ts b/lib/state/persistence.ts similarity index 96% rename from lib/state-persistence.ts rename to lib/state/persistence.ts index 384e6106..b394ef20 100644 --- a/lib/state-persistence.ts +++ b/lib/state/persistence.ts @@ -8,8 +8,8 @@ import * as fs from "fs/promises"; import { existsSync } from "fs"; import { homedir } from "os"; import { join } from "path"; -import type { SessionStats } from "./janitor"; -import type { Logger } from "./logger"; +import type { SessionStats } from "../core/janitor"; +import type { Logger } from "../logger"; export interface PersistedSessionState { sessionName?: string; diff --git a/lib/tool-cache.ts b/lib/state/tool-cache.ts similarity index 97% rename from lib/tool-cache.ts rename to lib/state/tool-cache.ts index 669fa0f8..aa57b4b7 100644 --- a/lib/tool-cache.ts +++ b/lib/state/tool-cache.ts @@ -1,4 +1,4 @@ -import type { PluginState } from "./state" +import type { PluginState } from "./index" /** * Cache tool parameters from OpenAI Chat Completions style messages. diff --git a/lib/display-utils.ts b/lib/ui/display-utils.ts similarity index 100% rename from lib/display-utils.ts rename to lib/ui/display-utils.ts diff --git a/lib/notification.ts b/lib/ui/notification.ts similarity index 98% rename from lib/notification.ts rename to lib/ui/notification.ts index e44923d1..1bc4af6c 100644 --- a/lib/notification.ts +++ b/lib/ui/notification.ts @@ -1,6 +1,6 @@ -import type { Logger } from "./logger" -import type { SessionStats, PruningResult } from "./janitor" -import { formatTokenCount } from "./tokenizer" +import type { Logger } from "../logger" +import type { SessionStats, PruningResult } from "../core/janitor" +import { formatTokenCount } from "../tokenizer" import { extractParameterKey } from "./display-utils" export type PruningSummaryLevel = "off" | "minimal" | "detailed" From f447e756daac2f1163e9ca3ab2e902c63bb8b215 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 2 Dec 2025 03:04:17 -0500 Subject: [PATCH 06/11] refactor: add strategies infrastructure and migrate deduplication --- lib/core/deduplicator.ts | 89 ---------------------------- lib/core/strategies/deduplication.ts | 88 +++++++++++++++++++++++++++ lib/core/strategies/index.ts | 72 ++++++++++++++++++++++ lib/core/strategies/types.ts | 43 ++++++++++++++ lib/fetch-wrapper/index.ts | 14 +++-- lib/ui/notification.ts | 2 + 6 files changed, 214 insertions(+), 94 deletions(-) delete mode 100644 lib/core/deduplicator.ts create mode 100644 lib/core/strategies/deduplication.ts create mode 100644 lib/core/strategies/index.ts create mode 100644 lib/core/strategies/types.ts diff --git a/lib/core/deduplicator.ts b/lib/core/deduplicator.ts deleted file mode 100644 index 3be8a4bc..00000000 --- a/lib/core/deduplicator.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { extractParameterKey } from "../ui/display-utils" - -export interface DuplicateDetectionResult { - duplicateIds: string[] // IDs to prune (older duplicates) - deduplicationDetails: Map -} - -export function detectDuplicates( - toolMetadata: Map, - unprunedToolCallIds: string[], // In chronological order - protectedTools: string[] -): DuplicateDetectionResult { - const signatureMap = new Map() - - const deduplicatableIds = unprunedToolCallIds.filter(id => { - const metadata = toolMetadata.get(id) - return !metadata || !protectedTools.includes(metadata.tool) - }) - - for (const id of deduplicatableIds) { - const metadata = toolMetadata.get(id) - if (!metadata) continue - - const signature = createToolSignature(metadata.tool, metadata.parameters) - if (!signatureMap.has(signature)) { - signatureMap.set(signature, []) - } - signatureMap.get(signature)!.push(id) - } - - const duplicateIds: string[] = [] - const deduplicationDetails = new Map() - - for (const [signature, ids] of signatureMap.entries()) { - if (ids.length > 1) { - const metadata = toolMetadata.get(ids[0])! - const idsToRemove = ids.slice(0, -1) // All except last - duplicateIds.push(...idsToRemove) - - deduplicationDetails.set(signature, { - toolName: metadata.tool, - parameterKey: extractParameterKey(metadata), - duplicateCount: ids.length, - prunedIds: idsToRemove, - keptId: ids[ids.length - 1] - }) - } - } - - return { duplicateIds, deduplicationDetails } -} - -function createToolSignature(tool: string, parameters?: any): string { - if (!parameters) return tool - - const normalized = normalizeParameters(parameters) - const sorted = sortObjectKeys(normalized) - return `${tool}::${JSON.stringify(sorted)}` -} - -function normalizeParameters(params: any): any { - if (typeof params !== 'object' || params === null) return params - if (Array.isArray(params)) return params - - const normalized: any = {} - for (const [key, value] of Object.entries(params)) { - if (value !== undefined && value !== null) { - normalized[key] = value - } - } - return normalized -} - -function sortObjectKeys(obj: any): any { - if (typeof obj !== 'object' || obj === null) return obj - if (Array.isArray(obj)) return obj.map(sortObjectKeys) - - const sorted: any = {} - for (const key of Object.keys(obj).sort()) { - sorted[key] = sortObjectKeys(obj[key]) - } - return sorted -} diff --git a/lib/core/strategies/deduplication.ts b/lib/core/strategies/deduplication.ts new file mode 100644 index 00000000..685fa06b --- /dev/null +++ b/lib/core/strategies/deduplication.ts @@ -0,0 +1,88 @@ +import { extractParameterKey } from "../../ui/display-utils" +import type { PruningStrategy, StrategyResult, ToolMetadata } from "./types" + +/** + * Deduplication strategy - prunes older tool calls that have identical + * tool name and parameters, keeping only the most recent occurrence. + */ +export const deduplicationStrategy: PruningStrategy = { + name: "deduplication", + + detect( + toolMetadata: Map, + unprunedIds: string[], + protectedTools: string[] + ): StrategyResult { + const signatureMap = new Map() + + const deduplicatableIds = unprunedIds.filter(id => { + const metadata = toolMetadata.get(id) + return !metadata || !protectedTools.includes(metadata.tool) + }) + + for (const id of deduplicatableIds) { + const metadata = toolMetadata.get(id) + if (!metadata) continue + + const signature = createToolSignature(metadata.tool, metadata.parameters) + if (!signatureMap.has(signature)) { + signatureMap.set(signature, []) + } + signatureMap.get(signature)!.push(id) + } + + const prunedIds: string[] = [] + const details = new Map() + + for (const [signature, ids] of signatureMap.entries()) { + if (ids.length > 1) { + const metadata = toolMetadata.get(ids[0])! + const idsToRemove = ids.slice(0, -1) // All except last + prunedIds.push(...idsToRemove) + + details.set(signature, { + toolName: metadata.tool, + parameterKey: extractParameterKey(metadata), + reason: `duplicate (${ids.length} occurrences, kept most recent)`, + duplicateCount: ids.length, + prunedIds: idsToRemove, + keptId: ids[ids.length - 1] + }) + } + } + + return { prunedIds, details } + } +} + +function createToolSignature(tool: string, parameters?: any): string { + if (!parameters) return tool + + const normalized = normalizeParameters(parameters) + const sorted = sortObjectKeys(normalized) + return `${tool}::${JSON.stringify(sorted)}` +} + +function normalizeParameters(params: any): any { + if (typeof params !== 'object' || params === null) return params + if (Array.isArray(params)) return params + + const normalized: any = {} + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + normalized[key] = value + } + } + return normalized +} + +function sortObjectKeys(obj: any): any { + if (typeof obj !== 'object' || obj === null) return obj + if (Array.isArray(obj)) return obj.map(sortObjectKeys) + + const sorted: any = {} + for (const key of Object.keys(obj).sort()) { + sorted[key] = sortObjectKeys(obj[key]) + } + return sorted +} diff --git a/lib/core/strategies/index.ts b/lib/core/strategies/index.ts new file mode 100644 index 00000000..060bf642 --- /dev/null +++ b/lib/core/strategies/index.ts @@ -0,0 +1,72 @@ +/** + * Strategy runner - executes all enabled pruning strategies and collects results. + */ + +import type { PruningStrategy, StrategyResult, ToolMetadata } from "./types" +import { deduplicationStrategy } from "./deduplication" + +export type { PruningStrategy, StrategyResult, ToolMetadata, StrategyDetail } from "./types" + +/** All available strategies */ +const ALL_STRATEGIES: PruningStrategy[] = [ + deduplicationStrategy, + // Future strategies will be added here: + // errorPruningStrategy, + // writeReadStrategy, + // partialReadStrategy, +] + +export interface RunStrategiesResult { + /** All tool IDs that should be pruned (deduplicated) */ + prunedIds: string[] + /** Results keyed by strategy name */ + byStrategy: Map +} + +/** + * Run all enabled strategies and collect pruned IDs. + * + * @param toolMetadata - Map of tool call ID to metadata + * @param unprunedIds - Tool call IDs not yet pruned (chronological order) + * @param protectedTools - Tool names that should never be pruned + * @param enabledStrategies - Strategy names to run (defaults to all) + */ +export function runStrategies( + toolMetadata: Map, + unprunedIds: string[], + protectedTools: string[], + enabledStrategies?: string[] +): RunStrategiesResult { + const byStrategy = new Map() + const allPrunedIds = new Set() + + // Filter to enabled strategies (or all if not specified) + const strategies = enabledStrategies + ? ALL_STRATEGIES.filter(s => enabledStrategies.includes(s.name)) + : ALL_STRATEGIES + + // Track which IDs are still available for each strategy + let remainingIds = unprunedIds + + for (const strategy of strategies) { + const result = strategy.detect(toolMetadata, remainingIds, protectedTools) + + if (result.prunedIds.length > 0) { + byStrategy.set(strategy.name, result) + + // Add to overall pruned set + for (const id of result.prunedIds) { + allPrunedIds.add(id) + } + + // Remove pruned IDs from remaining for next strategy + const prunedSet = new Set(result.prunedIds.map(id => id.toLowerCase())) + remainingIds = remainingIds.filter(id => !prunedSet.has(id.toLowerCase())) + } + } + + return { + prunedIds: Array.from(allPrunedIds), + byStrategy + } +} diff --git a/lib/core/strategies/types.ts b/lib/core/strategies/types.ts new file mode 100644 index 00000000..a013a0d8 --- /dev/null +++ b/lib/core/strategies/types.ts @@ -0,0 +1,43 @@ +/** + * Common interface for rule-based pruning strategies. + * Each strategy analyzes tool metadata and returns IDs that should be pruned. + */ + +export interface ToolMetadata { + tool: string + parameters?: any +} + +export interface StrategyResult { + /** Tool call IDs that should be pruned */ + prunedIds: string[] + /** Optional details about what was pruned and why */ + details?: Map +} + +export interface StrategyDetail { + toolName: string + parameterKey: string + reason: string + /** Additional info specific to the strategy */ + [key: string]: any +} + +export interface PruningStrategy { + /** Unique identifier for this strategy */ + name: string + + /** + * Analyze tool metadata and determine which tool calls should be pruned. + * + * @param toolMetadata - Map of tool call ID to metadata (tool name + parameters) + * @param unprunedIds - Tool call IDs that haven't been pruned yet (chronological order) + * @param protectedTools - Tool names that should never be pruned + * @returns IDs to prune and optional details + */ + detect( + toolMetadata: Map, + unprunedIds: string[], + protectedTools: string[] + ): StrategyResult +} diff --git a/lib/fetch-wrapper/index.ts b/lib/fetch-wrapper/index.ts index 1b93fb2e..1dbc0123 100644 --- a/lib/fetch-wrapper/index.ts +++ b/lib/fetch-wrapper/index.ts @@ -6,7 +6,7 @@ import type { PluginConfig } from "../config" import { handleOpenAIChatAndAnthropic } from "./openai-chat" import { handleGemini } from "./gemini" import { handleOpenAIResponses } from "./openai-responses" -import { detectDuplicates } from "../core/deduplicator" +import { runStrategies } from "../core/strategies" export type { FetchHandlerContext, FetchHandlerResult, SynthPrompts } from "./types" @@ -79,7 +79,7 @@ export function installFetchWrapper( } } - // Run deduplication after handlers have populated toolParameters cache + // Run strategies after handlers have populated toolParameters cache const sessionId = state.lastSeenSessionId if (sessionId && state.toolParameters.size > 0) { const toolIds = Array.from(state.toolParameters.keys()) @@ -87,10 +87,14 @@ export function installFetchWrapper( const alreadyPrunedLower = new Set(alreadyPruned.map(id => id.toLowerCase())) const unpruned = toolIds.filter(id => !alreadyPrunedLower.has(id.toLowerCase())) if (unpruned.length > 1) { - const { duplicateIds } = detectDuplicates(state.toolParameters, unpruned, config.protectedTools) - if (duplicateIds.length > 0) { + const result = runStrategies( + state.toolParameters, + unpruned, + config.protectedTools + ) + if (result.prunedIds.length > 0) { // Normalize to lowercase to match janitor's ID normalization - const normalizedIds = duplicateIds.map(id => id.toLowerCase()) + const normalizedIds = result.prunedIds.map(id => id.toLowerCase()) state.prunedIds.set(sessionId, [...new Set([...alreadyPruned, ...normalizedIds])]) } } diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index 1bc4af6c..b28f9f11 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -159,6 +159,8 @@ export function formatPruningResultForTool( // ============================================================================ // Summary building helpers +// Groups pruned tool IDs by tool name with their key parameter (file path, command, etc.) +// for human-readable display: e.g. "read (3): foo.ts, bar.ts, baz.ts" // ============================================================================ export function buildToolsSummary( From 87fe1c44a28a73e4eb021d07e7996ea36bb53b37 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 2 Dec 2025 03:17:50 -0500 Subject: [PATCH 07/11] chore: update opencode-auth-provider to 0.1.7 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2845ba87..1953c96c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@ai-sdk/openai-compatible": "^1.0.27", "@opencode-ai/sdk": "latest", - "@tarquinen/opencode-auth-provider": "^0.1.6", + "@tarquinen/opencode-auth-provider": "^0.1.7", "ai": "^5.0.98", "gpt-tokenizer": "^3.4.0", "jsonc-parser": "^3.3.1", @@ -1923,9 +1923,9 @@ "license": "MIT" }, "node_modules/@tarquinen/opencode-auth-provider": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@tarquinen/opencode-auth-provider/-/opencode-auth-provider-0.1.6.tgz", - "integrity": "sha512-P1r318UtXAnkLodcVNpEX0PZP1wOhsvJTP4aX3LB958HCKc3JNz1JZecCeggBtEtHlz/NVLkGWiG5M5YCWCTDQ==", + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@tarquinen/opencode-auth-provider/-/opencode-auth-provider-0.1.7.tgz", + "integrity": "sha512-FH1QEyoirr2e8b48Z6HrjioIZIZUIM9zOpYmku1ad+c4Nv70F37fSWhcObyIdZo4Ly3OntpKPWjadyRhd/kQcg==", "license": "MIT", "dependencies": { "@aws-sdk/credential-providers": "^3.936.0", diff --git a/package.json b/package.json index e5c80147..ba731fa5 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "dependencies": { "@ai-sdk/openai-compatible": "^1.0.27", "@opencode-ai/sdk": "latest", - "@tarquinen/opencode-auth-provider": "^0.1.6", + "@tarquinen/opencode-auth-provider": "^0.1.7", "ai": "^5.0.98", "gpt-tokenizer": "^3.4.0", "jsonc-parser": "^3.3.1", From 4800cf73d6a0d2f5f2143bea32aef2b8c30772ae Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 2 Dec 2025 16:00:38 -0500 Subject: [PATCH 08/11] feat: unified notification system with GC tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GCStats type and gcPending state to track deduplication activity - Accumulate GC stats during fetch when runStrategies prunes duplicates - Rewrite notification system to combine AI analysis and GC in one display - Show GC-only notifications when AI prunes nothing but deduplication occurred - Track totalGCTokens in session stats for lifetime GC contribution - Display format: '🧹 DCP: ~20K saved (10 tools, ā™»ļø ~500) │ Session: ...' - GC-only format: 'ā™»ļø DCP: ~500 collected │ Session: ...' --- lib/core/janitor.ts | 75 ++++++++++--- lib/fetch-wrapper/gc-tracker.ts | 77 +++++++++++++ lib/fetch-wrapper/index.ts | 4 + lib/state/index.ts | 30 ++--- lib/ui/notification.ts | 187 ++++++++++++++++++-------------- 5 files changed, 251 insertions(+), 122 deletions(-) create mode 100644 lib/fetch-wrapper/gc-tracker.ts diff --git a/lib/core/janitor.ts b/lib/core/janitor.ts index 454eb2fa..726170cb 100644 --- a/lib/core/janitor.ts +++ b/lib/core/janitor.ts @@ -8,17 +8,19 @@ import { estimateTokensBatch, formatTokenCount } from "../tokenizer" import { saveSessionState } from "../state/persistence" import { ensureSessionRestored } from "../state" import { - sendPruningSummary, + sendUnifiedNotification, type NotificationContext } from "../ui/notification" -// ============================================================================ -// Types -// ============================================================================ - export interface SessionStats { totalToolsPruned: number totalTokensSaved: number + totalGCTokens: number +} + +export interface GCStats { + tokensCollected: number + toolsDeduped: number } export interface PruningResult { @@ -136,7 +138,11 @@ async function runWithStrategies( const alreadyPrunedIds = state.prunedIds.get(sessionID) ?? [] const unprunedToolCallIds = toolCallIds.filter(id => !alreadyPrunedIds.includes(id)) - if (unprunedToolCallIds.length === 0) { + // Get pending GC stats (accumulated since last notification) + const gcPending = state.gcPending.get(sessionID) ?? null + + // If nothing to analyze and no GC activity, exit early + if (unprunedToolCallIds.length === 0 && !gcPending) { return null } @@ -148,7 +154,7 @@ async function runWithStrategies( // PHASE 1: LLM ANALYSIS let llmPrunedIds: string[] = [] - if (strategies.includes('ai-analysis')) { + if (strategies.includes('ai-analysis') && unprunedToolCallIds.length > 0) { llmPrunedIds = await runLlmAnalysis( ctx, sessionID, @@ -161,33 +167,62 @@ async function runWithStrategies( ) } - if (llmPrunedIds.length === 0) { + const finalNewlyPrunedIds = llmPrunedIds.filter(id => !alreadyPrunedIds.includes(id)) + + // If AI pruned nothing and no GC activity, nothing to report + if (finalNewlyPrunedIds.length === 0 && !gcPending) { return null } - const finalNewlyPrunedIds = llmPrunedIds.filter(id => !alreadyPrunedIds.includes(id)) - // PHASE 2: CALCULATE STATS & NOTIFICATION const tokensSaved = await calculateTokensSaved(finalNewlyPrunedIds, toolOutputs) - const currentStats = state.stats.get(sessionID) ?? { totalToolsPruned: 0, totalTokensSaved: 0 } + // Get current session stats, initializing with proper defaults + const currentStats = state.stats.get(sessionID) ?? { + totalToolsPruned: 0, + totalTokensSaved: 0, + totalGCTokens: 0 + } + + // Update session stats including GC contribution const sessionStats: SessionStats = { totalToolsPruned: currentStats.totalToolsPruned + finalNewlyPrunedIds.length, - totalTokensSaved: currentStats.totalTokensSaved + tokensSaved + totalTokensSaved: currentStats.totalTokensSaved + tokensSaved, + totalGCTokens: currentStats.totalGCTokens + (gcPending?.tokensCollected ?? 0) } state.stats.set(sessionID, sessionStats) - await sendPruningSummary( + // Send unified notification (handles all scenarios) + const notificationSent = await sendUnifiedNotification( ctx.notificationCtx, sessionID, - llmPrunedIds, - toolMetadata, - tokensSaved, - sessionStats, + { + aiPrunedCount: llmPrunedIds.length, + aiTokensSaved: tokensSaved, + aiPrunedIds: llmPrunedIds, + toolMetadata, + gcPending, + sessionStats + }, currentAgent ) - // PHASE 3: STATE UPDATE + // Clear pending GC stats after notification (whether sent or not - we've consumed them) + if (gcPending) { + state.gcPending.delete(sessionID) + } + + // If we only had GC activity (no AI pruning), return null but notification was sent + if (finalNewlyPrunedIds.length === 0) { + if (notificationSent) { + logger.info("janitor", `GC-only notification: ~${formatTokenCount(gcPending?.tokensCollected ?? 0)} tokens from ${gcPending?.toolsDeduped ?? 0} deduped tools`, { + trigger: options.trigger + }) + } + return null + } + + // PHASE 3: STATE UPDATE (only if AI pruned something) const allPrunedIds = [...new Set([...alreadyPrunedIds, ...llmPrunedIds])] state.prunedIds.set(sessionID, allPrunedIds) @@ -203,6 +238,10 @@ async function runWithStrategies( if (options.reason) { logMeta.reason = options.reason } + if (gcPending) { + logMeta.gcTokens = gcPending.tokensCollected + logMeta.gcTools = gcPending.toolsDeduped + } logger.info("janitor", `Pruned ${prunedCount}/${candidateCount} tools, ${keptCount} kept (~${formatTokenCount(tokensSaved)} tokens)`, logMeta) diff --git a/lib/fetch-wrapper/gc-tracker.ts b/lib/fetch-wrapper/gc-tracker.ts new file mode 100644 index 00000000..9119d89c --- /dev/null +++ b/lib/fetch-wrapper/gc-tracker.ts @@ -0,0 +1,77 @@ +import type { PluginState } from "../state" +import type { Logger } from "../logger" + +export function accumulateGCStats( + state: PluginState, + sessionId: string, + prunedIds: string[], + body: any, + logger: Logger +): void { + if (prunedIds.length === 0) return + + const toolOutputs = extractToolOutputsFromBody(body, prunedIds) + 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 + }) + + logger.debug("gc-tracker", "Accumulated GC stats", { + sessionId: sessionId.substring(0, 8), + newlyDeduped: prunedIds.length, + tokensThisCycle: tokensCollected, + pendingTotal: state.gcPending.get(sessionId) + }) +} + +function extractToolOutputsFromBody(body: any, prunedIds: string[]): string[] { + const outputs: string[] = [] + const prunedIdSet = new Set(prunedIds.map(id => id.toLowerCase())) + + // OpenAI Chat format + if (body.messages && Array.isArray(body.messages)) { + for (const m of body.messages) { + if (m.role === 'tool' && m.tool_call_id && prunedIdSet.has(m.tool_call_id.toLowerCase())) { + if (typeof m.content === 'string') { + outputs.push(m.content) + } + } + // Anthropic format + if (m.role === 'user' && Array.isArray(m.content)) { + for (const part of m.content) { + if (part.type === 'tool_result' && part.tool_use_id && prunedIdSet.has(part.tool_use_id.toLowerCase())) { + if (typeof part.content === 'string') { + outputs.push(part.content) + } + } + } + } + } + } + + // OpenAI Responses format + if (body.input && Array.isArray(body.input)) { + for (const item of body.input) { + if (item.type === 'function_call_output' && item.call_id && prunedIdSet.has(item.call_id.toLowerCase())) { + if (typeof item.output === 'string') { + outputs.push(item.output) + } + } + } + } + + return outputs +} + +// Character-based approximation (chars / 4) to avoid async tokenizer in fetch path +function estimateTokensFromOutputs(outputs: string[]): number { + let totalChars = 0 + for (const output of outputs) { + totalChars += output.length + } + return Math.round(totalChars / 4) +} diff --git a/lib/fetch-wrapper/index.ts b/lib/fetch-wrapper/index.ts index 1dbc0123..450e99c8 100644 --- a/lib/fetch-wrapper/index.ts +++ b/lib/fetch-wrapper/index.ts @@ -7,6 +7,7 @@ import { handleOpenAIChatAndAnthropic } from "./openai-chat" import { handleGemini } from "./gemini" import { handleOpenAIResponses } from "./openai-responses" import { runStrategies } from "../core/strategies" +import { accumulateGCStats } from "./gc-tracker" export type { FetchHandlerContext, FetchHandlerResult, SynthPrompts } from "./types" @@ -96,6 +97,9 @@ export function installFetchWrapper( // Normalize to lowercase to match janitor's ID normalization const normalizedIds = result.prunedIds.map(id => id.toLowerCase()) state.prunedIds.set(sessionId, [...new Set([...alreadyPruned, ...normalizedIds])]) + + // Track GC activity for the next notification + accumulateGCStats(state, sessionId, result.prunedIds, body, logger) } } } diff --git a/lib/state/index.ts b/lib/state/index.ts index 167202e3..b48c6569 100644 --- a/lib/state/index.ts +++ b/lib/state/index.ts @@ -1,32 +1,17 @@ -import type { SessionStats } from "../core/janitor" +import type { SessionStats, GCStats } from "../core/janitor" import type { Logger } from "../logger" import { loadSessionState } from "./persistence" -/** - * Centralized state management for the DCP plugin. - * All mutable state is stored here and shared across modules. - */ export interface PluginState { - /** Map of session IDs to arrays of pruned tool call IDs */ prunedIds: Map - /** Map of session IDs to session statistics */ stats: Map - /** Cache of tool call IDs to their parameters */ + gcPending: Map toolParameters: Map - /** Cache of session IDs to their model info */ model: Map - /** - * Maps Google/Gemini tool positions to OpenCode tool call IDs for correlation. - * Key: sessionID, Value: Map where positionKey is "toolName:index" - */ googleToolCallMapping: Map> - /** Set of session IDs that have been restored from disk */ restoredSessions: Set - /** Set of session IDs we've already checked for subagent status (to avoid redundant API calls) */ checkedSessions: Set - /** Set of session IDs that are subagents (have a parentID) - used to skip fetch wrapper processing */ subagentSessions: Set - /** The most recent session ID seen in chat.params - used to correlate fetch requests */ lastSeenSessionId: string | null } @@ -40,13 +25,11 @@ export interface ModelInfo { modelID: string } -/** - * Creates a fresh plugin state instance. - */ export function createPluginState(): PluginState { return { prunedIds: new Map(), stats: new Map(), + gcPending: new Map(), toolParameters: new Map(), model: new Map(), googleToolCallMapping: new Map(), @@ -78,7 +61,12 @@ export async function ensureSessionRestored( }) } if (!state.stats.has(sessionId)) { - state.stats.set(sessionId, persisted.stats) + const stats: SessionStats = { + totalToolsPruned: persisted.stats.totalToolsPruned, + totalTokensSaved: persisted.stats.totalTokensSaved, + totalGCTokens: persisted.stats.totalGCTokens ?? 0 + } + state.stats.set(sessionId, stats) } } } diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index b28f9f11..c294a756 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -1,5 +1,5 @@ import type { Logger } from "../logger" -import type { SessionStats, PruningResult } from "../core/janitor" +import type { SessionStats, GCStats, PruningResult } from "../core/janitor" import { formatTokenCount } from "../tokenizer" import { extractParameterKey } from "./display-utils" @@ -16,9 +16,14 @@ export interface NotificationContext { config: NotificationConfig } -// ============================================================================ -// Core notification sending -// ============================================================================ +export interface NotificationData { + aiPrunedCount: number + aiTokensSaved: number + aiPrunedIds: string[] + toolMetadata: Map + gcPending: GCStats | null + sessionStats: SessionStats | null +} export async function sendIgnoredMessage( ctx: NotificationContext, @@ -44,101 +49,127 @@ export async function sendIgnoredMessage( } } -// ============================================================================ -// Pruning notifications -// ============================================================================ - -export async function sendPruningSummary( +export async function sendUnifiedNotification( ctx: NotificationContext, sessionID: string, - llmPrunedIds: string[], - toolMetadata: Map, - tokensSaved: number, - sessionStats: SessionStats, + data: NotificationData, agent?: string -): Promise { - const totalPruned = llmPrunedIds.length - if (totalPruned === 0) return - if (ctx.config.pruningSummary === 'off') return +): Promise { + const hasAiPruning = data.aiPrunedCount > 0 + const hasGcActivity = data.gcPending && data.gcPending.toolsDeduped > 0 + + if (!hasAiPruning && !hasGcActivity) { + return false + } - if (ctx.config.pruningSummary === 'minimal') { - await sendMinimalSummary(ctx, sessionID, totalPruned, tokensSaved, sessionStats, agent) - return + if (ctx.config.pruningSummary === 'off') { + return false } - await sendDetailedSummary(ctx, sessionID, llmPrunedIds, toolMetadata, tokensSaved, sessionStats, agent) + const message = ctx.config.pruningSummary === 'minimal' + ? buildMinimalMessage(data) + : buildDetailedMessage(data, ctx.config.workingDirectory) + + await sendIgnoredMessage(ctx, sessionID, message, agent) + return true } -async function sendMinimalSummary( - ctx: NotificationContext, - sessionID: string, - totalPruned: number, - tokensSaved: number, - sessionStats: SessionStats, - agent?: string -): Promise { - if (totalPruned === 0) return +function buildMinimalMessage(data: NotificationData): string { + const hasAiPruning = data.aiPrunedCount > 0 + const hasGcActivity = data.gcPending && data.gcPending.toolsDeduped > 0 - const tokensFormatted = formatTokenCount(tokensSaved) - const toolText = totalPruned === 1 ? 'tool' : 'tools' + if (hasAiPruning) { + const tokensSaved = formatTokenCount(data.aiTokensSaved) + const toolText = data.aiPrunedCount === 1 ? 'tool' : 'tools' - let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${totalPruned} ${toolText} pruned)` + let cycleStats = `${data.aiPrunedCount} ${toolText}` + if (hasGcActivity) { + cycleStats += `, ā™»ļø ~${formatTokenCount(data.gcPending!.tokensCollected)}` + } - if (sessionStats.totalToolsPruned > totalPruned) { - message += ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools` - } + let message = `🧹 DCP: ~${tokensSaved} saved (${cycleStats})` + message += buildSessionSuffix(data.sessionStats, data.aiPrunedCount) - await sendIgnoredMessage(ctx, sessionID, message, agent) + return message + } else { + const tokensCollected = formatTokenCount(data.gcPending!.tokensCollected) + + let message = `ā™»ļø DCP: ~${tokensCollected} collected` + message += buildSessionSuffix(data.sessionStats, 0) + + return message + } } -async function sendDetailedSummary( - ctx: NotificationContext, - sessionID: string, - llmPrunedIds: string[], - toolMetadata: Map, - tokensSaved: number, - sessionStats: SessionStats, - agent?: string -): Promise { - const totalPruned = llmPrunedIds.length - const tokensFormatted = formatTokenCount(tokensSaved) +function buildDetailedMessage(data: NotificationData, workingDirectory?: string): string { + const hasAiPruning = data.aiPrunedCount > 0 + const hasGcActivity = data.gcPending && data.gcPending.toolsDeduped > 0 - let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${totalPruned} tool${totalPruned > 1 ? 's' : ''} pruned)` + let message: string - if (sessionStats.totalToolsPruned > totalPruned) { - message += ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools` - } - message += '\n' + if (hasAiPruning) { + const tokensSaved = formatTokenCount(data.aiTokensSaved) + const toolText = data.aiPrunedCount === 1 ? 'tool' : 'tools' + + let cycleStats = `${data.aiPrunedCount} ${toolText}` + if (hasGcActivity) { + cycleStats += `, ā™»ļø ~${formatTokenCount(data.gcPending!.tokensCollected)}` + } - message += `\nšŸ¤– LLM analysis (${llmPrunedIds.length}):\n` - const toolsSummary = buildToolsSummary(llmPrunedIds, toolMetadata, ctx.config.workingDirectory) + message = `🧹 DCP: ~${tokensSaved} saved (${cycleStats})` + message += buildSessionSuffix(data.sessionStats, data.aiPrunedCount) + message += '\n' - for (const [toolName, params] of toolsSummary.entries()) { - if (params.length > 0) { - message += ` ${toolName} (${params.length}):\n` - for (const param of params) { - message += ` ${param}\n` + message += `\nšŸ¤– LLM analysis (${data.aiPrunedIds.length}):\n` + const toolsSummary = buildToolsSummary(data.aiPrunedIds, data.toolMetadata, workingDirectory) + + for (const [toolName, params] of toolsSummary.entries()) { + if (params.length > 0) { + message += ` ${toolName} (${params.length}):\n` + for (const param of params) { + message += ` ${param}\n` + } } } - } - const foundToolNames = new Set(toolsSummary.keys()) - const missingTools = llmPrunedIds.filter(id => { - const normalizedId = id.toLowerCase() - const metadata = toolMetadata.get(normalizedId) - return !metadata || !foundToolNames.has(metadata.tool) - }) + const foundToolNames = new Set(toolsSummary.keys()) + const missingTools = data.aiPrunedIds.filter(id => { + const normalizedId = id.toLowerCase() + const metadata = data.toolMetadata.get(normalizedId) + return !metadata || !foundToolNames.has(metadata.tool) + }) + + if (missingTools.length > 0) { + message += ` (${missingTools.length} tool${missingTools.length > 1 ? 's' : ''} with unknown metadata)\n` + } + } else { + const tokensCollected = formatTokenCount(data.gcPending!.tokensCollected) - if (missingTools.length > 0) { - message += ` (${missingTools.length} tool${missingTools.length > 1 ? 's' : ''} with unknown metadata)\n` + message = `ā™»ļø DCP: ~${tokensCollected} collected` + message += buildSessionSuffix(data.sessionStats, 0) } - await sendIgnoredMessage(ctx, sessionID, message.trim(), agent) + return message.trim() } -// ============================================================================ -// Formatting for tool output -// ============================================================================ +function buildSessionSuffix(sessionStats: SessionStats | null, currentAiPruned: number): string { + if (!sessionStats) { + return '' + } + + if (sessionStats.totalToolsPruned <= currentAiPruned) { + return '' + } + + let suffix = ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} (${sessionStats.totalToolsPruned} tools` + + if (sessionStats.totalGCTokens > 0) { + suffix += `, ā™»ļø ~${formatTokenCount(sessionStats.totalGCTokens)}` + } + + suffix += ')' + return suffix +} export function formatPruningResultForTool( result: PruningResult, @@ -157,12 +188,6 @@ export function formatPruningResultForTool( return lines.join('\n').trim() } -// ============================================================================ -// Summary building helpers -// Groups pruned tool IDs by tool name with their key parameter (file path, command, etc.) -// for human-readable display: e.g. "read (3): foo.ts, bar.ts, baz.ts" -// ============================================================================ - export function buildToolsSummary( prunedIds: string[], toolMetadata: Map, @@ -212,10 +237,6 @@ export function formatToolSummaryLines( return lines } -// ============================================================================ -// Path utilities -// ============================================================================ - function truncate(str: string, maxLen: number = 60): string { if (str.length <= maxLen) return str return str.slice(0, maxLen - 3) + '...' From 0d9fb234da78191d258e886726b84e381d070c4f Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 2 Dec 2025 16:22:47 -0500 Subject: [PATCH 09/11] fix: show combined AI + GC tokens in notification headline --- lib/ui/notification.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index c294a756..1bb4d857 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -79,7 +79,8 @@ function buildMinimalMessage(data: NotificationData): string { const hasGcActivity = data.gcPending && data.gcPending.toolsDeduped > 0 if (hasAiPruning) { - const tokensSaved = formatTokenCount(data.aiTokensSaved) + const gcTokens = hasGcActivity ? data.gcPending!.tokensCollected : 0 + const totalSaved = formatTokenCount(data.aiTokensSaved + gcTokens) const toolText = data.aiPrunedCount === 1 ? 'tool' : 'tools' let cycleStats = `${data.aiPrunedCount} ${toolText}` @@ -87,7 +88,7 @@ function buildMinimalMessage(data: NotificationData): string { cycleStats += `, ā™»ļø ~${formatTokenCount(data.gcPending!.tokensCollected)}` } - let message = `🧹 DCP: ~${tokensSaved} saved (${cycleStats})` + let message = `🧹 DCP: ~${totalSaved} saved (${cycleStats})` message += buildSessionSuffix(data.sessionStats, data.aiPrunedCount) return message @@ -108,7 +109,8 @@ function buildDetailedMessage(data: NotificationData, workingDirectory?: string) let message: string if (hasAiPruning) { - const tokensSaved = formatTokenCount(data.aiTokensSaved) + const gcTokens = hasGcActivity ? data.gcPending!.tokensCollected : 0 + const totalSaved = formatTokenCount(data.aiTokensSaved + gcTokens) const toolText = data.aiPrunedCount === 1 ? 'tool' : 'tools' let cycleStats = `${data.aiPrunedCount} ${toolText}` @@ -116,7 +118,7 @@ function buildDetailedMessage(data: NotificationData, workingDirectory?: string) cycleStats += `, ā™»ļø ~${formatTokenCount(data.gcPending!.tokensCollected)}` } - message = `🧹 DCP: ~${tokensSaved} saved (${cycleStats})` + message = `🧹 DCP: ~${totalSaved} saved (${cycleStats})` message += buildSessionSuffix(data.sessionStats, data.aiPrunedCount) message += '\n' From 42ba48f7a8ba85232463814c2e884663639b5056 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 2 Dec 2025 16:25:55 -0500 Subject: [PATCH 10/11] chore: change GC icon from recycle to wastebasket --- lib/ui/notification.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index 1bb4d857..c518df48 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -85,7 +85,7 @@ function buildMinimalMessage(data: NotificationData): string { let cycleStats = `${data.aiPrunedCount} ${toolText}` if (hasGcActivity) { - cycleStats += `, ā™»ļø ~${formatTokenCount(data.gcPending!.tokensCollected)}` + cycleStats += `, šŸ—‘ļø ~${formatTokenCount(data.gcPending!.tokensCollected)}` } let message = `🧹 DCP: ~${totalSaved} saved (${cycleStats})` @@ -95,7 +95,7 @@ function buildMinimalMessage(data: NotificationData): string { } else { const tokensCollected = formatTokenCount(data.gcPending!.tokensCollected) - let message = `ā™»ļø DCP: ~${tokensCollected} collected` + let message = `šŸ—‘ļø DCP: ~${tokensCollected} collected` message += buildSessionSuffix(data.sessionStats, 0) return message @@ -115,7 +115,7 @@ function buildDetailedMessage(data: NotificationData, workingDirectory?: string) let cycleStats = `${data.aiPrunedCount} ${toolText}` if (hasGcActivity) { - cycleStats += `, ā™»ļø ~${formatTokenCount(data.gcPending!.tokensCollected)}` + cycleStats += `, šŸ—‘ļø ~${formatTokenCount(data.gcPending!.tokensCollected)}` } message = `🧹 DCP: ~${totalSaved} saved (${cycleStats})` @@ -147,7 +147,7 @@ function buildDetailedMessage(data: NotificationData, workingDirectory?: string) } else { const tokensCollected = formatTokenCount(data.gcPending!.tokensCollected) - message = `ā™»ļø DCP: ~${tokensCollected} collected` + message = `šŸ—‘ļø DCP: ~${tokensCollected} collected` message += buildSessionSuffix(data.sessionStats, 0) } @@ -166,7 +166,7 @@ function buildSessionSuffix(sessionStats: SessionStats | null, currentAiPruned: let suffix = ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} (${sessionStats.totalToolsPruned} tools` if (sessionStats.totalGCTokens > 0) { - suffix += `, ā™»ļø ~${formatTokenCount(sessionStats.totalGCTokens)}` + suffix += `, šŸ—‘ļø ~${formatTokenCount(sessionStats.totalGCTokens)}` } suffix += ')' From 5e84f1a97f1ebda9c6ec6414773acde95a4b2a33 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 2 Dec 2025 16:49:38 -0500 Subject: [PATCH 11/11] fix: include GC tokens in session total and improve icon placement --- lib/ui/notification.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index c518df48..7ea57722 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -85,7 +85,7 @@ function buildMinimalMessage(data: NotificationData): string { let cycleStats = `${data.aiPrunedCount} ${toolText}` if (hasGcActivity) { - cycleStats += `, šŸ—‘ļø ~${formatTokenCount(data.gcPending!.tokensCollected)}` + cycleStats += `, ~${formatTokenCount(data.gcPending!.tokensCollected)} šŸ—‘ļø` } let message = `🧹 DCP: ~${totalSaved} saved (${cycleStats})` @@ -115,7 +115,7 @@ function buildDetailedMessage(data: NotificationData, workingDirectory?: string) let cycleStats = `${data.aiPrunedCount} ${toolText}` if (hasGcActivity) { - cycleStats += `, šŸ—‘ļø ~${formatTokenCount(data.gcPending!.tokensCollected)}` + cycleStats += `, ~${formatTokenCount(data.gcPending!.tokensCollected)} šŸ—‘ļø` } message = `🧹 DCP: ~${totalSaved} saved (${cycleStats})` @@ -163,10 +163,11 @@ function buildSessionSuffix(sessionStats: SessionStats | null, currentAiPruned: return '' } - let suffix = ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} (${sessionStats.totalToolsPruned} tools` + const totalSaved = sessionStats.totalTokensSaved + sessionStats.totalGCTokens + let suffix = ` │ Session: ~${formatTokenCount(totalSaved)} (${sessionStats.totalToolsPruned} tools` if (sessionStats.totalGCTokens > 0) { - suffix += `, šŸ—‘ļø ~${formatTokenCount(sessionStats.totalGCTokens)}` + suffix += `, ~${formatTokenCount(sessionStats.totalGCTokens)} šŸ—‘ļø` } suffix += ')'