diff --git a/index.ts b/index.ts index e8585c9..77c73fa 100644 --- a/index.ts +++ b/index.ts @@ -4,35 +4,16 @@ import { getConfig } from "./lib/config" import { Logger } from "./lib/logger" import { StateManager } from "./lib/state" import { Janitor } from "./lib/janitor" -import { join } from "path" -import { homedir } from "os" /** * Checks if a session is a subagent (child session) * Subagent sessions should skip pruning operations */ -async function isSubagentSession( - client: any, - sessionID: string, - logger: Logger -): Promise { +async function isSubagentSession(client: any, sessionID: string): Promise { try { const result = await client.session.get({ path: { id: sessionID } }) - - if (result.data?.parentID) { - logger.debug("subagent-check", "Detected subagent session, skipping pruning", { - sessionID, - parentID: result.data.parentID - }) - return true - } - - return false + return !!result.data?.parentID } catch (error: any) { - logger.error("subagent-check", "Failed to check if session is subagent", { - sessionID, - error: error.message - }) // On error, assume it's not a subagent and continue (fail open) return false } @@ -58,7 +39,7 @@ const plugin: Plugin = (async (ctx) => { const modelCache = new Map() // sessionID -> model info const janitor = new Janitor(ctx.client, stateManager, logger, toolParametersCache, config.protectedTools, modelCache, config.model, config.showModelErrorToasts, config.pruningMode, config.pruning_summary, ctx.directory) - const cacheToolParameters = (messages: any[], component: string) => { + const cacheToolParameters = (messages: any[]) => { for (const message of messages) { if (message.role !== 'assistant' || !Array.isArray(message.tool_calls)) { continue @@ -77,11 +58,6 @@ const plugin: Plugin = (async (ctx) => { tool: toolCall.function.name, parameters: params }) - logger.debug(component, "Cached tool parameters", { - callID: toolCall.id, - tool: toolCall.function.name, - hasParams: !!params - }) } catch (error) { // Ignore JSON parse errors for individual tool calls } @@ -97,21 +73,12 @@ const plugin: Plugin = (async (ctx) => { try { const body = JSON.parse(init.body) if (body.messages && Array.isArray(body.messages)) { - logger.info("global-fetch", "๐Ÿ”ฅ AI REQUEST INTERCEPTED via global fetch!", { - url: typeof input === 'string' ? input.substring(0, 80) : 'URL object', - messageCount: body.messages.length - }) - // Cache tool parameters for janitor metadata - cacheToolParameters(body.messages, "global-fetch") - - // Always save wrapped context if debug is enabled (even when no tool messages) - // This captures janitor's AI inference which has messageCount=1 (just prompt) - const shouldLogAllRequests = logger.enabled - + cacheToolParameters(body.messages) + // Check for tool messages that might need pruning const toolMessages = body.messages.filter((m: any) => m.role === 'tool') - + // Collect all pruned IDs across all sessions (excluding subagents) // This is safe because tool_call_ids are globally unique const allSessions = await ctx.client.session.list() @@ -119,115 +86,50 @@ const plugin: Plugin = (async (ctx) => { if (allSessions.data) { for (const session of allSessions.data) { - // Skip subagent sessions (don't log - it's normal and would spam logs) - if (session.parentID) { - continue - } - + if (session.parentID) continue // Skip subagent sessions const prunedIds = await stateManager.get(session.id) prunedIds.forEach(id => allPrunedIds.add(id)) } } - // Only process tool message replacement if there are tool messages - if (toolMessages.length > 0) { - logger.debug("global-fetch", "Found tool messages in request", { - toolMessageCount: toolMessages.length, - toolCallIds: toolMessages.map((m: any) => m.tool_call_id).slice(0, 5) - }) - - if (allPrunedIds.size > 0) { - let replacedCount = 0 - const originalMessages = JSON.parse(JSON.stringify(body.messages)) // Deep copy for logging - - body.messages = body.messages.map((m: any) => { - // Normalize ID to lowercase for case-insensitive matching - if (m.role === 'tool' && allPrunedIds.has(m.tool_call_id?.toLowerCase())) { - replacedCount++ - return { - ...m, - content: '[Output removed to save context - information superseded or no longer needed]' - } - } - return m - }) + // Only process tool message replacement if there are tool messages and pruned IDs + if (toolMessages.length > 0 && allPrunedIds.size > 0) { + let replacedCount = 0 - if (replacedCount > 0) { - logger.info("global-fetch", "โœ‚๏ธ Replaced pruned tool messages", { - totalPrunedIds: allPrunedIds.size, - replacedCount: replacedCount, - totalMessages: body.messages.length - }) + body.messages = body.messages.map((m: any) => { + // Normalize ID to lowercase for case-insensitive matching + if (m.role === 'tool' && allPrunedIds.has(m.tool_call_id?.toLowerCase())) { + replacedCount++ + return { + ...m, + content: '[Output removed to save context - information superseded or no longer needed]' + } + } + return m + }) - // Save wrapped context to file if debug is enabled - await logger.saveWrappedContext( - "global", // Use "global" as session ID since we don't know which session this is - body.messages, - { - url: typeof input === 'string' ? input : 'URL object', - totalPrunedIds: allPrunedIds.size, - replacedCount, - totalMessages: body.messages.length, - originalMessageCount: originalMessages.length - } - ) + if (replacedCount > 0) { + logger.info("fetch", "Replaced pruned tool outputs", { + replaced: replacedCount, + total: toolMessages.length + }) - // Update the request body with modified messages - init.body = JSON.stringify(body) - } else if (shouldLogAllRequests) { - // Log even when no replacements occurred (tool messages exist but none were pruned) + // Save wrapped context to file if debug is enabled + if (logger.enabled) { await logger.saveWrappedContext( "global", body.messages, { url: typeof input === 'string' ? input : 'URL object', - totalPrunedIds: allPrunedIds.size, - replacedCount: 0, - totalMessages: body.messages.length, - toolMessageCount: toolMessages.length, - note: "Tool messages exist but none were replaced" + replacedCount, + totalMessages: body.messages.length } ) } - } else if (shouldLogAllRequests) { - // Log when tool messages exist but no pruned IDs exist yet - await logger.saveWrappedContext( - "global", - body.messages, - { - url: typeof input === 'string' ? input : 'URL object', - totalPrunedIds: 0, - replacedCount: 0, - totalMessages: body.messages.length, - toolMessageCount: toolMessages.length, - note: "No pruned IDs exist yet" - } - ) + + // Update the request body with modified messages + init.body = JSON.stringify(body) } - } else if (shouldLogAllRequests) { - // Log requests with NO tool messages (e.g., janitor's shadow inference) - // Detect if this is a janitor request by checking the prompt content - const isJanitorRequest = body.messages.length === 1 && - body.messages[0]?.role === 'user' && - typeof body.messages[0]?.content === 'string' && - body.messages[0].content.includes('conversation analyzer that identifies obsolete tool outputs') - - const sessionId = isJanitorRequest ? "janitor-shadow" : "global" - - await logger.saveWrappedContext( - sessionId, - body.messages, - { - url: typeof input === 'string' ? input : 'URL object', - totalPrunedIds: allPrunedIds.size, - replacedCount: 0, - totalMessages: body.messages.length, - toolMessageCount: 0, - note: isJanitorRequest - ? "Janitor shadow inference with embedded session history in prompt" - : "No tool messages in request (likely title generation or other inference)" - } - ) } } } catch (e) { @@ -238,17 +140,9 @@ const plugin: Plugin = (async (ctx) => { return originalGlobalFetch(input, init) } - logger.info("plugin", "Dynamic Context Pruning plugin initialized", { - enabled: config.enabled, - debug: config.debug, - protectedTools: config.protectedTools, - model: config.model, - pruningMode: config.pruningMode, - pruning_summary: config.pruning_summary, - globalConfigFile: join(homedir(), ".config", "opencode", "dcp.jsonc"), - projectConfigFile: ctx.directory ? join(ctx.directory, ".opencode", "dcp.jsonc") : "N/A", - logDirectory: join(homedir(), ".config", "opencode", "logs", "dcp"), - globalFetchWrapped: true + logger.info("plugin", "DCP initialized", { + mode: config.pruningMode, + model: config.model || "auto" }) return { @@ -258,212 +152,36 @@ const plugin: Plugin = (async (ctx) => { event: async ({ event }) => { if (event.type === "session.status" && event.properties.status.type === "idle") { // Skip pruning for subagent sessions - if (await isSubagentSession(ctx.client, event.properties.sessionID, logger)) return - - logger.debug("event", "Session became idle, triggering janitor", { - sessionID: event.properties.sessionID - }) + if (await isSubagentSession(ctx.client, event.properties.sessionID)) return // Fire and forget the janitor - don't block the event handler janitor.run(event.properties.sessionID).catch(err => { - logger.error("event", "Janitor failed", { - sessionID: event.properties.sessionID, - error: err.message, - stack: err.stack - }) + logger.error("janitor", "Failed", { error: err.message }) }) } }, /** - * Chat Params Hook: Wraps fetch function to filter pruned tool responses + * Chat Params Hook: Caches model info for janitor */ "chat.params": async (input, output) => { const sessionId = input.sessionID - // Debug: Log the entire input structure to see what we're getting - logger.debug("chat.params", "Hook input structure", { - sessionID: sessionId, - hasProvider: !!input.provider, - hasModel: !!input.model, - providerKeys: input.provider ? Object.keys(input.provider) : [], - provider: input.provider, - modelKeys: input.model ? Object.keys(input.model) : [], - model: input.model - }) - // Cache model information for this session so janitor can access it // The provider.id is actually nested at provider.info.id (not in SDK types) let providerID = (input.provider as any)?.info?.id || input.provider?.id const modelID = input.model?.id - + // If provider.id is not available, try to get it from the message if (!providerID && input.message?.model?.providerID) { providerID = input.message.model.providerID - logger.debug("chat.params", "Got providerID from message instead of provider object", { - sessionID: sessionId, - providerID: providerID - }) } - + if (providerID && modelID) { modelCache.set(sessionId, { providerID: providerID, modelID: modelID }) - logger.debug("chat.params", "Cached model info for session", { - sessionID: sessionId, - providerID: providerID, - modelID: modelID - }) - } else { - logger.warn("chat.params", "Missing provider or model info in hook input", { - sessionID: sessionId, - hasProvider: !!input.provider, - hasModel: !!input.model, - providerID: providerID, - modelID: modelID, - inputKeys: Object.keys(input), - messageModel: input.message?.model - }) - } - - // Skip pruning for subagent sessions - if (await isSubagentSession(ctx.client, sessionId, logger)) return - - logger.debug("chat.params", "Wrapping fetch for session", { - sessionID: sessionId, - hasFetch: !!(output.options as any).fetch, - fetchType: (output.options as any).fetch ? typeof (output.options as any).fetch : "none" - }) - - // Get the existing fetch - this might be from auth provider or globalThis - const existingFetch = (output.options as any).fetch ?? globalThis.fetch - - logger.debug("chat.params", "Existing fetch captured", { - sessionID: sessionId, - isGlobalFetch: existingFetch === globalThis.fetch - }) - - // Wrap the existing fetch with our pruning logic - ;(output.options as any).fetch = async (fetchInput: any, init?: any) => { - logger.info("pruning-fetch", "๐Ÿ”ฅ FETCH WRAPPER CALLED!", { - sessionId, - url: typeof fetchInput === 'string' ? fetchInput.substring(0, 100) : 'URL object' - }) - logger.debug("pruning-fetch", "Request intercepted", { sessionId }) - - // Retrieve the list of pruned tool call IDs from state - const prunedIds = await stateManager.get(sessionId) - logger.debug("pruning-fetch", "Retrieved pruned IDs", { - sessionId, - prunedCount: prunedIds.length, - prunedIds: prunedIds.length > 0 ? prunedIds : undefined - }) - - // Parse the request body once if possible for logging, caching, and filtering - let parsedBody: any | undefined - if (init?.body && typeof init.body === 'string') { - try { - parsedBody = JSON.parse(init.body) - } catch (e) { - // Ignore parse errors; we'll skip caching/filtering in this case - } - } - - if (parsedBody?.messages) { - const toolMessages = parsedBody.messages.filter((m: any) => m.role === 'tool') || [] - logger.debug("pruning-fetch", "Request body before filtering", { - sessionId, - totalMessages: parsedBody.messages.length, - toolMessages: toolMessages.length, - toolCallIds: toolMessages.map((m: any) => m.tool_call_id) - }) - - // Capture tool call parameters from assistant messages so Janitor toast metadata stays rich - cacheToolParameters(parsedBody.messages, "pruning-fetch") - } - - // Reset the count for this request - let prunedThisRequest = 0 - - // Only attempt filtering if there are pruned IDs and a request body exists - if (prunedIds.length > 0 && init?.body) { - let body = parsedBody - - if (!body && typeof init.body === 'string') { - try { - body = JSON.parse(init.body) - } catch (error: any) { - logger.error("pruning-fetch", "Failed to parse/filter request body", { - sessionId, - error: error.message, - stack: error.stack - }) - return existingFetch(fetchInput, init) - } - } - - if (body?.messages && Array.isArray(body.messages)) { - const originalMessageCount = body.messages.length - - // Replace tool response messages whose tool_call_id is in the pruned list - // with a short placeholder message instead of removing them entirely. - // This preserves the message structure and avoids API validation errors. - body.messages = body.messages.map((m: any) => { - if (m.role === 'tool' && prunedIds.includes(m.tool_call_id)) { - prunedThisRequest++ - return { - ...m, - content: '[Output removed to save context - information superseded or no longer needed]' - } - } - return m - }) - - if (prunedThisRequest > 0) { - logger.info("pruning-fetch", "Replaced pruned tool messages", { - sessionId, - totalMessages: originalMessageCount, - replacedCount: prunedThisRequest, - prunedIds - }) - - // Log remaining tool messages - const remainingToolMessages = body.messages.filter((m: any) => m.role === 'tool') - logger.debug("pruning-fetch", "Tool messages after replacement", { - sessionId, - totalToolCount: remainingToolMessages.length, - toolCallIds: remainingToolMessages.map((m: any) => m.tool_call_id) - }) - - // Save wrapped context to file if debug is enabled - await logger.saveWrappedContext( - sessionId, - body.messages, - { - url: typeof fetchInput === 'string' ? fetchInput : 'URL object', - totalMessages: originalMessageCount, - replacedCount: prunedThisRequest, - prunedIds, - wrapper: 'session-specific' - } - ) - - // Update the request body with modified messages - init.body = JSON.stringify(body) - parsedBody = body - } else { - logger.debug("pruning-fetch", "No messages replaced", { - sessionId, - messageCount: originalMessageCount - }) - } - } - } - - // Call the EXISTING fetch (which might be from auth provider) with potentially modified body - return existingFetch(fetchInput, init) } }, } diff --git a/lib/config.ts b/lib/config.ts index d7ae4e5..a922a0f 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -135,8 +135,6 @@ function loadConfigFile(configPath: string): Partial | null { const fileContent = readFileSync(configPath, 'utf-8') return parse(fileContent) as Partial } catch (error: any) { - const logger = new Logger(true) - logger.error('config', `Failed to read config from ${configPath}: ${error.message}`) return null } } diff --git a/lib/janitor.ts b/lib/janitor.ts index 7b184f9..5015ede 100644 --- a/lib/janitor.ts +++ b/lib/janitor.ts @@ -1,6 +1,6 @@ import { z } from "zod" import type { Logger } from "./logger" -import type { StateManager } from "./state" +import type { StateManager, SessionStats } from "./state" import { buildAnalysisPrompt } from "./prompt" import { selectModel, extractModelFromSession } from "./model-selector" import { estimateTokensBatch, formatTokenCount } from "./tokenizer" @@ -39,26 +39,16 @@ export class Janitor { }] } }) - this.logger.debug("janitor", "Sent ignored message to session", { - sessionID, - textLength: text.length - }) } catch (error: any) { - this.logger.error("janitor", "Failed to send ignored message", { - sessionID, + this.logger.error("janitor", "Failed to send notification", { error: error.message }) - // Don't fail the operation if sending the message fails } } async run(sessionID: string) { - this.logger.info("janitor", "Starting analysis", { sessionID }) - try { // Fetch session info and messages from OpenCode API - this.logger.debug("janitor", "Fetching session info and messages", { sessionID }) - const [sessionInfoResponse, messagesResponse] = await Promise.all([ this.client.session.get({ path: { id: sessionID } }), this.client.session.messages({ path: { id: sessionID }, query: { limit: 100 } }) @@ -68,17 +58,8 @@ export class Janitor { // Handle the response format - it should be { data: Array<{info, parts}> } or just the array const messages = messagesResponse.data || messagesResponse - this.logger.debug("janitor", "Retrieved messages", { - sessionID, - messageCount: messages.length - }) - // If there are no messages or very few, skip analysis if (!messages || messages.length < 3) { - this.logger.debug("janitor", "Too few messages to analyze, skipping", { - sessionID, - messageCount: messages?.length || 0 - }) return } @@ -109,18 +90,6 @@ export class Janitor { parameters: parameters }) - // Debug: log what we're storing - if (normalizedId.startsWith('prt_') || part.tool === "read" || part.tool === "list") { - this.logger.debug("janitor", "Storing tool metadata", { - sessionID, - callID: normalizedId, - tool: part.tool, - hasParameters: !!parameters, - hasCached: !!cachedData, - parameters: parameters - }) - } - // Track the output content for size calculation if (part.state?.status === "completed" && part.state.output) { toolOutputs.set(normalizedId, part.state.output) @@ -128,32 +97,15 @@ export class Janitor { // Check if this is a batch tool by looking at the tool name if (part.tool === "batch") { - const batchId = normalizedId - currentBatchId = batchId - batchToolChildren.set(batchId, []) - this.logger.debug("janitor", "Found batch tool", { - sessionID, - batchID: currentBatchId - }) + currentBatchId = normalizedId + batchToolChildren.set(normalizedId, []) } // If we're inside a batch and this is a prt_ (parallel) tool call, it's a child else if (currentBatchId && normalizedId.startsWith('prt_')) { - const children = batchToolChildren.get(currentBatchId)! - children.push(normalizedId) - this.logger.debug("janitor", "Added child to batch tool", { - sessionID, - batchID: currentBatchId, - childID: normalizedId, - totalChildren: children.length - }) + batchToolChildren.get(currentBatchId)!.push(normalizedId) } // If we hit a non-batch, non-prt_ tool, we're out of the batch else if (currentBatchId && !normalizedId.startsWith('prt_')) { - this.logger.debug("janitor", "Batch tool ended", { - sessionID, - batchID: currentBatchId, - totalChildren: batchToolChildren.get(currentBatchId)!.length - }) currentBatchId = null } } @@ -161,37 +113,12 @@ export class Janitor { } } - // Log summary of batch tools found - if (batchToolChildren.size > 0) { - this.logger.debug("janitor", "Batch tool summary", { - sessionID, - batchCount: batchToolChildren.size, - batches: Array.from(batchToolChildren.entries()).map(([id, children]) => ({ - batchID: id, - childCount: children.length, - childIDs: children - })) - }) - } - // Get already pruned IDs to filter them out const alreadyPrunedIds = await this.stateManager.get(sessionID) const unprunedToolCallIds = toolCallIds.filter(id => !alreadyPrunedIds.includes(id)) - this.logger.debug("janitor", "Found tool calls in session", { - sessionID, - toolCallCount: toolCallIds.length, - toolCallIds, - alreadyPrunedCount: alreadyPrunedIds.length, - alreadyPrunedIds: alreadyPrunedIds.slice(0, 5), // Show first 5 for brevity - unprunedCount: unprunedToolCallIds.length - }) - // If there are no unpruned tool calls, skip analysis if (unprunedToolCallIds.length === 0) { - this.logger.debug("janitor", "No unpruned tool calls found, skipping analysis", { - sessionID - }) return } @@ -202,11 +129,11 @@ export class Janitor { const deduplicatedIds = dedupeResult.duplicateIds const deduplicationDetails = dedupeResult.deduplicationDetails - this.logger.info("janitor", "Duplicate detection complete", { - sessionID, - duplicatesFound: deduplicatedIds.length, - uniqueToolPatterns: deduplicationDetails.size - }) + // Calculate candidates available for pruning (excludes protected tools) + const candidateCount = unprunedToolCallIds.filter(id => { + const metadata = toolMetadata.get(id) + return !metadata || !this.protectedTools.includes(metadata.tool) + }).length // ============================================================ // PHASE 2: LLM ANALYSIS (only runs in "smart" mode) @@ -230,44 +157,17 @@ export class Janitor { return true }) - if (protectedToolCallIds.length > 0) { - this.logger.debug("janitor", "Protected tools excluded from pruning", { - sessionID, - protectedCount: protectedToolCallIds.length, - protectedTools: protectedToolCallIds.map(id => { - const metadata = toolMetadata.get(id) - return { id, tool: metadata?.tool } - }) - }) - } - // Run LLM analysis only if there are prunable tools if (prunableToolCallIds.length > 0) { - this.logger.info("janitor", "Starting LLM analysis", { - sessionID, - candidateCount: prunableToolCallIds.length - }) - // Select appropriate model with intelligent fallback const cachedModelInfo = this.modelCache.get(sessionID) const sessionModelInfo = extractModelFromSession(sessionInfo, this.logger) const currentModelInfo = cachedModelInfo || sessionModelInfo - if (cachedModelInfo) { - this.logger.debug("janitor", "Using cached model info", { - sessionID, - providerID: cachedModelInfo.providerID, - modelID: cachedModelInfo.modelID - }) - } - const modelSelection = await selectModel(currentModelInfo, this.logger, this.configModel, this.workingDirectory) - this.logger.info("janitor", "Model selected for analysis", { - sessionID, - modelInfo: modelSelection.modelInfo, - source: modelSelection.source, - reason: modelSelection.reason + this.logger.info("janitor", `Model: ${modelSelection.modelInfo.providerID}/${modelSelection.modelInfo.modelID}`, { + source: modelSelection.source }) // Show toast if we had to fallback from a failed model @@ -281,56 +181,34 @@ export class Janitor { duration: 5000 } }) - this.logger.info("janitor", "Toast notification shown for model fallback", { - failedModel: modelSelection.failedModel, - selectedModel: modelSelection.modelInfo - }) } catch (toastError: any) { - this.logger.error("janitor", "Failed to show toast notification", { - error: toastError.message - }) // Don't fail the whole operation if toast fails } - } else if (modelSelection.failedModel && !this.showModelErrorToasts) { - this.logger.info("janitor", "Model fallback occurred but toast disabled by config", { - failedModel: modelSelection.failedModel, - selectedModel: modelSelection.modelInfo - }) } - // Log comprehensive stats before AI call - this.logger.info("janitor", "Preparing AI analysis", { - sessionID, - totalToolCallsInSession: toolCallIds.length, - alreadyPrunedCount: alreadyPrunedIds.length, - deduplicatedCount: deduplicatedIds.length, - protectedToolsCount: protectedToolCallIds.length, - candidatesForPruning: prunableToolCallIds.length, - candidateTools: prunableToolCallIds.map(id => { - const meta = toolMetadata.get(id) - return meta ? `${meta.tool}[${id.substring(0, 12)}...]` : id.substring(0, 12) + '...' - }).slice(0, 10), // Show first 10 for brevity - batchToolCount: batchToolChildren.size, - batchDetails: Array.from(batchToolChildren.entries()).map(([batchId, children]) => ({ - batchId: batchId.substring(0, 20) + '...', - childCount: children.length - })) - }) - - this.logger.debug("janitor", "Starting shadow inference", { sessionID }) + // Lazy import - only load the 2.8MB ai package when actually needed + const { generateObject } = await import('ai') // Replace already-pruned tool outputs to save tokens in janitor context const allPrunedSoFar = [...alreadyPrunedIds, ...deduplicatedIds] const sanitizedMessages = this.replacePrunedToolOutputs(messages, allPrunedSoFar) - - this.logger.debug("janitor", "Sanitized messages for analysis", { - sessionID, - totalPrunedBeforeAnalysis: allPrunedSoFar.length, - prunedIds: allPrunedSoFar.slice(0, 5) // Show first 5 - }) - // Lazy import - only load the 2.8MB ai package when actually needed - const { generateObject } = await import('ai') + // Build the prompt for analysis + const analysisPrompt = buildAnalysisPrompt(prunableToolCallIds, sanitizedMessages, this.protectedTools, allPrunedSoFar, protectedToolCallIds) + + // Save janitor shadow context directly (auth providers may bypass globalThis.fetch) + 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 + } + ) // Analyze which tool calls are obsolete const result = await generateObject({ @@ -339,7 +217,7 @@ export class Janitor { pruned_tool_call_ids: z.array(z.string()), reasoning: z.string(), }), - prompt: buildAnalysisPrompt(prunableToolCallIds, sanitizedMessages, this.protectedTools, allPrunedSoFar, protectedToolCallIds) + prompt: analysisPrompt }) // Filter LLM results to only include IDs that were actually candidates @@ -349,34 +227,12 @@ export class Janitor { prunableToolCallIds.includes(id.toLowerCase()) ) - if (rawLlmPrunedIds.length !== llmPrunedIds.length) { - this.logger.warn("janitor", "LLM returned non-candidate IDs (filtered out)", { - sessionID, - rawCount: rawLlmPrunedIds.length, - filteredCount: llmPrunedIds.length, - invalidIds: 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 ? '...' : ''}`) } - - this.logger.info("janitor", "LLM analysis complete", { - sessionID, - llmPrunedCount: llmPrunedIds.length, - reasoning: result.object.reasoning - }) - } else { - this.logger.info("janitor", "No prunable tools for LLM analysis", { - sessionID, - deduplicatedCount: deduplicatedIds.length, - protectedCount: protectedToolCallIds.length - }) } - } else { - this.logger.info("janitor", "Skipping LLM analysis (auto mode)", { - sessionID, - deduplicatedCount: deduplicatedIds.length - }) } - // If mode is "auto", llmPrunedIds stays empty // ============================================================ // PHASE 3: COMBINE & EXPAND @@ -384,7 +240,6 @@ export class Janitor { const newlyPrunedIds = [...deduplicatedIds, ...llmPrunedIds] if (newlyPrunedIds.length === 0) { - this.logger.info("janitor", "No tools to prune", { sessionID }) return } @@ -397,12 +252,6 @@ export class Janitor { // If this is a batch tool, add all its children const children = batchToolChildren.get(normalizedId) if (children) { - this.logger.debug("janitor", "Expanding batch tool to include children", { - sessionID, - batchID: normalizedId, - childCount: children.length, - childIDs: children - }) children.forEach(childId => expandedPrunedIds.add(childId)) } } @@ -413,33 +262,22 @@ export class Janitor { // finalPrunedIds includes everything (new + already pruned) for logging const finalPrunedIds = Array.from(expandedPrunedIds) - this.logger.info("janitor", "Analysis complete", { - sessionID, - prunedCount: finalPrunedIds.length, - deduplicatedCount: deduplicatedIds.length, - llmPrunedCount: llmPrunedIds.length, - prunedIds: finalPrunedIds - }) - - this.logger.debug("janitor", "Pruning ID details", { - sessionID, - alreadyPrunedCount: alreadyPrunedIds.length, - alreadyPrunedIds: alreadyPrunedIds, - finalPrunedCount: finalPrunedIds.length, - finalPrunedIds: finalPrunedIds, - newlyPrunedCount: finalNewlyPrunedIds.length, - newlyPrunedIds: finalNewlyPrunedIds - }) - // ============================================================ // PHASE 4: NOTIFICATION // ============================================================ + // Calculate token savings once (used by both notification and log) + const tokensSaved = await this.calculateTokensSaved(finalNewlyPrunedIds, toolOutputs) + + // Accumulate session stats (for showing cumulative totals in UI) + const sessionStats = await this.stateManager.addStats(sessionID, finalNewlyPrunedIds.length, tokensSaved) + if (this.pruningMode === "auto") { await this.sendAutoModeNotification( sessionID, deduplicatedIds, deduplicationDetails, - toolOutputs + tokensSaved, + sessionStats ) } else { await this.sendSmartModeNotification( @@ -448,7 +286,8 @@ export class Janitor { deduplicationDetails, llmPrunedIds, toolMetadata, - toolOutputs + tokensSaved, + sessionStats ) } @@ -458,17 +297,18 @@ export class Janitor { // Merge newly pruned IDs with existing ones (using expanded IDs) const allPrunedIds = [...new Set([...alreadyPrunedIds, ...finalPrunedIds])] await this.stateManager.set(sessionID, allPrunedIds) - this.logger.debug("janitor", "Updated state manager", { - sessionID, - totalPrunedCount: allPrunedIds.length, - newlyPrunedCount: finalNewlyPrunedIds.length - }) + + // Log final summary + // Format: "Pruned 5/5 tools (~4.2K tokens), 0 kept" or with breakdown if both duplicate and llm + 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)` : "" + this.logger.info("janitor", `Pruned ${prunedCount}/${candidateCount} tools${breakdown}, ${keptCount} kept (~${formatTokenCount(tokensSaved)} tokens)`) } catch (error: any) { this.logger.error("janitor", "Analysis failed", { - sessionID, - error: error.message, - stack: error.stack + error: error.message }) // Don't throw - this is a fire-and-forget background process // Silently fail and try again on next idle event @@ -561,7 +401,7 @@ export class Janitor { if (outputsToTokenize.length > 0) { // Use batch tokenization for efficiency (lazy loads gpt-tokenizer) - const tokenCounts = await estimateTokensBatch(outputsToTokenize, this.logger) + const tokenCounts = await estimateTokensBatch(outputsToTokenize) return tokenCounts.reduce((sum, count) => sum + count, 0) } @@ -616,16 +456,20 @@ export class Janitor { private async sendMinimalNotification( sessionID: string, totalPruned: number, - toolOutputs: Map, - prunedIds: string[] + tokensSaved: number, + sessionStats: SessionStats ) { if (totalPruned === 0) return - const tokensSaved = await this.calculateTokensSaved(prunedIds, toolOutputs) const tokensFormatted = formatTokenCount(tokensSaved) const toolText = totalPruned === 1 ? 'tool' : 'tools' - const message = `๐Ÿงน DCP: Saved ~${tokensFormatted} tokens (${totalPruned} ${toolText} pruned)` + let message = `๐Ÿงน DCP: Saved ~${tokensFormatted} tokens (${totalPruned} ${toolText} pruned)` + + // Add session totals if there's been more than one pruning run + if (sessionStats.totalToolsPruned > totalPruned) { + message += ` โ”‚ Session: ${sessionStats.totalToolsPruned} tools, ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens` + } await this.sendIgnoredMessage(sessionID, message) } @@ -637,7 +481,8 @@ export class Janitor { sessionID: string, deduplicatedIds: string[], deduplicationDetails: Map, - toolOutputs: Map + tokensSaved: number, + sessionStats: SessionStats ) { if (deduplicatedIds.length === 0) return @@ -646,17 +491,21 @@ export class Janitor { // Send minimal notification if configured if (this.pruningSummary === 'minimal') { - await this.sendMinimalNotification(sessionID, deduplicatedIds.length, toolOutputs, deduplicatedIds) + await this.sendMinimalNotification(sessionID, deduplicatedIds.length, tokensSaved, sessionStats) return } // Otherwise send detailed notification - // Calculate token savings - const tokensSaved = await this.calculateTokensSaved(deduplicatedIds, toolOutputs) const tokensFormatted = formatTokenCount(tokensSaved) const toolText = deduplicatedIds.length === 1 ? 'tool' : 'tools' - let message = `๐Ÿงน DCP: Saved ~${tokensFormatted} tokens (${deduplicatedIds.length} duplicate ${toolText} removed)\n` + let message = `๐Ÿงน DCP: Saved ~${tokensFormatted} tokens (${deduplicatedIds.length} duplicate ${toolText} removed)` + + // Add session totals if there's been more than one pruning run + if (sessionStats.totalToolsPruned > deduplicatedIds.length) { + message += ` โ”‚ Session: ${sessionStats.totalToolsPruned} tools, ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens` + } + message += '\n' // Group by tool type const grouped = new Map>() @@ -699,7 +548,8 @@ export class Janitor { deduplicationDetails: Map, llmPrunedIds: string[], toolMetadata: Map, - toolOutputs: Map + tokensSaved: number, + sessionStats: SessionStats ) { const totalPruned = deduplicatedIds.length + llmPrunedIds.length if (totalPruned === 0) return @@ -709,18 +559,20 @@ export class Janitor { // Send minimal notification if configured if (this.pruningSummary === 'minimal') { - const allPrunedIds = [...deduplicatedIds, ...llmPrunedIds] - await this.sendMinimalNotification(sessionID, totalPruned, toolOutputs, allPrunedIds) + await this.sendMinimalNotification(sessionID, totalPruned, tokensSaved, sessionStats) return } // Otherwise send detailed notification - // Calculate token savings - const allPrunedIds = [...deduplicatedIds, ...llmPrunedIds] - const tokensSaved = await this.calculateTokensSaved(allPrunedIds, toolOutputs) const tokensFormatted = formatTokenCount(tokensSaved) - let message = `๐Ÿงน DCP: Saved ~${tokensFormatted} tokens (${totalPruned} tool${totalPruned > 1 ? 's' : ''} pruned)\n` + let message = `๐Ÿงน DCP: Saved ~${tokensFormatted} tokens (${totalPruned} tool${totalPruned > 1 ? 's' : ''} pruned)` + + // Add session totals if there's been more than one pruning run + if (sessionStats.totalToolsPruned > totalPruned) { + message += ` โ”‚ Session: ${sessionStats.totalToolsPruned} tools, ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens` + } + message += '\n' // Section 1: Deduplicated tools if (deduplicatedIds.length > 0 && deduplicationDetails) { diff --git a/lib/logger.ts b/lib/logger.ts index c83f33f..f40b8b1 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -23,6 +23,37 @@ export class Logger { } } + /** + * Formats data object into a compact, readable string + * e.g., {saved: "~4.1K", pruned: 4, duplicates: 0} -> "saved=~4.1K pruned=4 duplicates=0" + */ + private formatData(data?: any): string { + if (!data) return "" + + const parts: string[] = [] + for (const [key, value] of Object.entries(data)) { + if (value === undefined || value === null) continue + + // Format arrays compactly + if (Array.isArray(value)) { + if (value.length === 0) continue + parts.push(`${key}=[${value.slice(0, 3).join(",")}${value.length > 3 ? `...+${value.length - 3}` : ""}]`) + } + // Format objects inline if small, skip if large + else if (typeof value === 'object') { + const str = JSON.stringify(value) + if (str.length < 50) { + parts.push(`${key}=${str}`) + } + } + // Format primitives directly + else { + parts.push(`${key}=${value}`) + } + } + return parts.join(" ") + } + private async write(level: string, component: string, message: string, data?: any) { if (!this.enabled) return @@ -30,13 +61,10 @@ export class Logger { await this.ensureLogDir() const timestamp = new Date().toISOString() - const logEntry = { - timestamp, - level, - component, - message, - ...(data && { data }) - } + const dataStr = this.formatData(data) + + // Simple, readable format: TIMESTAMP LEVEL component: message | key=value key=value + const logLine = `${timestamp} ${level.padEnd(5)} ${component}: ${message}${dataStr ? " | " + dataStr : ""}\n` const dailyLogDir = join(this.logDir, "daily") if (!existsSync(dailyLogDir)) { @@ -44,8 +72,6 @@ export class Logger { } const logFile = join(dailyLogDir, `${new Date().toISOString().split('T')[0]}.log`) - const logLine = JSON.stringify(logEntry) + "\n" - await writeFile(logFile, logLine, { flag: "a" }) } catch (error) { // Silently fail - don't break the plugin if logging fails @@ -140,7 +166,6 @@ export class Logger { // We detect being "inside a string" by tracking quotes let result = '' let inString = false - let escaped = false for (let i = 0; i < jsonText.length; i++) { const char = jsonText[i] @@ -237,15 +262,6 @@ export class Logger { const jsonString = JSON.stringify(content, null, 2) await writeFile(filepath, jsonString) - - // Log that we saved it - await this.debug("logger", "Saved AI context", { - sessionID, - filepath, - messageCount: messages.length, - isJanitorShadow, - parsed: isJanitorShadow - }) } catch (error) { // Silently fail - don't break the plugin if logging fails } diff --git a/lib/model-selector.ts b/lib/model-selector.ts index 7c2dd7f..af52134 100644 --- a/lib/model-selector.ts +++ b/lib/model-selector.ts @@ -120,8 +120,6 @@ export async function selectModel( configModel?: string, workspaceDir?: string ): Promise { - logger?.info('model-selector', 'Model selection started', { currentModel, configModel, workspaceDir }); - // Lazy import with retry logic - handles plugin initialization timing issues // Some providers (like openai via @openhax/codex) may not be ready on first attempt // Pass workspaceDir so OpencodeAI can find project-level config and plugins @@ -133,22 +131,12 @@ export async function selectModel( if (configModel) { const parts = configModel.split('/'); if (parts.length !== 2) { - logger?.warn('model-selector', 'โœ— Invalid config model format, expected "provider/model"', { - configModel - }); + logger?.warn('model-selector', 'Invalid config model format', { configModel }); } else { const [providerID, modelID] = parts; - logger?.debug('model-selector', 'Attempting to use config-specified model', { - providerID, - modelID - }); try { const model = await opencodeAI.getLanguageModel(providerID, modelID); - logger?.info('model-selector', 'โœ“ Successfully using config-specified model', { - providerID, - modelID - }); return { model, modelInfo: { providerID, modelID }, @@ -156,9 +144,7 @@ export async function selectModel( reason: 'Using model specified in dcp.jsonc config' }; } catch (error: any) { - logger?.warn('model-selector', 'โœ— Failed to use config-specified model, falling back', { - providerID, - modelID, + logger?.warn('model-selector', `Config model failed: ${providerID}/${modelID}`, { error: error.message }); failedModelInfo = { providerID, modelID }; @@ -169,27 +155,13 @@ export async function selectModel( // Step 2: Try user's current model (if not skipped provider) if (currentModel) { if (shouldSkipProvider(currentModel.providerID)) { - logger?.info('model-selector', 'Skipping user model (provider not suitable for background tasks)', { - providerID: currentModel.providerID, - modelID: currentModel.modelID, - reason: 'github-copilot and anthropic are skipped for analysis' - }); // Track as failed so we can show toast if (!failedModelInfo) { failedModelInfo = currentModel; } } else { - logger?.debug('model-selector', 'Attempting to use user\'s current model', { - providerID: currentModel.providerID, - modelID: currentModel.modelID - }); - try { const model = await opencodeAI.getLanguageModel(currentModel.providerID, currentModel.modelID); - logger?.info('model-selector', 'โœ“ Successfully using user\'s current model', { - providerID: currentModel.providerID, - modelID: currentModel.modelID - }); return { model, modelInfo: currentModel, @@ -197,11 +169,6 @@ export async function selectModel( reason: 'Using current session model' }; } catch (error: any) { - logger?.warn('model-selector', 'โœ— Failed to use user\'s current model', { - providerID: currentModel.providerID, - modelID: currentModel.modelID, - error: error.message - }); if (!failedModelInfo) { failedModelInfo = currentModel; } @@ -210,43 +177,16 @@ export async function selectModel( } // Step 3: Try fallback models from authenticated providers - logger?.debug('model-selector', 'Fetching available authenticated providers'); const providers = await opencodeAI.listProviders(); - const availableProviderIDs = Object.keys(providers); - logger?.info('model-selector', 'Available authenticated providers', { - providerCount: availableProviderIDs.length, - providerIDs: availableProviderIDs, - providers: Object.entries(providers).map(([id, info]: [string, any]) => ({ - id, - source: info.source, - name: info.info?.name - })) - }); - - logger?.debug('model-selector', 'Attempting fallback models from providers', { - priorityOrder: PROVIDER_PRIORITY - }); for (const providerID of PROVIDER_PRIORITY) { - if (!providers[providerID]) { - logger?.debug('model-selector', `Skipping ${providerID} (not authenticated)`); - continue; - } + if (!providers[providerID]) continue; const fallbackModelID = FALLBACK_MODELS[providerID]; - if (!fallbackModelID) { - logger?.debug('model-selector', `Skipping ${providerID} (no fallback model configured)`); - continue; - } - - logger?.debug('model-selector', `Attempting ${providerID}/${fallbackModelID}`); + if (!fallbackModelID) continue; try { const model = await opencodeAI.getLanguageModel(providerID, fallbackModelID); - logger?.info('model-selector', `โœ“ Successfully using fallback model`, { - providerID, - modelID: fallbackModelID - }); return { model, modelInfo: { providerID, modelID: fallbackModelID }, @@ -255,9 +195,6 @@ export async function selectModel( failedModel: failedModelInfo }; } catch (error: any) { - logger?.warn('model-selector', `โœ— Failed to use ${providerID}/${fallbackModelID}`, { - error: error.message - }); continue; } } @@ -270,14 +207,8 @@ export async function selectModel( * This can be used by the plugin to get the current session's model */ export function extractModelFromSession(sessionState: any, logger?: Logger): ModelInfo | undefined { - logger?.debug('model-selector', 'Extracting model from session state'); - // Try to get from ACP session state if (sessionState?.model?.providerID && sessionState?.model?.modelID) { - logger?.info('model-selector', 'Found model in ACP session state', { - providerID: sessionState.model.providerID, - modelID: sessionState.model.modelID - }); return { providerID: sessionState.model.providerID, modelID: sessionState.model.modelID @@ -288,10 +219,6 @@ export function extractModelFromSession(sessionState: any, logger?: Logger): Mod if (sessionState?.messages && Array.isArray(sessionState.messages)) { const lastMessage = sessionState.messages[sessionState.messages.length - 1]; if (lastMessage?.model?.providerID && lastMessage?.model?.modelID) { - logger?.info('model-selector', 'Found model in last message', { - providerID: lastMessage.model.providerID, - modelID: lastMessage.model.modelID - }); return { providerID: lastMessage.model.providerID, modelID: lastMessage.model.modelID @@ -299,6 +226,5 @@ export function extractModelFromSession(sessionState: any, logger?: Logger): Mod } } - logger?.warn('model-selector', 'Could not extract model from session state'); return undefined; } diff --git a/lib/state.ts b/lib/state.ts index 0447541..8ffa89c 100644 --- a/lib/state.ts +++ b/lib/state.ts @@ -1,7 +1,13 @@ // lib/state.ts +export interface SessionStats { + totalToolsPruned: number + totalTokensSaved: number +} + export class StateManager { private state: Map = new Map() + private stats: Map = new Map() async get(sessionID: string): Promise { return this.state.get(sessionID) ?? [] @@ -10,4 +16,18 @@ export class StateManager { async set(sessionID: string, prunedIds: string[]): Promise { this.state.set(sessionID, prunedIds) } + + async getStats(sessionID: string): Promise { + return this.stats.get(sessionID) ?? { totalToolsPruned: 0, totalTokensSaved: 0 } + } + + async addStats(sessionID: string, toolsPruned: number, tokensSaved: number): Promise { + const current = await this.getStats(sessionID) + const updated: SessionStats = { + totalToolsPruned: current.totalToolsPruned + toolsPruned, + totalTokensSaved: current.totalTokensSaved + tokensSaved + } + this.stats.set(sessionID, updated) + return updated + } } diff --git a/lib/tokenizer.ts b/lib/tokenizer.ts index f4b8f8b..041401a 100644 --- a/lib/tokenizer.ts +++ b/lib/tokenizer.ts @@ -10,41 +10,19 @@ * is actually needed. */ -import type { Logger } from './logger' - /** * Batch estimates tokens for multiple text samples * * @param texts - Array of text strings to tokenize - * @param logger - Optional logger instance * @returns Array of token counts */ -export async function estimateTokensBatch( - texts: string[], - logger?: Logger -): Promise { +export async function estimateTokensBatch(texts: string[]): Promise { try { // Lazy import - only load the 53MB gpt-tokenizer package when actually needed const { encode } = await import('gpt-tokenizer') - - const results = texts.map(text => { - const tokens = encode(text) - return tokens.length - }) - - logger?.debug('tokenizer', 'Batch token estimation complete', { - batchSize: texts.length, - totalTokens: results.reduce((sum, count) => sum + count, 0), - avgTokensPerText: Math.round(results.reduce((sum, count) => sum + count, 0) / results.length) - }) - - return results - } catch (error: any) { - logger?.warn('tokenizer', 'Batch tokenization failed, using fallback', { - error: error.message - }) - - // Fallback to character-based estimation + return texts.map(text => encode(text).length) + } catch { + // Fallback to character-based estimation if tokenizer fails return texts.map(text => Math.round(text.length / 4)) } } diff --git a/package-lock.json b/package-lock.json index 16d5b2e..eaed22d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tarquinen/opencode-dcp", - "version": "0.3.9", + "version": "0.3.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "0.3.9", + "version": "0.3.10", "license": "MIT", "dependencies": { "@ai-sdk/openai-compatible": "^1.0.27", diff --git a/package.json b/package.json index c238504..fe8fb45 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@tarquinen/opencode-dcp", - "version": "0.3.9", + "version": "0.3.10", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js",