diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 61a665312f0..8045a3664f7 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin" +import solidPlugin from "@opentui/solid/bun-plugin" import path from "path" import fs from "fs" import { $ } from "bun" diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 4477d301562..79bca42406a 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -62,7 +62,6 @@ function init() { current.onClose?.() setStore("stack", store.stack.slice(0, -1)) evt.preventDefault() - evt.stopPropagation() refocus() } }) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ead3a0149b4..0cefee63c52 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1004,6 +1004,42 @@ export namespace Config { prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"), }) .optional(), + pruning: z + .object({ + enabled: z.boolean().optional().describe("Enable smart pruning (default: true)"), + budgets: z + .object({ + content: z + .number() + .optional() + .describe("Token budget for content tools like read/webfetch (default: 60000)"), + navigation: z + .number() + .optional() + .describe("Token budget for navigation tools like grep/glob (default: 15000)"), + }) + .optional(), + summarization: z + .object({ + enabled: z.boolean().optional().describe("Enable LLM summarization for content tools (default: true)"), + model: z + .string() + .optional() + .describe("Model to use for summarization (default: uses small_model or provider's small model)"), + }) + .optional(), + contentTools: z + .array(z.string()) + .optional() + .describe("Additional tools to treat as content tools (high priority)"), + navigationTools: z + .array(z.string()) + .optional() + .describe("Additional tools to treat as navigation tools (low priority)"), + protectedTools: z.array(z.string()).optional().describe("Tools that should never be pruned"), + }) + .optional() + .describe("Smart pruning configuration for tiered tool output management"), experimental: z .object({ hook: z diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 42bab2eb975..dfc8285b718 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -1,5 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" +import { TuiEvent } from "@/cli/cmd/tui/event" import { Session } from "." import { Identifier } from "../id/id" import { Instance } from "../project/instance" @@ -14,6 +15,7 @@ import { fn } from "@/util/fn" import { Agent } from "@/agent/agent" import { Plugin } from "@/plugin" import { Config } from "@/config/config" +import { generateObject } from "ai" export namespace SessionCompaction { const log = Log.create({ service: "session.compaction" }) @@ -38,55 +40,385 @@ export namespace SessionCompaction { return count > usable } + // ============================================================================ + // Tool Categories - Tiered Priority System + // ============================================================================ + + /** Tier 1: Content tools - high informational value, LLM summarization when pruned */ + export const CONTENT_TOOLS = new Set(["read", "webfetch", "codesearch"]) + + /** Tier 2: Navigation tools - ephemeral results, tool-specific compression */ + export const NAVIGATION_TOOLS = new Set(["list", "grep", "glob", "bash", "websearch"]) + + /** Tier 3: Action tools - never pruned (outputs are small and important) */ + export const ACTION_TOOLS = new Set(["edit", "write", "task", "todowrite", "todoread", "skill"]) + + /** Token budgets for each tier */ + export const PRUNE_CONTENT_BUDGET = 60_000 + export const PRUNE_NAVIGATION_BUDGET = 15_000 + + /** Minimum tokens to prune (avoid pruning tiny amounts) */ export const PRUNE_MINIMUM = 20_000 + + /** Legacy constant for backwards compatibility */ export const PRUNE_PROTECT = 40_000 - const PRUNE_PROTECTED_TOOLS = ["skill"] + type ToolTier = "content" | "navigation" | "action" + + interface PruningConfig { + contentTools: Set + navigationTools: Set + protectedTools: Set + contentBudget: number + navigationBudget: number + summarizationEnabled: boolean + summarizationModel?: string // undefined = use Provider.getSmallModel() + } + + async function getPruningConfig(): Promise { + const config = await Config.get() + const pruning = config.pruning + + // Build tool sets from defaults + custom config + const contentTools = new Set(CONTENT_TOOLS) + const navigationTools = new Set(NAVIGATION_TOOLS) + const protectedTools = new Set(ACTION_TOOLS) + + if (pruning?.contentTools) { + for (const tool of pruning.contentTools) contentTools.add(tool) + } + if (pruning?.navigationTools) { + for (const tool of pruning.navigationTools) navigationTools.add(tool) + } + if (pruning?.protectedTools) { + for (const tool of pruning.protectedTools) protectedTools.add(tool) + } + + return { + contentTools, + navigationTools, + protectedTools, + contentBudget: pruning?.budgets?.content ?? PRUNE_CONTENT_BUDGET, + navigationBudget: pruning?.budgets?.navigation ?? PRUNE_NAVIGATION_BUDGET, + summarizationEnabled: pruning?.summarization?.enabled ?? true, + summarizationModel: pruning?.summarization?.model, // undefined = use Provider.getSmallModel() + } + } + + function getToolTier(toolName: string, config: PruningConfig): ToolTier { + if (config.protectedTools.has(toolName)) return "action" + if (config.contentTools.has(toolName)) return "content" + if (config.navigationTools.has(toolName)) return "navigation" + // MCP tools and unknown tools default to navigation tier (conservative approach) + return "navigation" + } + + // ============================================================================ + // Tool-Specific Compression Strategies + // ============================================================================ + + function compressNavigationOutput(tool: string, output: string): string { + switch (tool) { + case "grep": + return compressGrep(output) + case "glob": + return compressGlob(output) + case "bash": + return compressBash(output) + case "list": + return "[Directory listing cleared]" + case "websearch": + return compressWebsearch(output) + default: + return "[Tool output cleared]" + } + } + + function compressGrep(output: string): string { + const lines = output.split("\n") + if (lines.length <= 5) return output + const kept = lines.slice(0, 5) + const remaining = lines.length - 5 + return kept.join("\n") + `\n... (${remaining} more matches)` + } + + function compressGlob(output: string): string { + const lines = output.split("\n").filter((l) => l.trim()) + if (lines.length <= 10) return output + const kept = lines.slice(0, 10) + const remaining = lines.length - 10 + return kept.join("\n") + `\n... (${remaining} more files)` + } + + function compressBash(output: string): string { + const lines = output.split("\n") + if (lines.length <= 10) return output + + // Try to extract exit code if present + const exitMatch = output.match(/exit code:?\s*(\d+)/i) + const exitCode = exitMatch ? `Exit code: ${exitMatch[1]}\n` : "" + + const kept = lines.slice(-10) + return exitCode + `... (${lines.length - 10} lines truncated)\n` + kept.join("\n") + } + + function compressWebsearch(output: string): string { + // Extract just titles and URLs from search results + const lines = output.split("\n") + const compressed: string[] = [] + for (const line of lines) { + if (line.includes("http") || line.match(/^\d+\.\s/) || line.match(/^-\s/)) { + compressed.push(line) + } + } + if (compressed.length === 0) return "[Search results cleared]" + return compressed.slice(0, 10).join("\n") + } + + // ============================================================================ + // LLM Summarization for Content Tools + // ============================================================================ + + const SUMMARIZATION_PROMPT = `You are a code context summarizer. Your job is to create concise summaries of tool outputs that preserve the essential information an AI coding assistant needs to continue working effectively. + +For each tool output, create a summary that: +1. States the file path and size (lines/tokens) if applicable +2. Lists key exports, classes, or functions +3. Notes important line number ranges for key sections +4. Preserves any error messages or warnings verbatim +5. Keeps the summary under 100 tokens + +Focus on information that would prevent the assistant from needing to re-read the file. Structure matters more than prose.` + + async function summarizeToolOutputs( + parts: Array<{ id: string; tool: string; output: string; title: string }>, + providerID: string, + modelSpec?: string, + ): Promise> { + const results = new Map() + if (parts.length === 0) return results + + try { + // Use configured model, or fall back to small model (same as title generation) + let model: Provider.Model | undefined + if (modelSpec) { + const [provider, modelID] = modelSpec.includes("/") ? modelSpec.split("/", 2) : [providerID, modelSpec] + model = await Provider.getModel(provider, modelID).catch(() => undefined) + } + if (!model) { + model = await Provider.getSmallModel(providerID) + } + if (!model) { + log.warn("summarization model not available, skipping summaries") + return results + } + + const language = await Provider.getLanguage(model) + + // Build prompt with all parts to summarize + const partsPrompt = parts + .map( + (p, i) => ` +--- Part ${i + 1} (ID: ${p.id}, Tool: ${p.tool}) --- +Title: ${p.title} +Output: +${p.output.slice(0, 8000)}${p.output.length > 8000 ? "\n... (truncated)" : ""} +`, + ) + .join("\n") + + const response = await generateObject({ + model: language, + temperature: 0.1, + schema: z.object({ + summaries: z.array( + z.object({ + partId: z.string(), + summary: z.string(), + }), + ), + }), + messages: [ + { role: "system", content: SUMMARIZATION_PROMPT }, + { + role: "user", + content: `Summarize the following ${parts.length} tool output(s). Return a summary for each part ID.\n\n${partsPrompt}`, + }, + ], + }) + + for (const s of response.object.summaries) { + results.set(s.partId, s.summary) + } + + log.info("generated summaries", { count: results.size }) + } catch (err) { + log.warn("summarization failed", { error: err }) + } + + return results + } + + // ============================================================================ + // Main Pruning Logic - Tiered Priority System + // ============================================================================ + + interface PartInfo { + part: MessageV2.ToolPart + tokens: number + } - // goes backwards through parts until there are 40_000 tokens worth of tool - // calls. then erases output of previous tool calls. idea is to throw away old - // tool calls that are no longer relevant. export async function prune(input: { sessionID: string }) { const config = await Config.get() if (config.compaction?.prune === false) return + if (config.pruning?.enabled === false) return log.info("pruning") + + const pruningConfig = await getPruningConfig() const msgs = await Session.messages({ sessionID: input.sessionID }) - let total = 0 - let pruned = 0 - const toPrune = [] + + // Get the provider ID from the most recent assistant message for small model lookup + const lastAssistant = msgs.findLast((m) => m.info.role === "assistant") + const providerID = lastAssistant?.info.role === "assistant" ? lastAssistant.info.providerID : "openai" + + // Collect all tool parts by tier + const contentParts: PartInfo[] = [] + const navigationParts: PartInfo[] = [] let turns = 0 loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) { const msg = msgs[msgIndex] if (msg.info.role === "user") turns++ - if (turns < 2) continue + if (turns < 2) continue // Skip last 2 turns (recent context always protected) if (msg.info.role === "assistant" && msg.info.summary) break loop + for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) { const part = msg.parts[partIndex] - if (part.type === "tool") - if (part.state.status === "completed") { - if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue - - if (part.state.time.compacted) break loop - const estimate = Token.estimate(part.state.output) - total += estimate - if (total > PRUNE_PROTECT) { - pruned += estimate - toPrune.push(part) - } - } + if (part.type !== "tool") continue + if (part.state.status !== "completed") continue + if (part.state.time.compacted) break loop // Stop at first compacted part + + const tier = getToolTier(part.tool, pruningConfig) + if (tier === "action") continue // Never prune action tools + + const tokens = Token.estimate(part.state.output) + + if (tier === "content") { + contentParts.push({ part, tokens }) + } else { + navigationParts.push({ part, tokens }) + } } } - log.info("found", { pruned, total }) - if (pruned > PRUNE_MINIMUM) { - for (const part of toPrune) { - if (part.state.status === "completed") { - part.state.time.compacted = Date.now() - await Session.updatePart(part) + + // Calculate what to prune for each tier using configurable budgets + const contentToPrune = calculatePruneCandidates(contentParts, pruningConfig.contentBudget) + const navigationToPrune = calculatePruneCandidates(navigationParts, pruningConfig.navigationBudget) + + const totalToPrune = contentToPrune.tokens + navigationToPrune.tokens + log.info("found", { + contentTotal: contentParts.reduce((a, b) => a + b.tokens, 0), + navigationTotal: navigationParts.reduce((a, b) => a + b.tokens, 0), + contentToPrune: contentToPrune.tokens, + navigationToPrune: navigationToPrune.tokens, + }) + + if (totalToPrune < PRUNE_MINIMUM) { + log.info("skipping prune, below minimum", { totalToPrune, minimum: PRUNE_MINIMUM }) + return + } + + Bus.publish(TuiEvent.ToastShow, { + message: "Smart pruning started...", + variant: "info", + duration: 2000, + }) + + // Generate summaries for content tools being pruned (if enabled) + let summaries = new Map() + if (pruningConfig.summarizationEnabled) { + const partsToSummarize = contentToPrune.parts + .filter((p) => p.part.state.status === "completed") + .map((p) => ({ + id: p.part.id, + tool: p.part.tool, + output: (p.part.state as MessageV2.ToolStateCompleted).output, + title: (p.part.state as MessageV2.ToolStateCompleted).title, + })) + + summaries = await summarizeToolOutputs(partsToSummarize, providerID, pruningConfig.summarizationModel) + } + + let savedTokens = totalToPrune + + // Apply pruning - content tools get summaries + for (const { part } of contentToPrune.parts) { + if (part.state.status === "completed") { + const summary = summaries.get(part.id) + if (summary) { + part.state.summary = summary + savedTokens -= Token.estimate(summary) } + part.state.time.compacted = Date.now() + await Session.updatePart(part) + } + } + + // Apply pruning - navigation tools get compressed + for (const { part } of navigationToPrune.parts) { + if (part.state.status === "completed") { + const compressed = compressNavigationOutput(part.tool, part.state.output) + part.state.summary = compressed + savedTokens -= Token.estimate(compressed) + part.state.time.compacted = Date.now() + await Session.updatePart(part) + } + } + + Bus.publish(TuiEvent.ToastShow, { + title: "Smart Pruning", + message: `Saved ${Math.round(savedTokens)} tokens`, + variant: "success", + }) + + log.info("pruned", { + contentCount: contentToPrune.parts.length, + navigationCount: navigationToPrune.parts.length, + }) + } + + function calculatePruneCandidates(parts: PartInfo[], budget: number): { parts: PartInfo[]; tokens: number } { + let total = 0 + for (const p of parts) { + total += p.tokens + } + + if (total <= budget) { + return { parts: [], tokens: 0 } + } + + // Parts are in reverse chronological order (newest first from the loop). + // We want to KEEP the newest ones (within budget) and PRUNE the oldest. + // So we iterate from the START (newest), accumulating tokens until we hit the budget, + // then everything after that point gets pruned. + const toPrune: PartInfo[] = [] + let keptTokens = 0 + let budgetExceeded = false + + for (let i = 0; i < parts.length; i++) { + const p = parts[i] + if (!budgetExceeded && keptTokens + p.tokens <= budget) { + // Keep this part (it fits in budget) + keptTokens += p.tokens + } else { + // This part and all remaining (older) parts get pruned + budgetExceeded = true + toPrune.push(p) } - log.info("pruned", { count: toPrune.length }) } + + const prunedTokens = toPrune.reduce((acc, p) => acc + p.tokens, 0) + return { parts: toPrune, tokens: prunedTokens } } export async function process(input: { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2dff17a5efa..f50929cfe1f 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -243,6 +243,7 @@ export namespace MessageV2 { status: z.literal("completed"), input: z.record(z.string(), z.any()), output: z.string(), + summary: z.string().optional(), title: z.string(), metadata: z.record(z.string(), z.any()), time: z.object({ @@ -520,7 +521,9 @@ export namespace MessageV2 { state: "output-available", toolCallId: part.callID, input: part.state.input, - output: part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output, + output: part.state.time.compacted + ? (part.state.summary ?? "[Old tool result content cleared]") + : part.state.output, callProviderMetadata: part.metadata, }) } diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index a5931b6fcf5..d8bc97daf2b 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -493,6 +493,43 @@ You can control context compaction behavior through the `compaction` option. --- +### Pruning + +You can configure smart pruning behavior through the `pruning` option. This system manages context by intelligently summarizing and compressing tool outputs based on their importance. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "pruning": { + "enabled": true, + "budgets": { + "content": 60000, + "navigation": 15000 + }, + "summarization": { + "enabled": true, + "model": "openai/gpt-4o-mini" + }, + "contentTools": ["read", "webfetch"], + "navigationTools": ["grep", "ls"], + "protectedTools": ["edit", "write"] + } +} +``` + +- `enabled` - Enable smart pruning (default: `true`). +- `budgets` - Token budgets for different tool tiers. + - `content` - Budget for content tools like `read` (default: `60000`). + - `navigation` - Budget for navigation tools like `ls`, `grep` (default: `15000`). +- `summarization` - Configuration for LLM-based summarization of pruned content. + - `enabled` - Enable summarization (default: `true`). + - `model` - Model to use for summarization (default: `openai/gpt-4o-mini`). +- `contentTools` - List of additional tools to treat as content (high priority). +- `navigationTools` - List of additional tools to treat as navigation (low priority). +- `protectedTools` - List of tools that should never be pruned. + +--- + ### Watcher You can configure file watcher ignore patterns through the `watcher` option.