diff --git a/.gitignore b/.gitignore index 9f567cb5..5bf7a25f 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ Thumbs.db # OpenCode .opencode/ +# Generated prompt files (from scripts/generate-prompts.ts) +lib/prompts/*.generated.ts + # Tests (local development only) tests/ notes/ @@ -36,3 +39,5 @@ test-update.ts # Documentation (local development only) docs/ SCHEMA_NOTES.md + +repomix-output.xml \ No newline at end of file diff --git a/.repomixignore b/.repomixignore new file mode 100644 index 00000000..6bc6e2ee --- /dev/null +++ b/.repomixignore @@ -0,0 +1,9 @@ +.github/ +.logs/ +.opencode/ +dist/ +.repomixignore +repomix-output.xml +bun.lock +package-lock.jsonc +LICENCE diff --git a/README.md b/README.md index 4d39d2cd..afb761a5 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # Dynamic Context Pruning Plugin +[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/dansmolsky) [![npm version](https://img.shields.io/npm/v/@tarquinen/opencode-dcp.svg)](https://www.npmjs.com/package/@tarquinen/opencode-dcp) Automatically reduces token usage in OpenCode by removing obsolete tools from conversation history. -![DCP in action](dcp-demo5.png) +![DCP in action](assets/images/dcp-demo5.png) ## Installation @@ -27,9 +28,11 @@ DCP uses multiple tools and strategies to reduce context size: ### Tools -**Discard** — Exposes a `discard` tool that the AI can call to remove completed or noisy tool content from context. +**Distill** — Exposes a `distill` tool that the AI can call to distill valuable context into concise summaries before removing the tool content. -**Extract** — Exposes an `extract` tool that the AI can call to distill valuable context into concise summaries before removing the tool content. +**Compress** — Exposes a `compress` tool that the AI can call to collapse a large section of conversation (messages and tools) into a single summary. + +**Prune** — Exposes a `prune` tool that the AI can call to remove completed or noisy tool content from context. ### Strategies @@ -57,7 +60,7 @@ DCP uses its own config file: - Global: `~/.config/opencode/dcp.jsonc` (or `dcp.json`), created automatically on first run - Custom config directory: `$OPENCODE_CONFIG_DIR/dcp.jsonc` (or `dcp.json`), if `OPENCODE_CONFIG_DIR` is set -- Project: `.opencode/dcp.jsonc` (or `dcp.json`) in your project’s `.opencode` directory +- Project: `.opencode/dcp.jsonc` (or `dcp.json`) in your project's `.opencode` directory
Default Configuration (click to expand) @@ -96,15 +99,21 @@ DCP uses its own config file: "protectedTools": [], }, // Removes tool content from context without preservation (for completed tasks or noise) - "discard": { + "prune": { "enabled": true, }, // Distills key findings into preserved knowledge before removing raw content - "extract": { + "distill": { "enabled": true, // Show distillation content as an ignored message notification "showDistillation": false, }, + // Collapses a range of conversation content into a single summary + "compress": { + "enabled": true, + // Show summary content as an ignored message notification + "showCompression": true, + }, }, // Automatic pruning strategies "strategies": { @@ -143,12 +152,12 @@ DCP provides a `/dcp` slash command: ### Turn Protection -When enabled, turn protection prevents tool outputs from being pruned for a configurable number of message turns. This gives the AI time to reference recent tool outputs before they become prunable. Applies to both `discard` and `extract` tools, as well as automatic strategies. +When enabled, turn protection prevents tool outputs from being pruned for a configurable number of message turns. This gives the AI time to reference recent tool outputs before they become prunable. Applies to both `prune` and `distill` tools, as well as automatic strategies. ### Protected Tools By default, these tools are always protected from pruning across all strategies: -`task`, `todowrite`, `todoread`, `discard`, `extract`, `batch`, `write`, `edit` +`task`, `todowrite`, `todoread`, `distill`, `compress`, `prune`, `batch`, `write`, `edit`, `plan_enter`, `plan_exit` The `protectedTools` arrays in each section add to this default list. diff --git a/dcp-demo.png b/assets/images/dcp-demo.png similarity index 100% rename from dcp-demo.png rename to assets/images/dcp-demo.png diff --git a/dcp-demo2.png b/assets/images/dcp-demo2.png similarity index 100% rename from dcp-demo2.png rename to assets/images/dcp-demo2.png diff --git a/dcp-demo3.png b/assets/images/dcp-demo3.png similarity index 100% rename from dcp-demo3.png rename to assets/images/dcp-demo3.png diff --git a/dcp-demo4.png b/assets/images/dcp-demo4.png similarity index 100% rename from dcp-demo4.png rename to assets/images/dcp-demo4.png diff --git a/dcp-demo5.png b/assets/images/dcp-demo5.png similarity index 100% rename from dcp-demo5.png rename to assets/images/dcp-demo5.png diff --git a/dcp.schema.json b/dcp.schema.json index 91db1b3c..a9bfed22 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -100,46 +100,54 @@ "items": { "type": "string" }, - "default": [ - "task", - "todowrite", - "todoread", - "discard", - "extract", - "batch", - "write", - "edit" - ], + "default": [], "description": "Tool names that should be protected from automatic pruning" } } }, - "discard": { + "distill": { "type": "object", - "description": "Configuration for the discard tool", + "description": "Configuration for the distill tool", "additionalProperties": false, "properties": { "enabled": { "type": "boolean", "default": true, - "description": "Enable the discard tool" + "description": "Enable the distill tool" + }, + "showDistillation": { + "type": "boolean", + "default": false, + "description": "Show distillation output in the UI" } } }, - "extract": { + "compress": { "type": "object", - "description": "Configuration for the extract tool", + "description": "Configuration for the compress tool", "additionalProperties": false, "properties": { "enabled": { "type": "boolean", "default": true, - "description": "Enable the extract tool" + "description": "Enable the compress tool" }, - "showDistillation": { + "showCompression": { "type": "boolean", - "default": false, - "description": "Show distillation output in the UI" + "default": true, + "description": "Show summary output in the UI" + } + } + }, + "prune": { + "type": "object", + "description": "Configuration for the prune tool", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable the prune tool" } } } @@ -165,16 +173,7 @@ "items": { "type": "string" }, - "default": [ - "task", - "todowrite", - "todoread", - "discard", - "extract", - "batch", - "write", - "edit" - ], + "default": [], "description": "Tool names excluded from deduplication" } } @@ -211,16 +210,7 @@ "items": { "type": "string" }, - "default": [ - "task", - "todowrite", - "todoread", - "discard", - "extract", - "batch", - "write", - "edit" - ], + "default": [], "description": "Tool names excluded from error purging" } } diff --git a/index.ts b/index.ts index 0c7ae2a7..94291a6a 100644 --- a/index.ts +++ b/index.ts @@ -2,12 +2,13 @@ import type { Plugin } from "@opencode-ai/plugin" import { getConfig } from "./lib/config" import { Logger } from "./lib/logger" import { createSessionState } from "./lib/state" -import { createDiscardTool, createExtractTool } from "./lib/strategies" +import { createPruneTool, createDistillTool, createCompressTool } from "./lib/strategies" import { createChatMessageTransformHandler, createCommandExecuteHandler, createSystemPromptHandler, } from "./lib/hooks" +import { configureClientAuth, isSecureMode } from "./lib/auth" const plugin: Plugin = (async (ctx) => { const config = getConfig(ctx) @@ -19,6 +20,11 @@ const plugin: Plugin = (async (ctx) => { const logger = new Logger(config.debug) const state = createSessionState() + if (isSecureMode()) { + configureClientAuth(ctx.client) + // logger.info("Secure mode detected, configured client authentication") + } + logger.info("DCP initialized", { strategies: config.strategies, }) @@ -55,8 +61,17 @@ const plugin: Plugin = (async (ctx) => { ctx.directory, ), tool: { - ...(config.tools.discard.enabled && { - discard: createDiscardTool({ + ...(config.tools.distill.enabled && { + distill: createDistillTool({ + client: ctx.client, + state, + logger, + config, + workingDirectory: ctx.directory, + }), + }), + ...(config.tools.compress.enabled && { + compress: createCompressTool({ client: ctx.client, state, logger, @@ -64,8 +79,8 @@ const plugin: Plugin = (async (ctx) => { workingDirectory: ctx.directory, }), }), - ...(config.tools.extract.enabled && { - extract: createExtractTool({ + ...(config.tools.prune.enabled && { + prune: createPruneTool({ client: ctx.client, state, logger, @@ -84,8 +99,9 @@ const plugin: Plugin = (async (ctx) => { } const toolsToAdd: string[] = [] - if (config.tools.discard.enabled) toolsToAdd.push("discard") - if (config.tools.extract.enabled) toolsToAdd.push("extract") + if (config.tools.prune.enabled) toolsToAdd.push("prune") + if (config.tools.distill.enabled) toolsToAdd.push("distill") + if (config.tools.compress.enabled) toolsToAdd.push("compress") if (toolsToAdd.length > 0) { const existingPrimaryTools = opencodeConfig.experimental?.primary_tools ?? [] diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 00000000..8b7aa418 --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,37 @@ +export function isSecureMode(): boolean { + return !!process.env.OPENCODE_SERVER_PASSWORD +} + +export function getAuthorizationHeader(): string | undefined { + const password = process.env.OPENCODE_SERVER_PASSWORD + if (!password) return undefined + + const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" + // Use Buffer for Node.js base64 encoding (btoa may not be available in all Node versions) + const credentials = Buffer.from(`${username}:${password}`).toString("base64") + return `Basic ${credentials}` +} + +export function configureClientAuth(client: any): any { + const authHeader = getAuthorizationHeader() + + if (!authHeader) { + return client + } + + // The SDK client has an internal client with request interceptors + // Access the underlying client to add the interceptor + const innerClient = client._client || client.client + + if (innerClient?.interceptors?.request) { + innerClient.interceptors.request.use((request: Request) => { + // Only add auth header if not already present + if (!request.headers.has("Authorization")) { + request.headers.set("Authorization", authHeader) + } + return request + }) + } + + return client +} diff --git a/lib/commands/context.ts b/lib/commands/context.ts index bd2e8661..2706290d 100644 --- a/lib/commands/context.ts +++ b/lib/commands/context.ts @@ -62,6 +62,7 @@ interface TokenBreakdown { toolCount: number prunedTokens: number prunedCount: number + prunedMessageCount: number total: number } @@ -74,6 +75,7 @@ function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdo toolCount: 0, prunedTokens: state.stats.totalPruneTokens, prunedCount: state.prune.toolIds.length, + prunedMessageCount: state.prune.messageIds.length, total: 0, } @@ -112,43 +114,54 @@ function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdo const toolOutputParts: string[] = [] let firstUserText = "" let foundFirstUser = false + const foundToolIds = new Set() for (const msg of messages) { - if (isMessageCompacted(state, msg)) continue - if (msg.info.role === "user" && isIgnoredUserMessage(msg)) continue - const parts = Array.isArray(msg.parts) ? msg.parts : [] + const isCompacted = isMessageCompacted(state, msg) + const isIgnoredUser = msg.info.role === "user" && isIgnoredUserMessage(msg) + for (const part of parts) { - if (part.type === "text" && msg.info.role === "user") { + if (part.type === "tool") { + const toolPart = part as ToolPart + if (toolPart.callID && !foundToolIds.has(toolPart.callID)) { + breakdown.toolCount++ + foundToolIds.add(toolPart.callID) + } + + if (!isCompacted) { + if (toolPart.state?.input) { + const inputStr = + typeof toolPart.state.input === "string" + ? toolPart.state.input + : JSON.stringify(toolPart.state.input) + toolInputParts.push(inputStr) + } + + if (toolPart.state?.status === "completed" && toolPart.state?.output) { + const outputStr = + typeof toolPart.state.output === "string" + ? toolPart.state.output + : JSON.stringify(toolPart.state.output) + toolOutputParts.push(outputStr) + } + } + } else if ( + part.type === "text" && + msg.info.role === "user" && + !isCompacted && + !isIgnoredUser + ) { const textPart = part as TextPart const text = textPart.text || "" userTextParts.push(text) if (!foundFirstUser) { firstUserText += text } - } else if (part.type === "tool") { - const toolPart = part as ToolPart - breakdown.toolCount++ - - if (toolPart.state?.input) { - const inputStr = - typeof toolPart.state.input === "string" - ? toolPart.state.input - : JSON.stringify(toolPart.state.input) - toolInputParts.push(inputStr) - } - - if (toolPart.state?.status === "completed" && toolPart.state?.output) { - const outputStr = - typeof toolPart.state.output === "string" - ? toolPart.state.output - : JSON.stringify(toolPart.state.output) - toolOutputParts.push(outputStr) - } } } - if (msg.info.role === "user" && !isIgnoredUserMessage(msg) && !foundFirstUser) { + if (msg.info.role === "user" && !isIgnoredUser && !foundFirstUser) { foundFirstUser = true } } @@ -221,8 +234,12 @@ function formatContextMessage(breakdown: TokenBreakdown): string { if (breakdown.prunedTokens > 0) { const withoutPruning = breakdown.total + breakdown.prunedTokens + const pruned = [] + if (breakdown.prunedCount > 0) pruned.push(`${breakdown.prunedCount} tools`) + if (breakdown.prunedMessageCount > 0) + pruned.push(`${breakdown.prunedMessageCount} messages`) lines.push( - ` Pruned: ${breakdown.prunedCount} tools (~${formatTokenCount(breakdown.prunedTokens)})`, + ` Pruned: ${pruned.join(", ")} (~${formatTokenCount(breakdown.prunedTokens)})`, ) lines.push(` Current context: ~${formatTokenCount(breakdown.total)}`) lines.push(` Without DCP: ~${formatTokenCount(withoutPruning)}`) diff --git a/lib/config.ts b/lib/config.ts index f24e9680..337ddea0 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -9,15 +9,20 @@ export interface Deduplication { protectedTools: string[] } -export interface DiscardTool { +export interface PruneTool { enabled: boolean } -export interface ExtractTool { +export interface DistillTool { enabled: boolean showDistillation: boolean } +export interface CompressTool { + enabled: boolean + showCompression: boolean +} + export interface ToolSettings { nudgeEnabled: boolean nudgeFrequency: number @@ -26,8 +31,9 @@ export interface ToolSettings { export interface Tools { settings: ToolSettings - discard: DiscardTool - extract: ExtractTool + prune: PruneTool + distill: DistillTool + compress: CompressTool } export interface Commands { @@ -69,8 +75,9 @@ const DEFAULT_PROTECTED_TOOLS = [ "task", "todowrite", "todoread", - "discard", - "extract", + "prune", + "distill", + "compress", "batch", "write", "edit", @@ -98,11 +105,14 @@ export const VALID_CONFIG_KEYS = new Set([ "tools.settings.nudgeEnabled", "tools.settings.nudgeFrequency", "tools.settings.protectedTools", - "tools.discard", - "tools.discard.enabled", - "tools.extract", - "tools.extract.enabled", - "tools.extract.showDistillation", + "tools.prune", + "tools.prune.enabled", + "tools.distill", + "tools.distill.enabled", + "tools.distill.showDistillation", + "tools.compress", + "tools.compress.enabled", + "tools.compress.showCompression", "strategies", // strategies.deduplication "strategies.deduplication", @@ -267,31 +277,53 @@ function validateConfigTypes(config: Record): ValidationError[] { }) } } - if (tools.discard) { - if (tools.discard.enabled !== undefined && typeof tools.discard.enabled !== "boolean") { + if (tools.prune) { + if (tools.prune.enabled !== undefined && typeof tools.prune.enabled !== "boolean") { errors.push({ - key: "tools.discard.enabled", + key: "tools.prune.enabled", expected: "boolean", - actual: typeof tools.discard.enabled, + actual: typeof tools.prune.enabled, }) } } - if (tools.extract) { - if (tools.extract.enabled !== undefined && typeof tools.extract.enabled !== "boolean") { + if (tools.distill) { + if (tools.distill.enabled !== undefined && typeof tools.distill.enabled !== "boolean") { errors.push({ - key: "tools.extract.enabled", + key: "tools.distill.enabled", expected: "boolean", - actual: typeof tools.extract.enabled, + actual: typeof tools.distill.enabled, }) } if ( - tools.extract.showDistillation !== undefined && - typeof tools.extract.showDistillation !== "boolean" + tools.distill.showDistillation !== undefined && + typeof tools.distill.showDistillation !== "boolean" ) { errors.push({ - key: "tools.extract.showDistillation", + key: "tools.distill.showDistillation", expected: "boolean", - actual: typeof tools.extract.showDistillation, + actual: typeof tools.distill.showDistillation, + }) + } + } + if (tools.compress) { + if ( + tools.compress.enabled !== undefined && + typeof tools.compress.enabled !== "boolean" + ) { + errors.push({ + key: "tools.compress.enabled", + expected: "boolean", + actual: typeof tools.compress.enabled, + }) + } + if ( + tools.compress.showCompression !== undefined && + typeof tools.compress.showCompression !== "boolean" + ) { + errors.push({ + key: "tools.compress.showCompression", + expected: "boolean", + actual: typeof tools.compress.showCompression, }) } } @@ -439,13 +471,17 @@ const defaultConfig: PluginConfig = { nudgeFrequency: 10, protectedTools: [...DEFAULT_PROTECTED_TOOLS], }, - discard: { + prune: { enabled: true, }, - extract: { + distill: { enabled: true, showDistillation: false, }, + compress: { + enabled: true, + showCompression: true, + }, }, strategies: { deduplication: { @@ -611,12 +647,16 @@ function mergeTools( ]), ], }, - discard: { - enabled: override.discard?.enabled ?? base.discard.enabled, + prune: { + enabled: override.prune?.enabled ?? base.prune.enabled, + }, + distill: { + enabled: override.distill?.enabled ?? base.distill.enabled, + showDistillation: override.distill?.showDistillation ?? base.distill.showDistillation, }, - extract: { - enabled: override.extract?.enabled ?? base.extract.enabled, - showDistillation: override.extract?.showDistillation ?? base.extract.showDistillation, + compress: { + enabled: override.compress?.enabled ?? base.compress.enabled, + showCompression: override.compress?.showCompression ?? base.compress.showCompression, }, } } @@ -647,8 +687,9 @@ function deepCloneConfig(config: PluginConfig): PluginConfig { ...config.tools.settings, protectedTools: [...config.tools.settings.protectedTools], }, - discard: { ...config.tools.discard }, - extract: { ...config.tools.extract }, + prune: { ...config.tools.prune }, + distill: { ...config.tools.distill }, + compress: { ...config.tools.compress }, }, strategies: { deduplication: { diff --git a/lib/hooks.ts b/lib/hooks.ts index aaf43883..c10b6f00 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -5,7 +5,7 @@ import { syncToolCache } from "./state/tool-cache" import { deduplicate, supersedeWrites, purgeErrors } from "./strategies" import { prune, insertPruneToolContext } from "./messages" import { checkSession } from "./state" -import { loadPrompt } from "./prompts" +import { renderSystemPrompt } from "./prompts" import { handleStatsCommand } from "./commands/stats" import { handleContextCommand } from "./commands/context" import { handleHelpCommand } from "./commands/help" @@ -33,22 +33,17 @@ export function createSystemPromptHandler( return } - const discardEnabled = config.tools.discard.enabled - const extractEnabled = config.tools.extract.enabled - - let promptName: string - if (discardEnabled && extractEnabled) { - promptName = "system/system-prompt-both" - } else if (discardEnabled) { - promptName = "system/system-prompt-discard" - } else if (extractEnabled) { - promptName = "system/system-prompt-extract" - } else { + const flags = { + prune: config.tools.prune.enabled, + distill: config.tools.distill.enabled, + compress: config.tools.compress.enabled, + } + + if (!flags.prune && !flags.distill && !flags.compress) { return } - const syntheticPrompt = loadPrompt(promptName) - output.system.push(syntheticPrompt) + output.system.push(renderSystemPrompt(flags)) } } diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 491ecd6c..5ebe6552 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -2,54 +2,80 @@ import type { SessionState, WithParts } from "../state" import type { Logger } from "../logger" import type { PluginConfig } from "../config" import type { UserMessage } from "@opencode-ai/sdk/v2" -import { loadPrompt } from "../prompts" +import { renderNudge } from "../prompts" import { extractParameterKey, buildToolIdList, - createSyntheticAssistantMessage, createSyntheticUserMessage, + createSyntheticAssistantMessage, createSyntheticToolPart, isDeepSeekOrKimi, isIgnoredUserMessage, } from "./utils" import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" -import { getLastUserMessage } from "../shared-utils" - -const getNudgeString = (config: PluginConfig): string => { - const discardEnabled = config.tools.discard.enabled - const extractEnabled = config.tools.extract.enabled - - if (discardEnabled && extractEnabled) { - return loadPrompt(`nudge/nudge-both`) - } else if (discardEnabled) { - return loadPrompt(`nudge/nudge-discard`) - } else if (extractEnabled) { - return loadPrompt(`nudge/nudge-extract`) - } - return "" -} +import { getLastUserMessage, isMessageCompacted } from "../shared-utils" -const wrapPrunableTools = (content: string): string => ` -The following tools have been invoked and are available for pruning. This list does not mandate immediate action. Consider your current goals and the resources you need before discarding valuable tool inputs or outputs. Consolidate your prunes for efficiency; it is rarely worth pruning a single tiny tool output. Keep the context free of noise. +export const wrapPrunableTools = (content: string): string => ` +The following tools have been invoked and are available for pruning. This list does not mandate immediate action. Consider your current goals and the resources you need before pruning valuable tool inputs or outputs. Consolidate your prunes for efficiency; it is rarely worth pruning a single tiny tool output. Keep the context free of noise. ${content} ` -const getCooldownMessage = (config: PluginConfig): string => { - const discardEnabled = config.tools.discard.enabled - const extractEnabled = config.tools.extract.enabled +export const wrapCompressContext = (messageCount: number): string => ` +Compress available. Conversation: ${messageCount} messages. +Compress collapses completed task sequences or exploration phases into summaries. +Uses text boundaries [startString, endString, topic, summary]. +` + +export const wrapCooldownMessage = (flags: { + prune: boolean + distill: boolean + compress: boolean +}): string => { + const enabledTools: string[] = [] + if (flags.prune) enabledTools.push("prune") + if (flags.distill) enabledTools.push("distill") + if (flags.compress) enabledTools.push("compress") let toolName: string - if (discardEnabled && extractEnabled) { - toolName = "discard or extract tools" - } else if (discardEnabled) { - toolName = "discard tool" + if (enabledTools.length === 0) { + toolName = "pruning tools" + } else if (enabledTools.length === 1) { + toolName = `${enabledTools[0]} tool` } else { - toolName = "extract tool" + const last = enabledTools.pop() + toolName = `${enabledTools.join(", ")} or ${last} tools` } - return ` -Context management was just performed. Do not use the ${toolName} again. A fresh list will be available after your next tool use. -` + return ` +Context management was just performed. Do NOT use the ${toolName} again. A fresh list will be available after your next tool use. +` +} + +const getNudgeString = (config: PluginConfig): string => { + const flags = { + prune: config.tools.prune.enabled, + distill: config.tools.distill.enabled, + compress: config.tools.compress.enabled, + } + + if (!flags.prune && !flags.distill && !flags.compress) { + return "" + } + + return renderNudge(flags) +} + +const getCooldownMessage = (config: PluginConfig): string => { + return wrapCooldownMessage({ + prune: config.tools.prune.enabled, + distill: config.tools.distill.enabled, + compress: config.tools.compress.enabled, + }) +} + +const buildCompressContext = (state: SessionState, messages: WithParts[]): string => { + const messageCount = messages.filter((msg) => !isMessageCompacted(state, msg)).length + return wrapCompressContext(messageCount) } const buildPrunableToolsList = ( @@ -107,35 +133,53 @@ export const insertPruneToolContext = ( logger: Logger, messages: WithParts[], ): void => { - if (!config.tools.discard.enabled && !config.tools.extract.enabled) { + const pruneEnabled = config.tools.prune.enabled + const distillEnabled = config.tools.distill.enabled + const compressEnabled = config.tools.compress.enabled + + if (!pruneEnabled && !distillEnabled && !compressEnabled) { return } - let prunableToolsContent: string + const pruneOrDistillEnabled = pruneEnabled || distillEnabled + const contentParts: string[] = [] if (state.lastToolPrune) { logger.debug("Last tool was prune - injecting cooldown message") - prunableToolsContent = getCooldownMessage(config) + contentParts.push(getCooldownMessage(config)) } else { - const prunableToolsList = buildPrunableToolsList(state, config, logger, messages) - if (!prunableToolsList) { - return + // Inject only when prune or distill is enabled + if (pruneOrDistillEnabled) { + const prunableToolsList = buildPrunableToolsList(state, config, logger, messages) + if (prunableToolsList) { + // logger.debug("prunable-tools: \n" + prunableToolsList) + contentParts.push(prunableToolsList) + } } - logger.debug("prunable-tools: \n" + prunableToolsList) + // Inject always when compress is enabled (every turn) + if (compressEnabled) { + const compressContext = buildCompressContext(state, messages) + // logger.debug("compress-context: \n" + compressContext) + contentParts.push(compressContext) + } - let nudgeString = "" + // Add nudge if threshold reached if ( config.tools.settings.nudgeEnabled && state.nudgeCounter >= config.tools.settings.nudgeFrequency ) { logger.info("Inserting prune nudge message") - nudgeString = "\n" + getNudgeString(config) + contentParts.push(getNudgeString(config)) } + } - prunableToolsContent = prunableToolsList + nudgeString + if (contentParts.length === 0) { + return } + const combinedContent = contentParts.join("\n") + const lastUserMessage = getLastUserMessage(messages) if (!lastUserMessage) { return @@ -144,27 +188,33 @@ export const insertPruneToolContext = ( const userInfo = lastUserMessage.info as UserMessage const variant = state.variant ?? userInfo.variant - let lastNonIgnoredMessage: WithParts | undefined - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i] - if (!(msg.info.role === "user" && isIgnoredUserMessage(msg))) { - lastNonIgnoredMessage = msg - break - } - } + // Find the last message that isn't an ignored user message + const lastNonIgnoredMessage = messages.findLast( + (msg) => !(msg.info.role === "user" && isIgnoredUserMessage(msg)), + ) + // It's not safe to inject assistant role messages following a user message as models such + // as Claude expect the assistant "turn" to start with reasoning parts. Reasoning parts in many + // cases also cannot be faked as they may be encrypted by the model. + // Gemini only accepts synth reasoning text if it is "skip_thought_signature_validator" if (!lastNonIgnoredMessage || lastNonIgnoredMessage.info.role === "user") { - messages.push(createSyntheticUserMessage(lastUserMessage, prunableToolsContent, variant)) + messages.push(createSyntheticUserMessage(lastUserMessage, combinedContent, variant)) } else { + // For DeepSeek and Kimi, append tool part to existing message, for some reason they don't + // output reasoning parts following an assistant injection containing either just text part, + // or text part with synth reasoning, and there's no docs on how their reasoning encryption + // works as far as I can find. IDK what's going on here, seems like the only possible ways + // to inject for them is a user role message, or a tool part apeended to last assistant message. const providerID = userInfo.model?.providerID || "" const modelID = userInfo.model?.modelID || "" if (isDeepSeekOrKimi(providerID, modelID)) { - const toolPart = createSyntheticToolPart(lastNonIgnoredMessage, prunableToolsContent) + const toolPart = createSyntheticToolPart(lastNonIgnoredMessage, combinedContent) lastNonIgnoredMessage.parts.push(toolPart) } else { + // Create a new assistant message with just a text part messages.push( - createSyntheticAssistantMessage(lastUserMessage, prunableToolsContent, variant), + createSyntheticAssistantMessage(lastUserMessage, combinedContent, variant), ) } } diff --git a/lib/messages/prune.ts b/lib/messages/prune.ts index fb86036e..8d616270 100644 --- a/lib/messages/prune.ts +++ b/lib/messages/prune.ts @@ -1,7 +1,9 @@ import type { SessionState, WithParts } from "../state" import type { Logger } from "../logger" import type { PluginConfig } from "../config" -import { isMessageCompacted } from "../shared-utils" +import { isMessageCompacted, getLastUserMessage } from "../shared-utils" +import { createSyntheticUserMessage, COMPRESS_SUMMARY_PREFIX } from "./utils" +import type { UserMessage } from "@opencode-ai/sdk/v2" const PRUNED_TOOL_OUTPUT_REPLACEMENT = "[Output removed to save context - information superseded or no longer needed]" @@ -14,6 +16,7 @@ export const prune = ( config: PluginConfig, messages: WithParts[], ): void => { + filterCompressedRanges(state, logger, messages) pruneToolOutputs(state, logger, messages) pruneToolInputs(state, logger, messages) pruneToolErrors(state, logger, messages) @@ -103,3 +106,56 @@ const pruneToolErrors = (state: SessionState, logger: Logger, messages: WithPart } } } + +const filterCompressedRanges = ( + state: SessionState, + logger: Logger, + messages: WithParts[], +): void => { + if (!state.prune.messageIds?.length) { + return + } + + const result: WithParts[] = [] + + for (const msg of messages) { + const msgId = msg.info.id + + // Check if there's a summary to inject at this anchor point + const summary = state.compressSummaries?.find((s) => s.anchorMessageId === msgId) + if (summary) { + // Find user message for variant and as base for synthetic message + const msgIndex = messages.indexOf(msg) + const userMessage = getLastUserMessage(messages, msgIndex) + + if (userMessage) { + const userInfo = userMessage.info as UserMessage + const summaryContent = COMPRESS_SUMMARY_PREFIX + summary.summary + result.push( + createSyntheticUserMessage(userMessage, summaryContent, userInfo.variant), + ) + + logger.info("Injected compress summary", { + anchorMessageId: msgId, + summaryLength: summary.summary.length, + }) + } else { + logger.warn("No user message found for compress summary", { + anchorMessageId: msgId, + }) + } + } + + // Skip messages that are in the prune list + if (state.prune.messageIds.includes(msgId)) { + continue + } + + // Normal message, include it + result.push(msg) + } + + // Replace messages array contents + messages.length = 0 + messages.push(...result) +} diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index 406b6f42..a0035727 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -1,11 +1,12 @@ +import { ulid } from "ulid" import { Logger } from "../logger" import { isMessageCompacted } from "../shared-utils" import type { SessionState, WithParts } from "../state" import type { UserMessage } from "@opencode-ai/sdk/v2" -const SYNTHETIC_MESSAGE_ID = "msg_01234567890123456789012345" -const SYNTHETIC_PART_ID = "prt_01234567890123456789012345" -const SYNTHETIC_CALL_ID = "call_01234567890123456789012345" +export const COMPRESS_SUMMARY_PREFIX = "[Compressed conversation block]\n\n" + +const generateUniqueId = (prefix: string): string => `${prefix}_${ulid()}` export const isDeepSeekOrKimi = (providerID: string, modelID: string): boolean => { const lowerProviderID = providerID.toLowerCase() @@ -26,21 +27,24 @@ export const createSyntheticUserMessage = ( const userInfo = baseMessage.info as UserMessage const now = Date.now() + const messageId = generateUniqueId("msg") + const partId = generateUniqueId("prt") + return { info: { - id: SYNTHETIC_MESSAGE_ID, + id: messageId, sessionID: userInfo.sessionID, role: "user" as const, - agent: userInfo.agent || "code", + agent: userInfo.agent, model: userInfo.model, time: { created: now }, ...(variant !== undefined && { variant }), }, parts: [ { - id: SYNTHETIC_PART_ID, + id: partId, sessionID: userInfo.sessionID, - messageID: SYNTHETIC_MESSAGE_ID, + messageID: messageId, type: "text", text: content, }, @@ -56,9 +60,12 @@ export const createSyntheticAssistantMessage = ( const userInfo = baseMessage.info as UserMessage const now = Date.now() + const messageId = generateUniqueId("msg") + const partId = generateUniqueId("prt") + return { info: { - id: SYNTHETIC_MESSAGE_ID, + id: messageId, sessionID: userInfo.sessionID, role: "assistant" as const, agent: userInfo.agent || "code", @@ -77,9 +84,9 @@ export const createSyntheticAssistantMessage = ( }, parts: [ { - id: SYNTHETIC_PART_ID, + id: partId, sessionID: userInfo.sessionID, - messageID: SYNTHETIC_MESSAGE_ID, + messageID: messageId, type: "text", text: content, }, @@ -91,12 +98,15 @@ export const createSyntheticToolPart = (baseMessage: WithParts, content: string) const userInfo = baseMessage.info as UserMessage const now = Date.now() + const partId = generateUniqueId("prt") + const callId = generateUniqueId("call") + return { - id: SYNTHETIC_PART_ID, + id: partId, sessionID: userInfo.sessionID, messageID: baseMessage.info.id, type: "tool" as const, - callID: SYNTHETIC_CALL_ID, + callID: callId, tool: "context_info", state: { status: "completed" as const, @@ -264,3 +274,7 @@ export const isIgnoredUserMessage = (message: WithParts): boolean => { return true } + +export const findMessageIndex = (messages: WithParts[], messageId: string): number => { + return messages.findIndex((msg) => msg.info.id === messageId) +} diff --git a/lib/prompts/compress-tool-spec.ts b/lib/prompts/compress-tool-spec.ts new file mode 100644 index 00000000..0a08b08f --- /dev/null +++ b/lib/prompts/compress-tool-spec.ts @@ -0,0 +1,57 @@ +export const COMPRESS_TOOL_SPEC = `Collapses a contiguous range of conversation into a single summary. + +## When to Use This Tool + +Use \`compress\` when you want to condense an entire sequence of work into a brief summary: + +- **Phase Completion:** You completed a phase (research, tool calls, implementation) and want to collapse the entire sequence into a summary. +- **Exploration Done:** You explored multiple files or ran multiple commands and only need a summary of what you learned. +- **Failed Attempts:** You tried several unsuccessful approaches and want to condense them into a brief note. +- **Verbose Output:** A section of conversation has grown large but can be summarized without losing critical details. + +## When NOT to Use This Tool + +- **If you need specific details:** If you'll need exact code, file contents, or error messages from the range, keep them. +- **For individual tool outputs:** Use \`prune\` or \`distill\` for single tool outputs. Compress targets conversation ranges. +- **If it's recent content:** You may still need recent work for the current phase. + +## How It Works + +1. \`startString\` — A unique text string that marks the start of the range to compress +2. \`endString\` — A unique text string that marks the end of the range to compress +3. \`topic\` — A short label (3-5 words) describing the compressed content +4. \`summary\` — The replacement text that will be inserted + +Everything between startString and endString (inclusive) is removed and replaced with your summary. + +**Important:** The compress will FAIL if \`startString\` or \`endString\` is not found in the conversation. The compress will also FAIL if either string is found multiple times. Provide a larger string with more surrounding context to uniquely identify the intended match. + +## Best Practices +- **Choose unique strings:** Pick text that appears only once in the conversation. +- **Write concise topics:** Examples: "Auth System Exploration", "Token Logic Refactor" +- **Write comprehensive summaries:** Include key information like file names, function signatures, and important findings. +- **Timing:** Best used after finishing a work phase, not during active exploration. + +## Format + +- \`input\`: Array with four elements: [startString, endString, topic, summary] + +## Example + + +Conversation: [Asked about auth] -> [Read 5 files] -> [Analyzed patterns] -> [Found "JWT tokens with 24h expiry"] + +[Uses compress with: + input: [ + "Asked about authentication", + "JWT tokens with 24h expiry", + "Auth System Exploration", + "Auth: JWT 24h expiry, bcrypt passwords, refresh rotation. Files: auth.ts, tokens.ts, middleware/auth.ts" + ] +] + + + +Assistant: [Just finished reading auth.ts] +I've read the auth file and now need to make edits based on it. I'm keeping this in context rather than compressing. +` diff --git a/lib/prompts/discard-tool-spec.ts b/lib/prompts/discard-tool-spec.ts deleted file mode 100644 index e5084212..00000000 --- a/lib/prompts/discard-tool-spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -export const DISCARD_TOOL_SPEC = `Discards tool outputs from context to manage conversation size and reduce noise. - -## IMPORTANT: The Prunable List -A \`\` list is provided to you showing available tool outputs you can discard when there are tools available for pruning. Each line has the format \`ID: tool, parameter\` (e.g., \`20: read, /path/to/file.ts\`). You MUST only use numeric IDs that appear in this list to select which tools to discard. - -## When to Use This Tool - -Use \`discard\` for removing tool content that is no longer needed - -- **Noise:** Irrelevant, unhelpful, or superseded outputs that provide no value. -- **Task Completion:** Work is complete and there's no valuable information worth preserving. - -## When NOT to Use This Tool - -- **If the output contains useful information:** Keep it in context rather than discarding. -- **If you'll need the output later:** Don't discard files you plan to edit or context you'll need for implementation. - -## Best Practices -- **Strategic Batching:** Don't discard single small tool outputs (like short bash commands) unless they are pure noise. Wait until you have several items to perform high-impact discards. -- **Think ahead:** Before discarding, ask: "Will I need this output for an upcoming task?" If yes, keep it. - -## Format - -- \`ids\`: Array where the first element is the reason, followed by numeric IDs from the \`\` list - -Reasons: \`noise\` | \`completion\` - -## Example - - -Assistant: [Reads 'wrong_file.ts'] -This file isn't relevant to the auth system. I'll remove it to clear the context. -[Uses discard with ids: ["noise", "5"]] - - - -Assistant: [Runs tests, they pass] -The tests passed and I don't need to preserve any details. I'll clean up now. -[Uses discard with ids: ["completion", "20", "21"]] -` diff --git a/lib/prompts/extract-tool-spec.ts b/lib/prompts/distill-tool-spec.ts similarity index 61% rename from lib/prompts/extract-tool-spec.ts rename to lib/prompts/distill-tool-spec.ts index 9324dc0c..9fccc048 100644 --- a/lib/prompts/extract-tool-spec.ts +++ b/lib/prompts/distill-tool-spec.ts @@ -1,14 +1,14 @@ -export const EXTRACT_TOOL_SPEC = `Extracts key findings from tool outputs into distilled knowledge, then removes the raw outputs from context. +export const DISTILL_TOOL_SPEC = `Distills key findings from tool outputs into preserved knowledge, then removes the raw outputs from context. ## IMPORTANT: The Prunable List -A \`\` list is provided to you showing available tool outputs you can extract from when there are tools available for pruning. Each line has the format \`ID: tool, parameter\` (e.g., \`20: read, /path/to/file.ts\`). You MUST only use numeric IDs that appear in this list to select which tools to extract. +A \`\` list is provided to you showing available tool outputs you can distill from when there are tools available for pruning. Each line has the format \`ID: tool, parameter\` (e.g., \`20: read, /path/to/file.ts\`). You MUST only use numeric IDs that appear in this list to select which tools to distill. ## When to Use This Tool -Use \`extract\` when you have gathered useful information that you want to **preserve in distilled form** before removing the raw outputs: +Use \`distill\` when you have individual tool outputs with valuable information you want to **preserve in distilled form** before removing the raw content: -- **Task Completion:** You completed a unit of work and want to preserve key findings. -- **Knowledge Preservation:** You have context that contains valuable information, but also a lot of unnecessary detail - you only need to preserve some specifics. +- **Large Outputs:** The raw output is too large but contains valuable technical details worth keeping. +- **Knowledge Preservation:** You have context that contains valuable information (signatures, logic, constraints) but also a lot of unnecessary detail. ## When NOT to Use This Tool @@ -17,31 +17,31 @@ Use \`extract\` when you have gathered useful information that you want to **pre ## Best Practices -- **Strategic Batching:** Wait until you have several items or a few large outputs to extract, rather than doing tiny, frequent extractions. Aim for high-impact extractions that significantly reduce context size. -- **Think ahead:** Before extracting, ask: "Will I need the raw output for an upcoming task?" If you researched a file you'll later edit, do NOT extract it. +- **Strategic Batching:** Wait until you have several items or a few large outputs to distill, rather than doing tiny, frequent distillations. Aim for high-impact distillations that significantly reduce context size. +- **Think ahead:** Before distilling, ask: "Will I need the raw output for upcoming work?" If you researched a file you'll later edit, do NOT distill it. ## Format - \`ids\`: Array of numeric IDs as strings from the \`\` list - \`distillation\`: Array of strings, one per ID (positional: distillation[0] is for ids[0], etc.) -Each distillation string should capture the essential information you need to preserve - function signatures, logic, constraints, values, etc. Be as detailed as needed for your task. +Each distillation string should capture the essential information you need to preserve - function signatures, logic, constraints, values, etc. Be as detailed as needed. ## Example - + Assistant: [Reads auth service and user types] -I'll preserve the key details before extracting. -[Uses extract with: +I'll preserve the key details before distilling. +[Uses distill with: ids: ["10", "11"], distillation: [ "auth.ts: validateToken(token: string) -> User|null checks cache first (5min TTL) then OIDC. hashPassword uses bcrypt 12 rounds. Tokens must be 128+ chars.", "user.ts: interface User { id: string; email: string; permissions: ('read'|'write'|'admin')[]; status: 'active'|'suspended' }" ] ] - + Assistant: [Reads 'auth.ts' to understand the login flow] -I've understood the auth flow. I'll need to modify this file to add the new validation, so I'm keeping this read in context rather than extracting. +I've understood the auth flow. I'll need to modify this file to add the new validation, so I'm keeping this read in context rather than distilling. ` diff --git a/lib/prompts/index.ts b/lib/prompts/index.ts index bdfbc865..c80115f4 100644 --- a/lib/prompts/index.ts +++ b/lib/prompts/index.ts @@ -1,26 +1,44 @@ // Tool specs -import { DISCARD_TOOL_SPEC } from "./discard-tool-spec" -import { EXTRACT_TOOL_SPEC } from "./extract-tool-spec" +import { PRUNE_TOOL_SPEC } from "./prune-tool-spec" +import { DISTILL_TOOL_SPEC } from "./distill-tool-spec" +import { COMPRESS_TOOL_SPEC } from "./compress-tool-spec" -// System prompts -import { SYSTEM_PROMPT_BOTH } from "./system/both" -import { SYSTEM_PROMPT_DISCARD } from "./system/discard" -import { SYSTEM_PROMPT_EXTRACT } from "./system/extract" +// Generated prompts (from .md files via scripts/generate-prompts.ts) +import { SYSTEM as SYSTEM_PROMPT } from "./system.generated" +import { NUDGE } from "./nudge.generated" -// Nudge prompts -import { NUDGE_BOTH } from "./nudge/both" -import { NUDGE_DISCARD } from "./nudge/discard" -import { NUDGE_EXTRACT } from "./nudge/extract" +export interface ToolFlags { + distill: boolean + compress: boolean + prune: boolean +} + +function processConditionals(template: string, flags: ToolFlags): string { + const tools = ["distill", "compress", "prune"] as const + let result = template + // Strip comments: // ... // + result = result.replace(/\/\/.*?\/\//g, "") + // Process tool conditionals + for (const tool of tools) { + const regex = new RegExp(`<${tool}>([\\s\\S]*?)`, "g") + result = result.replace(regex, (_, content) => (flags[tool] ? content : "")) + } + // Collapse multiple blank/whitespace-only lines to single blank line + return result.replace(/\n([ \t]*\n)+/g, "\n\n").trim() +} + +export function renderSystemPrompt(flags: ToolFlags): string { + return processConditionals(SYSTEM_PROMPT, flags) +} + +export function renderNudge(flags: ToolFlags): string { + return processConditionals(NUDGE, flags) +} const PROMPTS: Record = { - "discard-tool-spec": DISCARD_TOOL_SPEC, - "extract-tool-spec": EXTRACT_TOOL_SPEC, - "system/system-prompt-both": SYSTEM_PROMPT_BOTH, - "system/system-prompt-discard": SYSTEM_PROMPT_DISCARD, - "system/system-prompt-extract": SYSTEM_PROMPT_EXTRACT, - "nudge/nudge-both": NUDGE_BOTH, - "nudge/nudge-discard": NUDGE_DISCARD, - "nudge/nudge-extract": NUDGE_EXTRACT, + "prune-tool-spec": PRUNE_TOOL_SPEC, + "distill-tool-spec": DISTILL_TOOL_SPEC, + "compress-tool-spec": COMPRESS_TOOL_SPEC, } export function loadPrompt(name: string, vars?: Record): string { diff --git a/lib/prompts/nudge.md b/lib/prompts/nudge.md new file mode 100644 index 00000000..078f166e --- /dev/null +++ b/lib/prompts/nudge.md @@ -0,0 +1,12 @@ + +CRITICAL CONTEXT WARNING +Your context window is filling with tool. Strict adherence to context hygiene is required. + +PROTOCOL +You should prioritize context management, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must perform context management. + +IMMEDIATE ACTION REQUIRED +KNOWLEDGE PRESERVATION: If holding valuable raw data you POTENTIALLY will need in your task, use the `distill` tool. Produce a high-fidelity distillation to preserve insights - be thorough +NOISE REMOVAL: If you read files or ran commands that yielded no value, use the `prune` tool to remove them. If newer tools supersedes older ones, prune the old +PHASE COMPLETION: If a phase is complete, use the `compress` tool to condense the entire sequence into a detailed summary + diff --git a/lib/prompts/nudge/both.ts b/lib/prompts/nudge/both.ts deleted file mode 100644 index 50fc0a9d..00000000 --- a/lib/prompts/nudge/both.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const NUDGE_BOTH = ` -**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. - -**Immediate Actions Required:** -1. **Task Completion:** If a sub-task is complete, decide: use \`discard\` if no valuable context to preserve (default), or use \`extract\` if insights are worth keeping. -2. **Noise Removal:** If you read files or ran commands that yielded no value, use \`discard\` to remove them. -3. **Knowledge Preservation:** If you are holding valuable raw data you'll need to reference later, use \`extract\` to distill the insights and remove the raw entry. - -**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must perform context management. -` diff --git a/lib/prompts/nudge/discard.ts b/lib/prompts/nudge/discard.ts deleted file mode 100644 index 18e92504..00000000 --- a/lib/prompts/nudge/discard.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const NUDGE_DISCARD = ` -**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. - -**Immediate Actions Required:** -1. **Task Completion:** If a sub-task is complete, use the \`discard\` tool to remove the tools used. -2. **Noise Removal:** If you read files or ran commands that yielded no value, use the \`discard\` tool to remove them. - -**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must discard unneeded tool outputs. -` diff --git a/lib/prompts/nudge/extract.ts b/lib/prompts/nudge/extract.ts deleted file mode 100644 index 243f5855..00000000 --- a/lib/prompts/nudge/extract.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const NUDGE_EXTRACT = ` -**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. - -**Immediate Actions Required:** -1. **Task Completion:** If you have completed work, extract key findings from the tools used. Scale distillation depth to the value of the content. -2. **Knowledge Preservation:** If you are holding valuable raw data you'll need to reference later, use the \`extract\` tool with high-fidelity distillation to preserve the insights and remove the raw entry. - -**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must extract valuable findings from tool outputs. -` diff --git a/lib/prompts/prune-tool-spec.ts b/lib/prompts/prune-tool-spec.ts new file mode 100644 index 00000000..c2ea3cbb --- /dev/null +++ b/lib/prompts/prune-tool-spec.ts @@ -0,0 +1,39 @@ +export const PRUNE_TOOL_SPEC = `Prunes tool outputs from context to manage conversation size and reduce noise. + +## IMPORTANT: The Prunable List +A \`\` list is provided to you showing available tool outputs you can prune when there are tools available for pruning. Each line has the format \`ID: tool, parameter\` (e.g., \`20: read, /path/to/file.ts\`). You MUST only use numeric IDs that appear in this list to select which tools to prune. + +## When to Use This Tool + +Use \`prune\` for removing individual tool outputs that are no longer needed: + +- **Noise:** Irrelevant, unhelpful, or superseded outputs that provide no value. +- **Wrong Files:** You read or accessed something that turned out to be irrelevant. +- **Outdated Info:** Outputs that have been superseded by newer information. + +## When NOT to Use This Tool + +- **If the output contains useful information:** Keep it in context rather than pruning. +- **If you'll need the output later:** Don't prune files you plan to edit or context you'll need for implementation. + +## Best Practices +- **Strategic Batching:** Don't prune single small tool outputs (like short bash commands) unless they are pure noise. Wait until you have several items to perform high-impact prunes. +- **Think ahead:** Before pruning, ask: "Will I need this output for upcoming work?" If yes, keep it. + +## Format + +- \`ids\`: Array of numeric IDs as strings from the \`\` list + +## Example + + +Assistant: [Reads 'wrong_file.ts'] +This file isn't relevant to the auth system. I'll remove it to clear the context. +[Uses prune with ids: ["5"]] + + + +Assistant: [Reads config.ts, then reads updated config.ts after changes] +The first read is now outdated. I'll prune it and keep the updated version. +[Uses prune with ids: ["20"]] +` diff --git a/lib/prompts/system.md b/lib/prompts/system.md new file mode 100644 index 00000000..a0574236 --- /dev/null +++ b/lib/prompts/system.md @@ -0,0 +1,85 @@ + + + +ENVIRONMENT +You are operating in a context-constrained environment and must proactively manage your context window. The environment calls the `context_info` tool to provide an up-to-date list after each turn. Use this information when deciding what to prune. + +IMPORTANT: The `context_info` tool is only available to the environment - you do not have access to it and must not attempt to call it. + +AVAILABLE TOOLS +`prune`: Remove individual tool outputs that are noise, irrelevant, or superseded. No preservation of content. +`distill`: Distill key findings from individual tool outputs into preserved knowledge. Use when you need to preserve valuable technical details. +`compress`: Collapse a contiguous range of conversation (completed phases) into a single summary. + +PRUNE METHODICALLY - BATCH YOUR ACTIONS +Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by pruning. Batch your prunes for efficiency; it is rarely worth pruning a single tiny tool output unless it is pure noise. Evaluate what SHOULD be pruned before jumping the gun. + +You MUST NOT prune when: + +- The tool output will be needed for upcoming implementation work +- The output contains files or context you'll need to reference when making edits + +Pruning that forces you to re-call the same tool later is a net loss. Only prune when you're confident the information won't be needed again. + + +WHEN TO PRUNE +- **Noise Removal:** Outputs that are irrelevant, unhelpful, or superseded by newer info. +- **Wrong Files:** You read or accessed something that turned out to be irrelevant to the current work. +- **Outdated Info:** Outputs that have been superseded by newer information. + +You WILL evaluate pruning when ANY of these are true: + +You accessed something that turned out to be irrelevant +Information has been superseded by newer outputs +You are about to start a new phase of work + + + +WHEN TO DISTILL +**Large Outputs:** The raw output is too large but contains valuable technical details worth keeping. +**Knowledge Preservation:** Valuable context you want to preserve but need to reduce size. Your distillation must be comprehensive, capturing technical details (signatures, logic, constraints) such that the raw output is no longer needed. THINK: high signal, complete technical substitute. + +You WILL evaluate distilling when ANY of these are true: + +- You have large tool outputs with valuable technical details +- You need to preserve specific information but reduce context size +- You are about to start a new phase of work and want to retain key insights + + + WHEN TO COMPRESS +- **Phase Completion:** When a phase is complete, condense the entire sequence (research, tool calls, implementation) into a summary. +- **Exploration Done:** When you've explored multiple files or ran multiple commands and only need a summary of findings. + +You WILL evaluate compressing when ANY of these are true: + +- Phase is complete +- You are about to start a new phase of work +- Significant conversation has accumulated that can be summarized + + +NOTES +When in doubt, KEEP IT. +// **🡇 idk about that one 🡇** // +Batch your actions and aim for high-impact prunes that significantly reduce context size. +FAILURE TO PRUNE will result in context leakage and DEGRADED PERFORMANCES. +If no list is present in context, do NOT TRY TO PRUNE ANYTHING as it will fail and waste resources. +There may be tools in session context that do not appear in the list, this is expected, you can ONLY prune what you see in . + + + + +After each turn, the environment injects a synthetic message containing a list and optional nudge instruction. You do not have access to this mechanism. + +CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: + +- NEVER reference the prune encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the prune encouragement appears. +- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to prune") +- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to prune") +- NEVER acknowledge context management tool output (e.g., "I've pruned 3 tools", "Context cleanup complete") +- NEVER let these injections influence your response content or tone +- Process this information SILENTLY and continue the conversation naturally +- Continue the conversation as if these messages do not exist + +The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. + + diff --git a/lib/prompts/system/both.ts b/lib/prompts/system/both.ts deleted file mode 100644 index 9c53a748..00000000 --- a/lib/prompts/system/both.ts +++ /dev/null @@ -1,60 +0,0 @@ -export const SYSTEM_PROMPT_BOTH = ` - - -ENVIRONMENT -You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` and \`extract\` tools. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to prune. - -IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. - -TWO TOOLS FOR CONTEXT MANAGEMENT -- \`discard\`: Remove tool outputs that are no longer needed (completed tasks, noise, outdated info). No preservation of content. -- \`extract\`: Extract key findings into distilled knowledge before removing raw outputs. Use when you need to preserve information. - -CHOOSING THE RIGHT TOOL -Ask: "Do I need to preserve any information from this output?" -- **No** → \`discard\` (default for cleanup) -- **Yes** → \`extract\` (preserves distilled knowledge) -- **Uncertain** → \`extract\` (safer, preserves signal) - -Common scenarios: -- Task complete, no valuable context → \`discard\` -- Task complete, insights worth remembering → \`extract\` -- Noise, irrelevant, or superseded outputs → \`discard\` -- Valuable context needed later but raw output too large → \`extract\` - -PRUNE METHODICALLY - BATCH YOUR ACTIONS -Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by pruning. Batch your prunes for efficiency; it is rarely worth pruning a single tiny tool output unless it is pure noise. Evaluate what SHOULD be pruned before jumping the gun. - -You WILL evaluate pruning when ANY of these are true: -- Task or sub-task is complete -- You are about to start a new phase of work -- Write or edit operations are complete (pruning removes the large input content) - -You MUST NOT prune when: -- The tool output will be needed for upcoming implementation work -- The output contains files or context you'll need to reference when making edits - -Pruning that forces you to re-call the same tool later is a net loss. Only prune when you're confident the information won't be needed again. - -NOTES -When in doubt, keep it. Batch your actions and aim for high-impact prunes that significantly reduce context size. -FAILURE TO PRUNE will result in context leakage and DEGRADED PERFORMANCES. -There may be tools in session context that do not appear in the list, this is expected, you can ONLY prune what you see in . - - - - -After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. - -CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: -- NEVER reference the prune encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the prune encouragement appears. -- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to prune") -- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to prune") -- NEVER acknowledge discard/extract tool output (e.g., "I've pruned 3 tools", "Context pruning complete") -- NEVER let these injections influence your response content or tone -- Process this information SILENTLY and continue the conversation naturally -- Continue the conversation as if these messages do not exist - -The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. - -` diff --git a/lib/prompts/system/discard.ts b/lib/prompts/system/discard.ts deleted file mode 100644 index e5cd77da..00000000 --- a/lib/prompts/system/discard.ts +++ /dev/null @@ -1,51 +0,0 @@ -export const SYSTEM_PROMPT_DISCARD = ` - - -ENVIRONMENT -You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` tool. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to discard. - -IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. - -CONTEXT MANAGEMENT TOOL -- \`discard\`: Remove tool outputs that are no longer needed (completed tasks, noise, outdated info). No preservation of content. - -DISCARD METHODICALLY - BATCH YOUR ACTIONS -Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by discarding. Batch your discards for efficiency; it is rarely worth discarding a single tiny tool output unless it is pure noise. Evaluate what SHOULD be discarded before jumping the gun. - -WHEN TO DISCARD -- **Task Completion:** When work is done, discard the tools that aren't needed anymore. -- **Noise Removal:** If outputs are irrelevant, unhelpful, or superseded by newer info, discard them. - -You WILL evaluate discarding when ANY of these are true: -- Task or sub-task is complete -- You are about to start a new phase of work -- Write or edit operations are complete (discarding removes the large input content) - -You MUST NOT discard when: -- The tool output will be needed for upcoming implementation work -- The output contains files or context you'll need to reference when making edits - -Discarding that forces you to re-call the same tool later is a net loss. Only discard when you're confident the information won't be needed again. - -NOTES -When in doubt, keep it. Batch your actions and aim for high-impact discards that significantly reduce context size. -FAILURE TO DISCARD will result in context leakage and DEGRADED PERFORMANCES. -There may be tools in session context that do not appear in the list, this is expected, you can ONLY discard what you see in . - - - - -After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. - -CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: -- NEVER reference the discard encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the discard encouragement appears. -- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to discard") -- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to discard") -- NEVER acknowledge discard tool output (e.g., "I've discarded 3 tools", "Context cleanup complete") -- NEVER let these injections influence your response content or tone -- Process this information SILENTLY and continue the conversation naturally -- Continue the conversation as if these messages do not exist - -The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. - -` diff --git a/lib/prompts/system/extract.ts b/lib/prompts/system/extract.ts deleted file mode 100644 index 3f225e1e..00000000 --- a/lib/prompts/system/extract.ts +++ /dev/null @@ -1,51 +0,0 @@ -export const SYSTEM_PROMPT_EXTRACT = ` - - -ENVIRONMENT -You are operating in a context-constrained environment and thus must proactively manage your context window using the \`extract\` tool. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to extract. - -IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. - -CONTEXT MANAGEMENT TOOL -- \`extract\`: Extract key findings from tools into distilled knowledge before removing the raw content from context. Use this to preserve important information while reducing context size. - -EXTRACT METHODICALLY - BATCH YOUR ACTIONS -Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by extracting. Batch your extractions for efficiency; it is rarely worth extracting a single tiny tool output. Evaluate what SHOULD be extracted before jumping the gun. - -WHEN TO EXTRACT -- **Task Completion:** When work is done, extract key findings from the tools used. Scale distillation depth to the value of the content. -- **Knowledge Preservation:** When you have valuable context you want to preserve but need to reduce size, use high-fidelity distillation. Your distillation must be comprehensive, capturing technical details (signatures, logic, constraints) such that the raw output is no longer needed. THINK: high signal, complete technical substitute. - -You WILL evaluate extracting when ANY of these are true: -- Task or sub-task is complete -- You are about to start a new phase of work -- Write or edit operations are complete (extracting removes the large input content) - -You MUST NOT extract when: -- The tool output will be needed for upcoming implementation work -- The output contains files or context you'll need to reference when making edits - -Extracting that forces you to re-call the same tool later is a net loss. Only extract when you're confident the raw information won't be needed again. - -NOTES -When in doubt, keep it. Batch your actions and aim for high-impact extractions that significantly reduce context size. -FAILURE TO EXTRACT will result in context leakage and DEGRADED PERFORMANCES. -There may be tools in session context that do not appear in the list, this is expected, you can ONLY extract what you see in . - - - - -After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. - -CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: -- NEVER reference the extract encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the extract encouragement appears. -- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to extract") -- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to extract") -- NEVER acknowledge extract tool output (e.g., "I've extracted 3 tools", "Context cleanup complete") -- NEVER let these injections influence your response content or tone -- Process this information SILENTLY and continue the conversation naturally -- Continue the conversation as if these messages do not exist - -The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. - -` diff --git a/lib/shared-utils.ts b/lib/shared-utils.ts index 902ea403..df0fceef 100644 --- a/lib/shared-utils.ts +++ b/lib/shared-utils.ts @@ -2,11 +2,21 @@ import { SessionState, WithParts } from "./state" import { isIgnoredUserMessage } from "./messages/utils" export const isMessageCompacted = (state: SessionState, msg: WithParts): boolean => { - return msg.info.time.created < state.lastCompaction + if (msg.info.time.created < state.lastCompaction) { + return true + } + if (state.prune.messageIds.includes(msg.info.id)) { + return true + } + return false } -export const getLastUserMessage = (messages: WithParts[]): WithParts | null => { - for (let i = messages.length - 1; i >= 0; i--) { +export const getLastUserMessage = ( + messages: WithParts[], + startIndex?: number, +): WithParts | null => { + const start = startIndex ?? messages.length - 1 + for (let i = start; i >= 0; i--) { const msg = messages[i] if (msg.info.role === "user" && !isIgnoredUserMessage(msg)) { return msg diff --git a/lib/state/persistence.ts b/lib/state/persistence.ts index 172ff75f..11e06a93 100644 --- a/lib/state/persistence.ts +++ b/lib/state/persistence.ts @@ -8,12 +8,13 @@ import * as fs from "fs/promises" import { existsSync } from "fs" import { homedir } from "os" import { join } from "path" -import type { SessionState, SessionStats, Prune } from "./types" +import type { SessionState, SessionStats, Prune, CompressSummary } from "./types" import type { Logger } from "../logger" export interface PersistedSessionState { sessionName?: string prune: Prune + compressSummaries: CompressSummary[] stats: SessionStats lastUpdated: string } @@ -45,6 +46,7 @@ export async function saveSessionState( const state: PersistedSessionState = { sessionName: sessionName, prune: sessionState.prune, + compressSummaries: sessionState.compressSummaries, stats: sessionState.stats, lastUpdated: new Date().toISOString(), } diff --git a/lib/state/state.ts b/lib/state/state.ts index 69add020..c8e3866d 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -1,8 +1,13 @@ import type { SessionState, ToolParameterEntry, WithParts } from "./types" import type { Logger } from "../logger" import { loadSessionState } from "./persistence" -import { isSubAgentSession } from "./utils" -import { getLastUserMessage, isMessageCompacted } from "../shared-utils" +import { + isSubAgentSession, + findLastCompactionTimestamp, + countTurns, + resetOnCompaction, +} from "./utils" +import { getLastUserMessage } from "../shared-utils" export const checkSession = async ( client: any, @@ -29,9 +34,8 @@ export const checkSession = async ( const lastCompactionTimestamp = findLastCompactionTimestamp(messages) if (lastCompactionTimestamp > state.lastCompaction) { state.lastCompaction = lastCompactionTimestamp - state.toolParameters.clear() - state.prune.toolIds = [] - logger.info("Detected compaction from messages - cleared tool cache", { + resetOnCompaction(state) + logger.info("Detected compaction - reset stale state", { timestamp: lastCompactionTimestamp, }) } @@ -45,7 +49,9 @@ export function createSessionState(): SessionState { isSubAgent: false, prune: { toolIds: [], + messageIds: [], }, + compressSummaries: [], stats: { pruneTokenCounter: 0, totalPruneTokens: 0, @@ -64,7 +70,9 @@ export function resetSessionState(state: SessionState): void { state.isSubAgent = false state.prune = { toolIds: [], + messageIds: [], } + state.compressSummaries = [] state.stats = { pruneTokenCounter: 0, totalPruneTokens: 0, @@ -108,35 +116,11 @@ export async function ensureSessionInitialized( state.prune = { toolIds: persisted.prune.toolIds || [], + messageIds: persisted.prune.messageIds || [], } + state.compressSummaries = persisted.compressSummaries || [] state.stats = { pruneTokenCounter: persisted.stats?.pruneTokenCounter || 0, totalPruneTokens: persisted.stats?.totalPruneTokens || 0, } } - -function findLastCompactionTimestamp(messages: WithParts[]): number { - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i] - if (msg.info.role === "assistant" && msg.info.summary === true) { - return msg.info.time.created - } - } - return 0 -} - -export function countTurns(state: SessionState, messages: WithParts[]): number { - let turnCount = 0 - for (const msg of messages) { - if (isMessageCompacted(state, msg)) { - continue - } - const parts = Array.isArray(msg.parts) ? msg.parts : [] - for (const part of parts) { - if (part.type === "step-start") { - turnCount++ - } - } - } - return turnCount -} diff --git a/lib/state/tool-cache.ts b/lib/state/tool-cache.ts index 38d3b54b..b5ad154e 100644 --- a/lib/state/tool-cache.ts +++ b/lib/state/tool-cache.ts @@ -44,12 +44,11 @@ export async function syncToolCache( state.currentTurn - turnCounter < turnProtectionTurns state.lastToolPrune = - (part.tool === "discard" || part.tool === "extract") && - part.state.status === "completed" + part.tool === "distill" || part.tool === "compress" || part.tool === "prune" const allProtectedTools = config.tools.settings.protectedTools - if (part.tool === "discard" || part.tool === "extract") { + if (part.tool === "distill" || part.tool === "compress" || part.tool === "prune") { state.nudgeCounter = 0 } else if (!allProtectedTools.includes(part.tool) && !isProtectedByTurn) { state.nudgeCounter++ diff --git a/lib/state/types.ts b/lib/state/types.ts index 1e41170d..d84f0ee5 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -20,14 +20,21 @@ export interface SessionStats { totalPruneTokens: number } +export interface CompressSummary { + anchorMessageId: string + summary: string +} + export interface Prune { toolIds: string[] + messageIds: string[] } export interface SessionState { sessionId: string | null isSubAgent: boolean prune: Prune + compressSummaries: CompressSummary[] stats: SessionStats toolParameters: Map nudgeCounter: number diff --git a/lib/state/utils.ts b/lib/state/utils.ts index 4cc10ce1..da96afb1 100644 --- a/lib/state/utils.ts +++ b/lib/state/utils.ts @@ -1,3 +1,6 @@ +import type { SessionState, WithParts } from "./types" +import { isMessageCompacted } from "../shared-utils" + export async function isSubAgentSession(client: any, sessionID: string): Promise { try { const result = await client.session.get({ path: { id: sessionID } }) @@ -6,3 +9,38 @@ export async function isSubAgentSession(client: any, sessionID: string): Promise return false } } + +export function findLastCompactionTimestamp(messages: WithParts[]): number { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.info.role === "assistant" && msg.info.summary === true) { + return msg.info.time.created + } + } + return 0 +} + +export function countTurns(state: SessionState, messages: WithParts[]): number { + let turnCount = 0 + for (const msg of messages) { + if (isMessageCompacted(state, msg)) { + continue + } + const parts = Array.isArray(msg.parts) ? msg.parts : [] + for (const part of parts) { + if (part.type === "step-start") { + turnCount++ + } + } + } + return turnCount +} + +export function resetOnCompaction(state: SessionState): void { + state.toolParameters.clear() + state.prune.toolIds = [] + state.prune.messageIds = [] + state.compressSummaries = [] + state.nudgeCounter = 0 + state.lastToolPrune = false +} diff --git a/lib/strategies/index.ts b/lib/strategies/index.ts index 5444964c..e0680e6b 100644 --- a/lib/strategies/index.ts +++ b/lib/strategies/index.ts @@ -1,4 +1,4 @@ export { deduplicate } from "./deduplication" -export { createDiscardTool, createExtractTool } from "./tools" +export { createPruneTool, createDistillTool, createCompressTool } from "../tools" export { supersedeWrites } from "./supersede-writes" export { purgeErrors } from "./purge-errors" diff --git a/lib/strategies/tools.ts b/lib/strategies/tools.ts deleted file mode 100644 index 44f6742f..00000000 --- a/lib/strategies/tools.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { tool } from "@opencode-ai/plugin" -import type { SessionState, ToolParameterEntry, WithParts } from "../state" -import type { PluginConfig } from "../config" -import { buildToolIdList } from "../messages/utils" -import { PruneReason, sendUnifiedNotification } from "../ui/notification" -import { formatPruningResultForTool } from "../ui/utils" -import { ensureSessionInitialized } from "../state" -import { saveSessionState } from "../state/persistence" -import type { Logger } from "../logger" -import { loadPrompt } from "../prompts" -import { calculateTokensSaved, getCurrentParams } from "./utils" -import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" - -const DISCARD_TOOL_DESCRIPTION = loadPrompt("discard-tool-spec") -const EXTRACT_TOOL_DESCRIPTION = loadPrompt("extract-tool-spec") - -export interface PruneToolContext { - client: any - state: SessionState - logger: Logger - config: PluginConfig - workingDirectory: string -} - -// Shared logic for executing prune operations. -async function executePruneOperation( - ctx: PruneToolContext, - toolCtx: { sessionID: string }, - ids: string[], - reason: PruneReason, - toolName: string, - distillation?: string[], -): Promise { - const { client, state, logger, config, workingDirectory } = ctx - const sessionId = toolCtx.sessionID - - logger.info(`${toolName} tool invoked`) - logger.info(JSON.stringify(reason ? { ids, reason } : { ids })) - - if (!ids || ids.length === 0) { - logger.debug(`${toolName} tool called but ids is empty or undefined`) - throw new Error( - `No IDs provided. Check the list for available IDs to ${toolName.toLowerCase()}.`, - ) - } - - const numericToolIds: number[] = ids - .map((id) => parseInt(id, 10)) - .filter((n): n is number => !isNaN(n)) - - if (numericToolIds.length === 0) { - logger.debug(`No numeric tool IDs provided for ${toolName}: ` + JSON.stringify(ids)) - throw new Error("No numeric IDs provided. Format: ids: [id1, id2, ...]") - } - - // Fetch messages to calculate tokens and find current agent - const messagesResponse = await client.session.messages({ - path: { id: sessionId }, - }) - const messages: WithParts[] = messagesResponse.data || messagesResponse - - await ensureSessionInitialized(ctx.client, state, sessionId, logger, messages) - - const currentParams = getCurrentParams(state, messages, logger) - const toolIdList: string[] = buildToolIdList(state, messages, logger) - - // Validate that all numeric IDs are within bounds - if (numericToolIds.some((id) => id < 0 || id >= toolIdList.length)) { - logger.debug("Invalid tool IDs provided: " + numericToolIds.join(", ")) - throw new Error( - "Invalid IDs provided. Only use numeric IDs from the list.", - ) - } - - // Validate that all IDs exist in cache and aren't protected - // (rejects hallucinated IDs and turn-protected tools not shown in ) - for (const index of numericToolIds) { - const id = toolIdList[index] - const metadata = state.toolParameters.get(id) - if (!metadata) { - logger.debug( - "Rejecting prune request - ID not in cache (turn-protected or hallucinated)", - { index, id }, - ) - throw new Error( - "Invalid IDs provided. Only use numeric IDs from the list.", - ) - } - const allProtectedTools = config.tools.settings.protectedTools - if (allProtectedTools.includes(metadata.tool)) { - logger.debug("Rejecting prune request - protected tool", { - index, - id, - tool: metadata.tool, - }) - throw new Error( - "Invalid IDs provided. Only use numeric IDs from the list.", - ) - } - - const filePath = getFilePathFromParameters(metadata.parameters) - if (isProtectedFilePath(filePath, config.protectedFilePatterns)) { - logger.debug("Rejecting prune request - protected file path", { - index, - id, - tool: metadata.tool, - filePath, - }) - throw new Error( - "Invalid IDs provided. Only use numeric IDs from the list.", - ) - } - } - - const pruneToolIds: string[] = numericToolIds.map((index) => toolIdList[index]) - state.prune.toolIds.push(...pruneToolIds) - - const toolMetadata = new Map() - for (const id of pruneToolIds) { - const toolParameters = state.toolParameters.get(id) - if (toolParameters) { - toolMetadata.set(id, toolParameters) - } else { - logger.debug("No metadata found for ID", { id }) - } - } - - state.stats.pruneTokenCounter += calculateTokensSaved(state, messages, pruneToolIds) - - await sendUnifiedNotification( - client, - logger, - config, - state, - sessionId, - pruneToolIds, - toolMetadata, - reason, - currentParams, - workingDirectory, - distillation, - ) - - state.stats.totalPruneTokens += state.stats.pruneTokenCounter - state.stats.pruneTokenCounter = 0 - state.nudgeCounter = 0 - - saveSessionState(state, logger).catch((err) => - logger.error("Failed to persist state", { error: err.message }), - ) - - return formatPruningResultForTool(pruneToolIds, toolMetadata, workingDirectory) -} - -export function createDiscardTool(ctx: PruneToolContext): ReturnType { - return tool({ - description: DISCARD_TOOL_DESCRIPTION, - args: { - ids: tool.schema - .array(tool.schema.string()) - .describe( - "First element is the reason ('completion' or 'noise'), followed by numeric IDs as strings to discard", - ), - }, - async execute(args, toolCtx) { - // Parse reason from first element, numeric IDs from the rest - const reason = args.ids?.[0] - const validReasons = ["completion", "noise"] as const - if (typeof reason !== "string" || !validReasons.includes(reason as any)) { - ctx.logger.debug("Invalid discard reason provided: " + reason) - throw new Error( - "No valid reason found. Use 'completion' or 'noise' as the first element.", - ) - } - - const numericIds = args.ids.slice(1) - - return executePruneOperation(ctx, toolCtx, numericIds, reason as PruneReason, "Discard") - }, - }) -} - -export function createExtractTool(ctx: PruneToolContext): ReturnType { - return tool({ - description: EXTRACT_TOOL_DESCRIPTION, - args: { - ids: tool.schema - .array(tool.schema.string()) - .describe("Numeric IDs as strings to extract from the list"), - distillation: tool.schema - .array(tool.schema.string()) - .describe( - "REQUIRED. Array of strings, one per ID (positional: distillation[0] is for ids[0], etc.)", - ), - }, - async execute(args, toolCtx) { - if (!args.distillation || args.distillation.length === 0) { - ctx.logger.debug( - "Extract tool called without distillation: " + JSON.stringify(args), - ) - throw new Error( - "Missing distillation. You must provide a distillation string for each ID.", - ) - } - - // Log the distillation for debugging/analysis - ctx.logger.info("Distillation data received:") - ctx.logger.info(JSON.stringify(args.distillation, null, 2)) - - return executePruneOperation( - ctx, - toolCtx, - args.ids, - "extraction" as PruneReason, - "Extract", - args.distillation, - ) - }, - }) -} diff --git a/lib/strategies/utils.ts b/lib/strategies/utils.ts index 7ae04154..c32ba727 100644 --- a/lib/strategies/utils.ts +++ b/lib/strategies/utils.ts @@ -42,8 +42,9 @@ export function countTokens(text: string): number { } } -function estimateTokensBatch(texts: string[]): number[] { - return texts.map(countTokens) +export function estimateTokensBatch(texts: string[]): number { + if (texts.length === 0) return 0 + return countTokens(texts.join(" ")) } export const calculateTokensSaved = ( @@ -86,8 +87,7 @@ export const calculateTokensSaved = ( } } } - const tokenCounts: number[] = estimateTokensBatch(contents) - return tokenCounts.reduce((sum, count) => sum + count, 0) + return estimateTokensBatch(contents) } catch (error: any) { return 0 } diff --git a/lib/tools/compress.ts b/lib/tools/compress.ts new file mode 100644 index 00000000..f5c30334 --- /dev/null +++ b/lib/tools/compress.ts @@ -0,0 +1,155 @@ +import { tool } from "@opencode-ai/plugin" +import type { WithParts, CompressSummary } from "../state" +import type { PruneToolContext } from "./types" +import { ensureSessionInitialized } from "../state" +import { saveSessionState } from "../state/persistence" +import { loadPrompt } from "../prompts" +import { estimateTokensBatch, getCurrentParams } from "../strategies/utils" +import { + collectContentInRange, + findStringInMessages, + collectToolIdsInRange, + collectMessageIdsInRange, +} from "./utils" +import { sendCompressNotification } from "../ui/notification" + +const COMPRESS_TOOL_DESCRIPTION = loadPrompt("compress-tool-spec") + +export function createCompressTool(ctx: PruneToolContext): ReturnType { + return tool({ + description: COMPRESS_TOOL_DESCRIPTION, + args: { + input: tool.schema + .array(tool.schema.string()) + .length(4) + .describe( + "[startString, endString, topic, summary] - 4 required strings: (1) startString: unique text from conversation marking range start, (2) endString: unique text marking range end, (3) topic: short 3-5 word label for UI, (4) summary: comprehensive text replacing all compressed content", + ), + }, + async execute(args, toolCtx) { + const { client, state, logger } = ctx + const sessionId = toolCtx.sessionID + + const [startString, endString, topic, summary] = args.input + + logger.info("Compress tool invoked") + // logger.info( + // JSON.stringify({ + // startString: startString?.substring(0, 50) + "...", + // endString: endString?.substring(0, 50) + "...", + // topic: topic, + // summaryLength: summary?.length, + // }), + // ) + + const messagesResponse = await client.session.messages({ + path: { id: sessionId }, + }) + const messages: WithParts[] = messagesResponse.data || messagesResponse + + await ensureSessionInitialized(client, state, sessionId, logger, messages) + + const startResult = findStringInMessages( + messages, + startString, + logger, + state.compressSummaries, + "startString", + ) + const endResult = findStringInMessages( + messages, + endString, + logger, + state.compressSummaries, + "endString", + ) + + if (startResult.messageIndex > endResult.messageIndex) { + throw new Error( + `startString appears after endString in the conversation. Start must come before end.`, + ) + } + + const containedToolIds = collectToolIdsInRange( + messages, + startResult.messageIndex, + endResult.messageIndex, + ) + + const containedMessageIds = collectMessageIdsInRange( + messages, + startResult.messageIndex, + endResult.messageIndex, + ) + + state.prune.toolIds.push(...containedToolIds) + state.prune.messageIds.push(...containedMessageIds) + + // Remove any existing summaries whose anchors are now inside this range + // This prevents duplicate injections when a larger compress subsumes a smaller one + const removedSummaries = state.compressSummaries.filter((s) => + containedMessageIds.includes(s.anchorMessageId), + ) + if (removedSummaries.length > 0) { + // logger.info("Removing subsumed compress summaries", { + // count: removedSummaries.length, + // anchorIds: removedSummaries.map((s) => s.anchorMessageId), + // }) + state.compressSummaries = state.compressSummaries.filter( + (s) => !containedMessageIds.includes(s.anchorMessageId), + ) + } + + const compressSummary: CompressSummary = { + anchorMessageId: startResult.messageId, + summary: summary, + } + state.compressSummaries.push(compressSummary) + + const contentsToTokenize = collectContentInRange( + messages, + startResult.messageIndex, + endResult.messageIndex, + ) + const estimatedCompressedTokens = estimateTokensBatch(contentsToTokenize) + + state.stats.pruneTokenCounter += estimatedCompressedTokens + + const currentParams = getCurrentParams(state, messages, logger) + await sendCompressNotification( + client, + logger, + ctx.config, + state, + sessionId, + containedToolIds, + containedMessageIds, + topic, + summary, + startResult, + endResult, + messages.length, + currentParams, + ) + + state.stats.totalPruneTokens += state.stats.pruneTokenCounter + state.stats.pruneTokenCounter = 0 + state.nudgeCounter = 0 + + // logger.info("Compress range created", { + // startMessageId: startResult.messageId, + // endMessageId: endResult.messageId, + // toolIdsRemoved: containedToolIds.length, + // messagesInRange: containedMessageIds.length, + // estimatedTokens: estimatedCompressedTokens, + // }) + + saveSessionState(state, logger).catch((err) => + logger.error("Failed to persist state", { error: err.message }), + ) + + const messagesCompressed = endResult.messageIndex - startResult.messageIndex + 1 + return `Compressed ${messagesCompressed} messages (${containedToolIds.length} tool calls) into summary. The content will be replaced with your summary.` + }, + }) +} diff --git a/lib/tools/distill.ts b/lib/tools/distill.ts new file mode 100644 index 00000000..ce224e9d --- /dev/null +++ b/lib/tools/distill.ts @@ -0,0 +1,58 @@ +import { tool } from "@opencode-ai/plugin" +import type { PruneToolContext } from "./types" +import { executePruneOperation } from "./prune-shared" +import { PruneReason } from "../ui/notification" +import { loadPrompt } from "../prompts" + +const DISTILL_TOOL_DESCRIPTION = loadPrompt("distill-tool-spec") + +export function createDistillTool(ctx: PruneToolContext): ReturnType { + return tool({ + description: DISTILL_TOOL_DESCRIPTION, + args: { + ids: tool.schema + .array(tool.schema.string()) + .min(1) + .describe("Numeric IDs as strings to distill from the list"), + distillation: tool.schema + .array(tool.schema.string()) + .min(1) + .describe( + "Required array of distillation strings, one per ID (positional: distillation[0] for ids[0], etc.)", + ), + }, + async execute(args, toolCtx) { + if (!args.distillation || args.distillation.length === 0) { + ctx.logger.debug( + "Distill tool called without distillation: " + JSON.stringify(args), + ) + throw new Error( + "Missing distillation. You must provide a distillation string for each ID.", + ) + } + + if (!Array.isArray(args.distillation)) { + ctx.logger.debug( + "Distill tool called with non-array distillation: " + JSON.stringify(args), + ) + throw new Error( + `Invalid distillation format: expected an array of strings, got ${typeof args.distillation}. ` + + `Example: distillation: ["summary for id 0", "summary for id 1"]`, + ) + } + + // Log the distillation for debugging/analysis + ctx.logger.info("Distillation data received:") + ctx.logger.info(JSON.stringify(args.distillation, null, 2)) + + return executePruneOperation( + ctx, + toolCtx, + args.ids, + "extraction" as PruneReason, + "Distill", + args.distillation, + ) + }, + }) +} diff --git a/lib/tools/index.ts b/lib/tools/index.ts new file mode 100644 index 00000000..32a5e9c8 --- /dev/null +++ b/lib/tools/index.ts @@ -0,0 +1,4 @@ +export { PruneToolContext } from "./types" +export { createPruneTool } from "./prune" +export { createDistillTool } from "./distill" +export { createCompressTool } from "./compress" diff --git a/lib/tools/prune-shared.ts b/lib/tools/prune-shared.ts new file mode 100644 index 00000000..ba37fb78 --- /dev/null +++ b/lib/tools/prune-shared.ts @@ -0,0 +1,159 @@ +import type { SessionState, ToolParameterEntry, WithParts } from "../state" +import type { PluginConfig } from "../config" +import type { Logger } from "../logger" +import type { PruneToolContext } from "./types" +import { buildToolIdList } from "../messages/utils" +import { syncToolCache } from "../state/tool-cache" +import { PruneReason, sendUnifiedNotification } from "../ui/notification" +import { formatPruningResultForTool } from "../ui/utils" +import { ensureSessionInitialized } from "../state" +import { saveSessionState } from "../state/persistence" +import { calculateTokensSaved, getCurrentParams } from "../strategies/utils" +import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" + +// Shared logic for executing prune operations. +export async function executePruneOperation( + ctx: PruneToolContext, + toolCtx: { sessionID: string }, + ids: string[], + reason: PruneReason, + toolName: string, + distillation?: string[], +): Promise { + const { client, state, logger, config, workingDirectory } = ctx + const sessionId = toolCtx.sessionID + + logger.info(`${toolName} tool invoked`) + logger.info(JSON.stringify(reason ? { ids, reason } : { ids })) + + if (!ids || ids.length === 0) { + logger.debug(`${toolName} tool called but ids is empty or undefined`) + throw new Error( + `No IDs provided. Check the list for available IDs to ${toolName.toLowerCase()}.`, + ) + } + + const numericToolIds: number[] = ids + .map((id) => parseInt(id, 10)) + .filter((n): n is number => !isNaN(n)) + + if (numericToolIds.length === 0) { + logger.debug(`No numeric tool IDs provided for ${toolName}: ` + JSON.stringify(ids)) + throw new Error("No numeric IDs provided. Format: ids: [id1, id2, ...]") + } + + // Fetch messages to calculate tokens and find current agent + const messagesResponse = await client.session.messages({ + path: { id: sessionId }, + }) + const messages: WithParts[] = messagesResponse.data || messagesResponse + + await ensureSessionInitialized(ctx.client, state, sessionId, logger, messages) + await syncToolCache(state, config, logger, messages) + + const currentParams = getCurrentParams(state, messages, logger) + const toolIdList: string[] = buildToolIdList(state, messages, logger) + + const validNumericIds: number[] = [] + const skippedIds: string[] = [] + + // Validate and filter IDs + for (const index of numericToolIds) { + // Validate that all numeric IDs are within bounds + if (index < 0 || index >= toolIdList.length) { + logger.debug(`Rejecting prune request - index out of bounds: ${index}`) + skippedIds.push(index.toString()) + continue + } + + const id = toolIdList[index] + const metadata = state.toolParameters.get(id) + + // Validate that all IDs exist in cache and aren't protected + // (rejects hallucinated IDs and turn-protected tools not shown in ) + if (!metadata) { + logger.debug( + "Rejecting prune request - ID not in cache (turn-protected or hallucinated)", + { index, id }, + ) + skippedIds.push(index.toString()) + continue + } + + const allProtectedTools = config.tools.settings.protectedTools + if (allProtectedTools.includes(metadata.tool)) { + logger.debug("Rejecting prune request - protected tool", { + index, + id, + tool: metadata.tool, + }) + skippedIds.push(index.toString()) + continue + } + + const filePath = getFilePathFromParameters(metadata.parameters) + if (isProtectedFilePath(filePath, config.protectedFilePatterns)) { + logger.debug("Rejecting prune request - protected file path", { + index, + id, + tool: metadata.tool, + filePath, + }) + skippedIds.push(index.toString()) + continue + } + + validNumericIds.push(index) + } + + if (validNumericIds.length === 0) { + const errorMsg = + skippedIds.length > 0 + ? `Invalid IDs provided: [${skippedIds.join(", ")}]. Only use numeric IDs from the list.` + : `No valid IDs provided to ${toolName.toLowerCase()}.` + throw new Error(errorMsg) + } + + const pruneToolIds: string[] = validNumericIds.map((index) => toolIdList[index]) + state.prune.toolIds.push(...pruneToolIds) + + const toolMetadata = new Map() + for (const id of pruneToolIds) { + const toolParameters = state.toolParameters.get(id) + if (toolParameters) { + toolMetadata.set(id, toolParameters) + } else { + logger.debug("No metadata found for ID", { id }) + } + } + + state.stats.pruneTokenCounter += calculateTokensSaved(state, messages, pruneToolIds) + + await sendUnifiedNotification( + client, + logger, + config, + state, + sessionId, + pruneToolIds, + toolMetadata, + reason, + currentParams, + workingDirectory, + distillation, + ) + + state.stats.totalPruneTokens += state.stats.pruneTokenCounter + state.stats.pruneTokenCounter = 0 + state.nudgeCounter = 0 + + saveSessionState(state, logger).catch((err) => + logger.error("Failed to persist state", { error: err.message }), + ) + + let result = formatPruningResultForTool(pruneToolIds, toolMetadata, workingDirectory) + if (skippedIds.length > 0) { + result += `\n\nNote: ${skippedIds.length} IDs were skipped (invalid, protected, or missing metadata): ${skippedIds.join(", ")}` + } + return result +} diff --git a/lib/tools/prune.ts b/lib/tools/prune.ts new file mode 100644 index 00000000..29448c01 --- /dev/null +++ b/lib/tools/prune.ts @@ -0,0 +1,25 @@ +import { tool } from "@opencode-ai/plugin" +import type { PruneToolContext } from "./types" +import { executePruneOperation } from "./prune-shared" +import { PruneReason } from "../ui/notification" +import { loadPrompt } from "../prompts" + +const PRUNE_TOOL_DESCRIPTION = loadPrompt("prune-tool-spec") + +export function createPruneTool(ctx: PruneToolContext): ReturnType { + return tool({ + description: PRUNE_TOOL_DESCRIPTION, + args: { + ids: tool.schema + .array(tool.schema.string()) + .min(1) + .describe("Numeric IDs as strings from the list to prune"), + }, + async execute(args, toolCtx) { + const numericIds = args.ids + const reason = "noise" + + return executePruneOperation(ctx, toolCtx, numericIds, reason, "Prune") + }, + }) +} diff --git a/lib/tools/types.ts b/lib/tools/types.ts new file mode 100644 index 00000000..c4950e47 --- /dev/null +++ b/lib/tools/types.ts @@ -0,0 +1,11 @@ +import type { SessionState } from "../state" +import type { PluginConfig } from "../config" +import type { Logger } from "../logger" + +export interface PruneToolContext { + client: any + state: SessionState + logger: Logger + config: PluginConfig + workingDirectory: string +} diff --git a/lib/tools/utils.ts b/lib/tools/utils.ts new file mode 100644 index 00000000..8adec13e --- /dev/null +++ b/lib/tools/utils.ts @@ -0,0 +1,165 @@ +import type { WithParts, CompressSummary } from "../state" +import type { Logger } from "../logger" + +/** + * Searches messages for a string and returns the message ID where it's found. + * Searches in text parts, tool outputs, tool inputs, and other textual content. + * Also searches through existing compress summaries to enable chained compression. + * Throws an error if the string is not found or found more than once. + */ +export function findStringInMessages( + messages: WithParts[], + searchString: string, + logger: Logger, + compressSummaries: CompressSummary[] = [], + stringType: "startString" | "endString", +): { messageId: string; messageIndex: number } { + const matches: { messageId: string; messageIndex: number }[] = [] + + // First, search through existing compress summaries + // This allows referencing text from previous compress operations + for (const summary of compressSummaries) { + if (summary.summary.includes(searchString)) { + const anchorIndex = messages.findIndex((m) => m.info.id === summary.anchorMessageId) + if (anchorIndex !== -1) { + matches.push({ + messageId: summary.anchorMessageId, + messageIndex: anchorIndex, + }) + } + } + } + + // Then search through raw messages + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + const parts = Array.isArray(msg.parts) ? msg.parts : [] + + for (const part of parts) { + let content = "" + + if (part.type === "text" && typeof part.text === "string") { + content = part.text + } else if (part.type === "tool" && part.state?.status === "completed") { + if (typeof part.state.output === "string") { + content = part.state.output + } + if (part.state.input) { + const inputStr = + typeof part.state.input === "string" + ? part.state.input + : JSON.stringify(part.state.input) + content += " " + inputStr + } + } + + if (content.includes(searchString)) { + matches.push({ messageId: msg.info.id, messageIndex: i }) + } + } + } + + if (matches.length === 0) { + throw new Error( + `${stringType} not found in conversation. Make sure the string exists and is spelled exactly as it appears.`, + ) + } + + if (matches.length > 1) { + throw new Error( + `Found multiple matches for ${stringType}. Provide more surrounding context to uniquely identify the intended match.`, + ) + } + + return matches[0] +} + +/** + * Collects all tool callIDs from messages between start and end indices (inclusive). + */ +export function collectToolIdsInRange( + messages: WithParts[], + startIndex: number, + endIndex: number, +): string[] { + const toolIds: string[] = [] + + for (let i = startIndex; i <= endIndex; i++) { + const msg = messages[i] + const parts = Array.isArray(msg.parts) ? msg.parts : [] + + for (const part of parts) { + if (part.type === "tool" && part.callID) { + if (!toolIds.includes(part.callID)) { + toolIds.push(part.callID) + } + } + } + } + + return toolIds +} + +/** + * Collects all message IDs from messages between start and end indices (inclusive). + */ +export function collectMessageIdsInRange( + messages: WithParts[], + startIndex: number, + endIndex: number, +): string[] { + const messageIds: string[] = [] + + for (let i = startIndex; i <= endIndex; i++) { + const msgId = messages[i].info.id + if (!messageIds.includes(msgId)) { + messageIds.push(msgId) + } + } + + return messageIds +} + +/** + * Collects all textual content (text parts, tool inputs, and tool outputs) + * from a range of messages. Used for token estimation. + */ +export function collectContentInRange( + messages: WithParts[], + startIndex: number, + endIndex: number, +): string[] { + const contents: string[] = [] + for (let i = startIndex; i <= endIndex; i++) { + const msg = messages[i] + const parts = Array.isArray(msg.parts) ? msg.parts : [] + for (const part of parts) { + if (part.type === "text") { + contents.push(part.text) + } else if (part.type === "tool") { + const toolState = part.state as any + if (toolState?.input) { + contents.push( + typeof toolState.input === "string" + ? toolState.input + : JSON.stringify(toolState.input), + ) + } + if (toolState?.status === "completed" && toolState?.output) { + contents.push( + typeof toolState.output === "string" + ? toolState.output + : JSON.stringify(toolState.output), + ) + } else if (toolState?.status === "error" && toolState?.error) { + contents.push( + typeof toolState.error === "string" + ? toolState.error + : JSON.stringify(toolState.error), + ) + } + } + } + } + return contents +} diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index acb948cd..ec6d399b 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -6,6 +6,7 @@ import { formatPrunedItemsList, formatStatsHeader, formatTokenCount, + formatProgressBar, } from "./utils" import { ToolParameterEntry } from "../state" import { PluginConfig } from "../config" @@ -25,7 +26,7 @@ function buildMinimalMessage( ): string { const extractedTokens = countDistillationTokens(distillation) const extractedSuffix = - extractedTokens > 0 ? ` (extracted ${formatTokenCount(extractedTokens)})` : "" + extractedTokens > 0 ? ` (distilled ${formatTokenCount(extractedTokens)})` : "" const reasonSuffix = reason && extractedTokens === 0 ? ` — ${PRUNE_REASON_LABELS[reason]}` : "" let message = formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter) + @@ -50,7 +51,7 @@ function buildDetailedMessage( const pruneTokenCounterStr = `~${formatTokenCount(state.stats.pruneTokenCounter)}` const extractedTokens = countDistillationTokens(distillation) const extractedSuffix = - extractedTokens > 0 ? `, extracted ${formatTokenCount(extractedTokens)}` : "" + extractedTokens > 0 ? `, distilled ${formatTokenCount(extractedTokens)}` : "" const reasonLabel = reason && extractedTokens === 0 ? ` — ${PRUNE_REASON_LABELS[reason]}` : "" message += `\n\n▣ Pruning (${pruneTokenCounterStr}${extractedSuffix})${reasonLabel}` @@ -84,7 +85,7 @@ export async function sendUnifiedNotification( return false } - const showDistillation = config.tools.extract.showDistillation + const showDistillation = config.tools.distill.showDistillation const message = config.pruneNotification === "minimal" @@ -103,6 +104,56 @@ export async function sendUnifiedNotification( return true } +export async function sendCompressNotification( + client: any, + logger: Logger, + config: PluginConfig, + state: SessionState, + sessionId: string, + toolIds: string[], + messageIds: string[], + topic: string, + summary: string, + startResult: any, + endResult: any, + totalMessages: number, + params: any, +): Promise { + if (config.pruneNotification === "off") { + return false + } + + let message: string + + if (config.pruneNotification === "minimal") { + message = formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter) + } else { + message = formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter) + + const pruneTokenCounterStr = `~${formatTokenCount(state.stats.pruneTokenCounter)}` + const progressBar = formatProgressBar( + totalMessages, + startResult.messageIndex, + endResult.messageIndex, + 25, + ) + message += `\n\n▣ Compressing (${pruneTokenCounterStr}) ${progressBar}` + message += `\n→ Topic: ${topic}` + message += `\n→ Items: ${messageIds.length} messages` + if (toolIds.length > 0) { + message += ` and ${toolIds.length} tools condensed` + } else { + message += ` condensed` + } + if (config.tools.compress.showCompression) { + message += `\n→ Compression: ${summary}` + } + } + + await sendIgnoredMessage(client, sessionId, message, params, logger) + return true +} + export async function sendIgnoredMessage( client: any, sessionID: string, diff --git a/lib/ui/utils.ts b/lib/ui/utils.ts index 9134a5cf..2f6fc754 100644 --- a/lib/ui/utils.ts +++ b/lib/ui/utils.ts @@ -35,6 +35,29 @@ export function truncate(str: string, maxLen: number = 60): string { return str.slice(0, maxLen - 3) + "..." } +export function formatProgressBar( + total: number, + start: number, + end: number, + width: number = 20, +): string { + if (total <= 0) return `│${" ".repeat(width)}│` + + const startIdx = Math.floor((start / total) * width) + const endIdx = Math.min(width - 1, Math.floor((end / total) * width)) + + let bar = "" + for (let i = 0; i < width; i++) { + if (i >= startIdx && i <= endIdx) { + bar += "░" + } else { + bar += "█" + } + } + + return `│${bar}│` +} + export function shortenPath(input: string, workingDirectory?: string): string { const inPathMatch = input.match(/^(.+) in (.+)$/) if (inPathMatch) { diff --git a/package-lock.json b/package-lock.json index 43517ddd..2efc0095 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "@tarquinen/opencode-dcp", - "version": "1.2.8", + "version": "1.3.1-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "1.2.8", + "version": "1.3.1-beta.2", "license": "MIT", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", "@opencode-ai/sdk": "^1.1.3", "jsonc-parser": "^3.3.1", + "ulid": "^3.0.2", "zod": "^4.1.13" }, "devDependencies": { @@ -676,6 +677,15 @@ "node": ">=14.17" } }, + "node_modules/ulid": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/ulid/-/ulid-3.0.2.tgz", + "integrity": "sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==", + "license": "MIT", + "bin": { + "ulid": "dist/cli.js" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", diff --git a/package.json b/package.json index d35967c2..b03bd432 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,23 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@tarquinen/opencode-dcp", - "version": "1.2.8", + "version": "1.3.1-beta.2", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { "clean": "rm -rf dist", + "generate:prompts": "tsx scripts/generate-prompts.ts", + "prebuild": "npm run generate:prompts", "build": "npm run clean && tsc", - "postbuild": "rm -rf dist/logs", "prepublishOnly": "npm run build", "dev": "opencode plugin dev", - "typecheck": "tsc --noEmit", + "typecheck": "npm run generate:prompts && tsc --noEmit", "test": "node --import tsx --test tests/*.test.ts", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "dcp": "tsx scripts/print.ts" }, "keywords": [ "opencode", @@ -43,6 +45,7 @@ "@anthropic-ai/tokenizer": "^0.0.4", "@opencode-ai/sdk": "^1.1.3", "jsonc-parser": "^3.3.1", + "ulid": "^3.0.2", "zod": "^4.1.13" }, "devDependencies": { diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 00000000..a99c256b --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,41 @@ +# DCP CLI + +Dev tool for previewing prompt outputs. Verify parsing works correctly and quickly check specific tool combinations. + +## Usage + +```bash +bun run dcp [TYPE] [-p] [-d] [-c] +``` + +## Types + +| Flag | Description | +| -------------------- | --------------------------- | +| `--system` | System prompt | +| `--nudge` | Nudge prompt | +| `--prune-list` | Example prunable tools list | +| `--compress-context` | Example compress context | + +## Tool Flags + +| Flag | Description | +| ---------------- | -------------------- | +| `-d, --distill` | Enable distill tool | +| `-c, --compress` | Enable compress tool | +| `-p, --prune` | Enable prune tool | + +If no tool flags specified, all are enabled. + +## Examples + +```bash +bun run dcp --system -p -d -c # System prompt with all tools +bun run dcp --system -p # System prompt with prune only +bun run dcp --nudge -d -c # Nudge with distill and compress +bun run dcp --prune-list # Example prunable tools list +``` + +## Purpose + +This CLI does NOT ship with the plugin. It's purely for DX - iterate on prompt templates and verify the `` conditional parsing produces the expected output. diff --git a/scripts/generate-prompts.ts b/scripts/generate-prompts.ts new file mode 100644 index 00000000..815a54db --- /dev/null +++ b/scripts/generate-prompts.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env tsx +/** + * Prebuild script that generates TypeScript files from Markdown prompts. + * + * This solves the issue where readFileSync with __dirname fails when the + * package is bundled by Bun (see issue #222, PR #272, #327). + * + * The .md files are kept for convenient editing, and this script generates + * .ts files with exported string constants that bundle correctly. + */ + +import { readFileSync, writeFileSync, readdirSync } from "node:fs" +import { dirname, join, basename } from "node:path" +import { fileURLToPath } from "node:url" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const PROMPTS_DIR = join(__dirname, "..", "lib", "prompts") + +// Find all .md files in the prompts directory +const mdFiles = readdirSync(PROMPTS_DIR).filter((f) => f.endsWith(".md")) + +for (const mdFile of mdFiles) { + const mdPath = join(PROMPTS_DIR, mdFile) + const baseName = basename(mdFile, ".md") + const constName = baseName.toUpperCase().replace(/-/g, "_") + const tsPath = join(PROMPTS_DIR, `${baseName}.generated.ts`) + + const content = readFileSync(mdPath, "utf-8") + + // Escape backticks and ${} template expressions for safe embedding in template literal + const escaped = content.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${") + + const tsContent = `// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from ${mdFile} by scripts/generate-prompts.ts +// To modify, edit ${mdFile} and run \`npm run generate:prompts\` + +export const ${constName} = \`${escaped}\` +` + + writeFileSync(tsPath, tsContent) + console.log(`Generated: ${baseName}.generated.ts`) +} + +console.log(`Done! Generated ${mdFiles.length} TypeScript file(s) from Markdown prompts.`) diff --git a/scripts/print.ts b/scripts/print.ts new file mode 100644 index 00000000..8d795cd3 --- /dev/null +++ b/scripts/print.ts @@ -0,0 +1,117 @@ +#!/usr/bin/env npx tsx + +import { renderSystemPrompt, renderNudge, type ToolFlags } from "../lib/prompts/index.js" +import { + wrapPrunableTools, + wrapCompressContext, + wrapCooldownMessage, +} from "../lib/messages/inject.js" + +const args = process.argv.slice(2) + +const flags: ToolFlags = { + prune: args.includes("-p") || args.includes("--prune"), + distill: args.includes("-d") || args.includes("--distill"), + compress: args.includes("-c") || args.includes("--compress"), +} + +// Default to all enabled if none specified +if (!flags.prune && !flags.distill && !flags.compress) { + flags.prune = true + flags.distill = true + flags.compress = true +} + +const showSystem = args.includes("--system") +const showNudge = args.includes("--nudge") +const showPruneList = args.includes("--prune-list") +const showCompressContext = args.includes("--compress-context") +const showCooldown = args.includes("--cooldown") +const showHelp = args.includes("--help") || args.includes("-h") + +if ( + showHelp || + (!showSystem && !showNudge && !showPruneList && !showCompressContext && !showCooldown) +) { + console.log(` +Usage: bun run dcp [TYPE] [-p] [-d] [-c] + +Types: + --system System prompt + --nudge Nudge prompt + --prune-list Example prunable tools list + --compress-context Example compress context + --cooldown Cooldown message after pruning + +Tool flags (for --system and --nudge): + -p, --prune Enable prune tool + -d, --distill Enable distill tool + -c, --compress Enable compress tool + +If no tool flags specified, all are enabled. + +Examples: + bun run dcp --system -p -d -c # System prompt with all tools + bun run dcp --system -p # System prompt with prune only + bun run dcp --nudge -d -c # Nudge with distill and compress + bun run dcp --prune-list # Example prunable tools list +`) + process.exit(0) +} + +const header = (title: string) => { + console.log() + console.log("─".repeat(60)) + console.log(title) + console.log("─".repeat(60)) +} + +if (showSystem) { + const enabled = [ + flags.prune && "prune", + flags.distill && "distill", + flags.compress && "compress", + ] + .filter(Boolean) + .join(", ") + header(`SYSTEM PROMPT (tools: ${enabled})`) + console.log(renderSystemPrompt(flags)) +} + +if (showNudge) { + const enabled = [ + flags.prune && "prune", + flags.distill && "distill", + flags.compress && "compress", + ] + .filter(Boolean) + .join(", ") + header(`NUDGE (tools: ${enabled})`) + console.log(renderNudge(flags)) +} + +if (showPruneList) { + header("PRUNABLE TOOLS LIST (mock example)") + const mockList = `5: read, /path/to/file.ts +8: bash, npm run build +12: glob, src/**/*.ts +15: read, /path/to/another-file.ts` + console.log(wrapPrunableTools(mockList)) +} + +if (showCompressContext) { + header("COMPRESS CONTEXT (mock example)") + console.log(wrapCompressContext(45)) +} + +if (showCooldown) { + const enabled = [ + flags.prune && "prune", + flags.distill && "distill", + flags.compress && "compress", + ] + .filter(Boolean) + .join(", ") + header(`COOLDOWN MESSAGE (tools: ${enabled})`) + console.log(wrapCooldownMessage(flags)) +} diff --git a/tsconfig.json b/tsconfig.json index b30286cf..c20d8a54 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "target": "ES2022", "module": "ESNext", - "lib": ["ES2022"], + "lib": ["ES2023"], "moduleResolution": "bundler", "resolveJsonModule": true, "allowJs": true,