diff --git a/.gitignore b/.gitignore index 753a64b2..8cb40b8c 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,11 @@ Thumbs.db # OpenCode .opencode/ +# Python cache +__pycache__/ +*.py[cod] +*$py.class + # Generated prompt files (from scripts/generate-prompts.ts) lib/prompts/**/*.generated.ts @@ -40,4 +45,4 @@ test-update.ts docs/ SCHEMA_NOTES.md -repomix-output.xml \ No newline at end of file +repomix-output.xml diff --git a/.repomixignore b/.repomixignore index 6bc6e2ee..fb3eec4d 100644 --- a/.repomixignore +++ b/.repomixignore @@ -6,4 +6,7 @@ dist/ repomix-output.xml bun.lock package-lock.jsonc -LICENCE +LICENSE +scripts/ +tests/ +README.md \ No newline at end of file diff --git a/README.md b/README.md index 596c4bee..52842933 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,15 @@ Restart OpenCode. The plugin will automatically start optimizing your sessions. ## How Pruning Works -DCP uses multiple tools and strategies to reduce context size: +DCP uses one user-facing tool and strategies to reduce context size: -### Tools +For model-facing behavior (prompts and tool calls), this capability is always addressed as `compress`. -**Distill** — Exposes a `distill` tool that the AI can call to distill valuable context into concise summaries before removing the tool content. +### Tool -**Compress** — Exposes a `compress` tool that the AI can call to collapse a large section of conversation (messages and tools) into a single summary. +**Compress** — Exposes a single `compress` tool with one method: select a conversation range using injected `startId` and `endId` (`mNNNN` or `bN`), then replace it with a technical summary. -**Prune** — Exposes a `prune` tool that the AI can call to remove completed or noisy tool content from context. +The model can use that same method at different scales: tiny ranges for noise cleanup, focused ranges for preserving key findings, and full chapters for completed work. ### Strategies @@ -105,13 +105,10 @@ DCP uses its own config file: > // Protect file operations from pruning via glob patterns > // Patterns match tool parameters.filePath (e.g. read/write/edit) > "protectedFilePatterns": [], -> // LLM-driven context pruning tools +> // LLM-driven context management tool > "tools": { -> // Shared settings for all prune tools +> // Shared settings for context management > "settings": { -> // Nudge the LLM to use prune tools (every tool results) -> "nudgeEnabled": true, -> "nudgeFrequency": 10, > // Token limit at which the model compresses session context > // to keep the model in the "smart zone" (not a hard limit) > // Accepts: number or "X%" (percentage of model's context window) @@ -123,28 +120,16 @@ DCP uses its own config file: > // "openai/gpt-5": 120000, > // "anthropic/claude-3-7-sonnet": "80%" > // }, -> // Additional tools to protect from pruning -> "protectedTools": [], +> // How often the context-limit nudge fires (1 = every fetch, 5 = every 5th) +> "nudgeFrequency": 5, > }, -> // Distills key findings into preserved knowledge before removing raw content -> "distill": { +> // Unified context compression tool +> "compress": { > // Permission mode: "allow" (no prompt), "ask" (prompt), "deny" (tool not registered) > "permission": "allow", -> // Show distillation content as an ignored message notification -> "showDistillation": false, -> }, -> // Collapses a range of conversation content into a single summary -> "compress": { -> // Permission mode: "deny" (tool not registered), "ask" (prompt), "allow" (no prompt) -> "permission": "deny", > // Show summary content as an ignored message notification > "showCompression": false, > }, -> // Removes tool content from context without preservation (for completed tasks or noise) -> "prune": { -> // Permission mode: "allow" (no prompt), "ask" (prompt), "deny" (tool not registered) -> "permission": "allow", -> }, > }, > // Automatic pruning strategies > "strategies": { @@ -181,16 +166,15 @@ DCP provides a `/dcp` slash command: - `/dcp stats` — Shows cumulative pruning statistics across all sessions. - `/dcp sweep` — Prunes all tools since the last user message. Accepts an optional count: `/dcp sweep 10` prunes the last 10 tools. Respects `commands.protectedTools`. - `/dcp manual [on|off]` — Toggle manual mode or set explicit state. When on, the AI will not autonomously use context management tools. -- `/dcp prune [focus]` — Trigger a single prune tool execution. Optional focus text directs the AI's pruning decisions. -- `/dcp distill [focus]` — Trigger a single distill tool execution. Optional focus text directs what to distill. + - `/dcp compress [focus]` — Trigger a single compress tool execution. Optional focus text directs what range to compress. ### Protected Tools By default, these tools are always protected from pruning: -`task`, `todowrite`, `todoread`, `distill`, `compress`, `prune`, `batch`, `plan_enter`, `plan_exit` +`task`, `todowrite`, `todoread`, `compress`, `batch`, `plan_enter`, `plan_exit` -The `protectedTools` arrays in each section add to this default list. +The `protectedTools` arrays in `commands` and `strategies` add to this default list. ### Config Precedence diff --git a/assets/images/3.0 release.png b/assets/images/3.0 release.png new file mode 100644 index 00000000..d0e8f81f Binary files /dev/null and b/assets/images/3.0 release.png differ diff --git a/dcp.schema.json b/dcp.schema.json index 49d8fbe1..4dca1cf5 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -24,13 +24,13 @@ "type": "string", "enum": ["off", "minimal", "detailed"], "default": "detailed", - "description": "Level of notification shown when pruning occurs" + "description": "Level of notification shown when context management occurs" }, "pruneNotificationType": { "type": "string", "enum": ["chat", "toast"], "default": "chat", - "description": "Where to display prune notifications (chat message or toast notification)" + "description": "Where to display notifications (chat message or toast notification)" }, "commands": { "type": "object", @@ -104,7 +104,7 @@ }, "tools": { "type": "object", - "description": "Configuration for pruning tools", + "description": "Configuration for context-management tools", "additionalProperties": false, "properties": { "settings": { @@ -112,23 +112,11 @@ "description": "General tool settings", "additionalProperties": false, "properties": { - "nudgeEnabled": { - "type": "boolean", - "default": true, - "description": "Enable nudge reminders to prune context" - }, "nudgeFrequency": { "type": "number", - "default": 10, - "description": "Frequency of nudge reminders (in turns)" - }, - "protectedTools": { - "type": "array", - "items": { - "type": "string" - }, - "default": [], - "description": "Tool names that should be protected from automatic pruning" + "default": 5, + "minimum": 1, + "description": "How often the context-limit nudge fires when above contextLimit (1 = every fetch, 5 = every 5th fetch)" }, "contextLimit": { "description": "When session tokens exceed this limit, a compress nudge is injected (\"X%\" uses percentage of the model's context window)", @@ -160,52 +148,21 @@ } } }, - "distill": { - "type": "object", - "description": "Configuration for the distill tool", - "additionalProperties": false, - "properties": { - "permission": { - "type": "string", - "enum": ["ask", "allow", "deny"], - "default": "allow", - "description": "Permission mode (deny disables the tool)" - }, - "showDistillation": { - "type": "boolean", - "default": false, - "description": "Show distillation output in the UI" - } - } - }, "compress": { "type": "object", - "description": "Configuration for the compress tool", + "description": "Configuration for the unified compress tool", "additionalProperties": false, "properties": { "permission": { "type": "string", "enum": ["ask", "allow", "deny"], - "default": "ask", + "default": "allow", "description": "Permission mode (deny disables the tool)" }, "showCompression": { "type": "boolean", "default": false, - "description": "Show summary output in the UI" - } - } - }, - "prune": { - "type": "object", - "description": "Configuration for the prune tool", - "additionalProperties": false, - "properties": { - "permission": { - "type": "string", - "enum": ["ask", "allow", "deny"], - "default": "allow", - "description": "Permission mode (deny disables the tool)" + "description": "Show compression summaries in notifications" } } } diff --git a/index.ts b/index.ts index 60c1b3ec..d8715756 100644 --- a/index.ts +++ b/index.ts @@ -2,7 +2,7 @@ import type { Plugin } from "@opencode-ai/plugin" import { getConfig } from "./lib/config" import { Logger } from "./lib/logger" import { createSessionState } from "./lib/state" -import { createPruneTool, createDistillTool, createCompressTool } from "./lib/strategies" +import { createCompressTool } from "./lib/tools" import { createChatMessageTransformHandler, createCommandExecuteHandler, @@ -61,15 +61,6 @@ const plugin: Plugin = (async (ctx) => { ctx.directory, ), tool: { - ...(config.tools.distill.permission !== "deny" && { - distill: createDistillTool({ - client: ctx.client, - state, - logger, - config, - workingDirectory: ctx.directory, - }), - }), ...(config.tools.compress.permission !== "deny" && { compress: createCompressTool({ client: ctx.client, @@ -79,15 +70,6 @@ const plugin: Plugin = (async (ctx) => { workingDirectory: ctx.directory, }), }), - ...(config.tools.prune.permission !== "deny" && { - prune: createPruneTool({ - client: ctx.client, - state, - logger, - config, - workingDirectory: ctx.directory, - }), - }), }, config: async (opencodeConfig) => { if (config.commands.enabled) { @@ -99,9 +81,7 @@ const plugin: Plugin = (async (ctx) => { } const toolsToAdd: string[] = [] - if (config.tools.distill.permission !== "deny") toolsToAdd.push("distill") if (config.tools.compress.permission !== "deny") toolsToAdd.push("compress") - if (config.tools.prune.permission !== "deny") toolsToAdd.push("prune") if (toolsToAdd.length > 0) { const existingPrimaryTools = opencodeConfig.experimental?.primary_tools ?? [] @@ -118,9 +98,7 @@ const plugin: Plugin = (async (ctx) => { const permission = opencodeConfig.permission ?? {} opencodeConfig.permission = { ...permission, - distill: config.tools.distill.permission, compress: config.tools.compress.permission, - prune: config.tools.prune.permission, } as typeof permission }, } diff --git a/lib/commands/help.ts b/lib/commands/help.ts index f456d933..6d1f6cfc 100644 --- a/lib/commands/help.ts +++ b/lib/commands/help.ts @@ -26,18 +26,16 @@ const BASE_COMMANDS: [string, string][] = [ ] const TOOL_COMMANDS: Record = { - prune: ["/dcp prune [focus]", "Trigger manual prune tool execution"], - distill: ["/dcp distill [focus]", "Trigger manual distill tool execution"], compress: ["/dcp compress [focus]", "Trigger manual compress tool execution"], } function getVisibleCommands(config: PluginConfig): [string, string][] { const commands = [...BASE_COMMANDS] - for (const tool of ["prune", "distill", "compress"] as const) { - if (config.tools[tool].permission !== "deny") { - commands.push(TOOL_COMMANDS[tool]) - } + + if (config.tools.compress.permission !== "deny") { + commands.push(TOOL_COMMANDS.compress) } + return commands } diff --git a/lib/commands/manual.ts b/lib/commands/manual.ts index 2c5c1815..4a60e97f 100644 --- a/lib/commands/manual.ts +++ b/lib/commands/manual.ts @@ -4,8 +4,6 @@ * * Usage: * /dcp manual [on|off] - Toggle manual mode or set explicit state - * /dcp prune [focus] - Trigger manual prune execution - * /dcp distill [focus] - Trigger manual distill execution * /dcp compress [focus] - Trigger manual compress execution */ @@ -14,33 +12,11 @@ import type { SessionState, WithParts } from "../state" import type { PluginConfig } from "../config" import { sendIgnoredMessage } from "../ui/notification" import { getCurrentParams } from "../strategies/utils" -import { syncToolCache } from "../state/tool-cache" -import { buildToolIdList } from "../messages/utils" -import { buildPrunableToolsList } from "../messages/inject" -const MANUAL_MODE_ON = - "Manual mode is now ON. Use /dcp prune, /dcp distill, or /dcp compress to trigger context tools manually." +const MANUAL_MODE_ON = "Manual mode is now ON. Use /dcp compress to trigger context tools manually." const MANUAL_MODE_OFF = "Manual mode is now OFF." -const NO_PRUNABLE_TOOLS = "No prunable tool outputs are currently available for manual triggering." - -const PRUNE_TRIGGER_PROMPT = [ - "", - "Manual mode trigger received. You must now use the prune tool exactly once.", - "Find the most significant set of prunable tool outputs to remove safely.", - "Follow prune policy and avoid pruning outputs that may be needed later.", - "Return after prune with a brief explanation of what you pruned and why.", -].join("\n\n") - -const DISTILL_TRIGGER_PROMPT = [ - "", - "Manual mode trigger received. You must now use the distill tool.", - "Select the most information-dense prunable outputs and distill them into complete technical substitutes.", - "Be exhaustive and preserve all critical technical details.", - "Return after distill with a brief explanation of what was distilled and why.", -].join("\n\n") - const COMPRESS_TRIGGER_PROMPT = [ "", "Manual mode trigger received. You must now use the compress tool.", @@ -49,25 +25,13 @@ const COMPRESS_TRIGGER_PROMPT = [ "Return after compress with a brief explanation of what range was compressed.", ].join("\n\n") -function getTriggerPrompt( - tool: "prune" | "distill" | "compress", - context?: string, - userFocus?: string, -): string { - const base = - tool === "prune" - ? PRUNE_TRIGGER_PROMPT - : tool === "distill" - ? DISTILL_TRIGGER_PROMPT - : COMPRESS_TRIGGER_PROMPT +function getTriggerPrompt(tool: "compress", userFocus?: string): string { + const base = COMPRESS_TRIGGER_PROMPT const sections = [base] if (userFocus && userFocus.trim().length > 0) { sections.push(`Additional user focus:\n${userFocus.trim()}`) } - if (context) { - sections.push(context) - } return sections.join("\n\n") } @@ -109,23 +73,8 @@ export async function handleManualToggleCommand( export async function handleManualTriggerCommand( ctx: ManualCommandContext, - tool: "prune" | "distill" | "compress", + tool: "compress", userFocus?: string, ): Promise { - const { client, state, config, logger, sessionId, messages } = ctx - - if (tool === "prune" || tool === "distill") { - syncToolCache(state, config, logger, messages) - buildToolIdList(state, messages, logger) - const prunableToolsList = buildPrunableToolsList(state, config, logger) - if (!prunableToolsList) { - const params = getCurrentParams(state, messages, logger) - await sendIgnoredMessage(client, sessionId, NO_PRUNABLE_TOOLS, params, logger) - return null - } - - return getTriggerPrompt(tool, prunableToolsList, userFocus) - } - - return getTriggerPrompt("compress", undefined, userFocus) + return getTriggerPrompt(tool, userFocus) } diff --git a/lib/commands/sweep.ts b/lib/commands/sweep.ts index 394c91b7..0a01a9f6 100644 --- a/lib/commands/sweep.ts +++ b/lib/commands/sweep.ts @@ -128,7 +128,7 @@ export async function handleSweepCommand(ctx: SweepCommandContext): Promise { + if (buffer.length === 0) { + scheduled = false + return + } + const chunk = buffer.join("") + buffer = [] + scheduled = false + try { + await fs.promises.appendFile(LOG_FILE, chunk) + } catch {} +} + +function schedule(): void { + if (!scheduled) { + scheduled = true + setTimeout(flush, WRITE_INTERVAL_MS) + } +} + +type Level = "DEBUG" | "INFO" | "WARN" | "ERROR" + +function truncate(str: string, max: number): string { + if (str.length <= max) return str + return `${str.substring(0, max)}...` +} + +function formatNumber(n: number): string { + return n.toLocaleString("en-US") +} + +function indent(lines: string, spaces: number): string { + const prefix = " ".repeat(spaces) + return lines + .split("\n") + .map((line) => (line ? prefix + line : "")) + .join("\n") +} + +function formatValue(value: unknown, depth = 0): string { + if (value === null) return "null" + if (value === undefined) return "undefined" + if (typeof value === "boolean") return value ? "true" : "false" + if (typeof value === "number") return formatNumber(value) + if (typeof value === "string") { + if (value.length > 120) { + return `"${truncate(value, 120)}"` + } + return `"${value}"` + } + + if (Array.isArray(value)) { + if (value.length === 0) return "[]" + if (depth > 3) { + return `[${value.length} items]` + } + const items = value + .slice(0, 10) + .map((v) => `- ${formatValue(v, depth + 1)}`) + .join("\n") + const omitted = value.length > 10 ? `\n... (${value.length - 10} more)` : "" + return `\n${indent(items, 2)}${omitted}` + } + + if (typeof value === "object") { + const entries = Object.entries(value) + if (entries.length === 0) return "{}" + if (depth > 3) { + return `{${entries.length} keys}` + } + const lines = entries + .slice(0, 15) + .map(([k, v]) => `${k}: ${formatValue(v, depth + 1)}`) + .join("\n") + const omitted = entries.length > 15 ? `\n... (${entries.length - 15} more)` : "" + return `\n${indent(lines, 2)}${omitted}` + } + + return String(value) +} + +function write(level: Level, category: string, message: string, data?: unknown): void { + if (!init()) return + const ts = new Date().toISOString() + let output = `[${ts}] [${level}] [${category}]\n${indent(message, 2)}` + if (data !== undefined) { + const formatted = formatValue(data) + output += `\n${indent(formatted, 2)}` + } + buffer.push(`${output}\n\n`) + schedule() +} + +export const clog = { + debug: (category: string, message: string, data?: unknown) => + write("DEBUG", category, message, data), + info: (category: string, message: string, data?: unknown) => + write("INFO", category, message, data), + warn: (category: string, message: string, data?: unknown) => + write("WARN", category, message, data), + error: (category: string, message: string, data?: unknown) => + write("ERROR", category, message, data), + flush, +} + +export const C = { + COMPRESS: "COMPRESS", + BOUNDARY: "BOUNDARY", + STATE: "STATE", +} as const diff --git a/lib/config.ts b/lib/config.ts index 1ea6c0d8..2869f9b9 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -4,38 +4,27 @@ import { homedir } from "os" import { parse } from "jsonc-parser" import type { PluginInput } from "@opencode-ai/plugin" +type Permission = "ask" | "allow" | "deny" + export interface Deduplication { enabled: boolean protectedTools: string[] } -export interface PruneTool { - permission: "ask" | "allow" | "deny" -} - -export interface DistillTool { - permission: "ask" | "allow" | "deny" - showDistillation: boolean -} - export interface CompressTool { - permission: "ask" | "allow" | "deny" + permission: Permission showCompression: boolean } export interface ToolSettings { - nudgeEnabled: boolean nudgeFrequency: number - protectedTools: string[] contextLimit: number | `${number}%` modelLimits?: Record } export interface Tools { settings: ToolSettings - distill: DistillTool compress: CompressTool - prune: PruneTool } export interface Commands { @@ -80,25 +69,23 @@ export interface PluginConfig { } } +type ToolOverride = Partial + const DEFAULT_PROTECTED_TOOLS = [ "task", "todowrite", "todoread", - "distill", "compress", - "prune", "batch", "plan_enter", "plan_exit", ] -// Valid config keys for validation against user config export const VALID_CONFIG_KEYS = new Set([ - // Top-level keys "$schema", "enabled", "debug", - "showUpdateToasts", // Deprecated but kept for backwards compatibility + "showUpdateToasts", "pruneNotification", "pruneNotificationType", "turnProtection", @@ -113,35 +100,24 @@ export const VALID_CONFIG_KEYS = new Set([ "manualMode.automaticStrategies", "tools", "tools.settings", - "tools.settings.nudgeEnabled", "tools.settings.nudgeFrequency", - "tools.settings.protectedTools", "tools.settings.contextLimit", "tools.settings.modelLimits", - "tools.distill", - "tools.distill.permission", - "tools.distill.showDistillation", "tools.compress", "tools.compress.permission", "tools.compress.showCompression", - "tools.prune", - "tools.prune.permission", "strategies", - // strategies.deduplication "strategies.deduplication", "strategies.deduplication.enabled", "strategies.deduplication.protectedTools", - // strategies.supersedeWrites "strategies.supersedeWrites", "strategies.supersedeWrites.enabled", - // strategies.purgeErrors "strategies.purgeErrors", "strategies.purgeErrors.enabled", "strategies.purgeErrors.turns", "strategies.purgeErrors.protectedTools", ]) -// Extract all key paths from a config object for validation function getConfigKeyPaths(obj: Record, prefix = ""): string[] { const keys: string[] = [] for (const key of Object.keys(obj)) { @@ -160,13 +136,11 @@ function getConfigKeyPaths(obj: Record, prefix = ""): string[] { return keys } -// Returns invalid keys found in user config export function getInvalidConfigKeys(userConfig: Record): string[] { const userKeys = getConfigKeyPaths(userConfig) return userKeys.filter((key) => !VALID_CONFIG_KEYS.has(key)) } -// Type validators for config values interface ValidationError { key: string expected: string @@ -176,13 +150,14 @@ interface ValidationError { export function validateConfigTypes(config: Record): ValidationError[] { const errors: ValidationError[] = [] - // Top-level validators if (config.enabled !== undefined && typeof config.enabled !== "boolean") { errors.push({ key: "enabled", expected: "boolean", actual: typeof config.enabled }) } + if (config.debug !== undefined && typeof config.debug !== "boolean") { errors.push({ key: "debug", expected: "boolean", actual: typeof config.debug }) } + if (config.pruneNotification !== undefined) { const validValues = ["off", "minimal", "detailed"] if (!validValues.includes(config.pruneNotification)) { @@ -212,7 +187,7 @@ export function validateConfigTypes(config: Record): ValidationErro expected: "string[]", actual: typeof config.protectedFilePatterns, }) - } else if (!config.protectedFilePatterns.every((v) => typeof v === "string")) { + } else if (!config.protectedFilePatterns.every((v: unknown) => typeof v === "string")) { errors.push({ key: "protectedFilePatterns", expected: "string[]", @@ -221,7 +196,6 @@ export function validateConfigTypes(config: Record): ValidationErro } } - // Top-level turnProtection validator if (config.turnProtection) { if ( config.turnProtection.enabled !== undefined && @@ -233,6 +207,7 @@ export function validateConfigTypes(config: Record): ValidationErro actual: typeof config.turnProtection.enabled, }) } + if ( config.turnProtection.turns !== undefined && typeof config.turnProtection.turns !== "number" @@ -252,10 +227,15 @@ export function validateConfigTypes(config: Record): ValidationErro } } - // Commands validator const commands = config.commands if (commands !== undefined) { - if (typeof commands === "object") { + if (typeof commands !== "object" || commands === null || Array.isArray(commands)) { + errors.push({ + key: "commands", + expected: "object", + actual: typeof commands, + }) + } else { if (commands.enabled !== undefined && typeof commands.enabled !== "boolean") { errors.push({ key: "commands.enabled", @@ -270,19 +250,18 @@ export function validateConfigTypes(config: Record): ValidationErro actual: typeof commands.protectedTools, }) } - } else { - errors.push({ - key: "commands", - expected: "{ enabled: boolean, protectedTools: string[] }", - actual: typeof commands, - }) } } - // Manual mode validator const manualMode = config.manualMode if (manualMode !== undefined) { - if (typeof manualMode === "object") { + if (typeof manualMode !== "object" || manualMode === null || Array.isArray(manualMode)) { + errors.push({ + key: "manualMode", + expected: "object", + actual: typeof manualMode, + }) + } else { if (manualMode.enabled !== undefined && typeof manualMode.enabled !== "boolean") { errors.push({ key: "manualMode.enabled", @@ -290,6 +269,7 @@ export function validateConfigTypes(config: Record): ValidationErro actual: typeof manualMode.enabled, }) } + if ( manualMode.automaticStrategies !== undefined && typeof manualMode.automaticStrategies !== "boolean" @@ -300,29 +280,12 @@ export function validateConfigTypes(config: Record): ValidationErro actual: typeof manualMode.automaticStrategies, }) } - } else { - errors.push({ - key: "manualMode", - expected: "{ enabled: boolean, automaticStrategies: boolean }", - actual: typeof manualMode, - }) } } - // Tools validators const tools = config.tools if (tools) { if (tools.settings) { - if ( - tools.settings.nudgeEnabled !== undefined && - typeof tools.settings.nudgeEnabled !== "boolean" - ) { - errors.push({ - key: "tools.settings.nudgeEnabled", - expected: "boolean", - actual: typeof tools.settings.nudgeEnabled, - }) - } if ( tools.settings.nudgeFrequency !== undefined && typeof tools.settings.nudgeFrequency !== "number" @@ -333,6 +296,7 @@ export function validateConfigTypes(config: Record): ValidationErro actual: typeof tools.settings.nudgeFrequency, }) } + if ( typeof tools.settings.nudgeFrequency === "number" && tools.settings.nudgeFrequency < 1 @@ -343,16 +307,6 @@ export function validateConfigTypes(config: Record): ValidationErro actual: `${tools.settings.nudgeFrequency} (will be clamped to 1)`, }) } - if ( - tools.settings.protectedTools !== undefined && - !Array.isArray(tools.settings.protectedTools) - ) { - errors.push({ - key: "tools.settings.protectedTools", - expected: "string[]", - actual: typeof tools.settings.protectedTools, - }) - } if (tools.settings.contextLimit !== undefined) { const isValidNumber = typeof tools.settings.contextLimit === "number" const isPercentString = @@ -367,9 +321,11 @@ export function validateConfigTypes(config: Record): ValidationErro }) } } + if (tools.settings.modelLimits !== undefined) { if ( typeof tools.settings.modelLimits !== "object" || + tools.settings.modelLimits === null || Array.isArray(tools.settings.modelLimits) ) { errors.push({ @@ -394,40 +350,21 @@ export function validateConfigTypes(config: Record): ValidationErro } } } - if (tools.distill) { - if (tools.distill.permission !== undefined) { - const validValues = ["ask", "allow", "deny"] - if (!validValues.includes(tools.distill.permission)) { - errors.push({ - key: "tools.distill.permission", - expected: '"ask" | "allow" | "deny"', - actual: JSON.stringify(tools.distill.permission), - }) - } - } - if ( - tools.distill.showDistillation !== undefined && - typeof tools.distill.showDistillation !== "boolean" - ) { - errors.push({ - key: "tools.distill.showDistillation", - expected: "boolean", - actual: typeof tools.distill.showDistillation, - }) - } - } } + if (tools.compress) { - if (tools.compress.permission !== undefined) { - const validValues = ["ask", "allow", "deny"] - if (!validValues.includes(tools.compress.permission)) { - errors.push({ - key: "tools.compress.permission", - expected: '"ask" | "allow" | "deny"', - actual: JSON.stringify(tools.compress.permission), - }) - } + const validValues = ["ask", "allow", "deny"] + if ( + tools.compress.permission !== undefined && + !validValues.includes(tools.compress.permission) + ) { + errors.push({ + key: "tools.compress.permission", + expected: '"ask" | "allow" | "deny"', + actual: JSON.stringify(tools.compress.permission), + }) } + if ( tools.compress.showCompression !== undefined && typeof tools.compress.showCompression !== "boolean" @@ -439,24 +376,10 @@ export function validateConfigTypes(config: Record): ValidationErro }) } } - if (tools.prune) { - if (tools.prune.permission !== undefined) { - const validValues = ["ask", "allow", "deny"] - if (!validValues.includes(tools.prune.permission)) { - errors.push({ - key: "tools.prune.permission", - expected: '"ask" | "allow" | "deny"', - actual: JSON.stringify(tools.prune.permission), - }) - } - } - } } - // Strategies validators const strategies = config.strategies if (strategies) { - // deduplication if ( strategies.deduplication?.enabled !== undefined && typeof strategies.deduplication.enabled !== "boolean" @@ -467,6 +390,7 @@ export function validateConfigTypes(config: Record): ValidationErro actual: typeof strategies.deduplication.enabled, }) } + if ( strategies.deduplication?.protectedTools !== undefined && !Array.isArray(strategies.deduplication.protectedTools) @@ -478,7 +402,6 @@ export function validateConfigTypes(config: Record): ValidationErro }) } - // supersedeWrites if (strategies.supersedeWrites) { if ( strategies.supersedeWrites.enabled !== undefined && @@ -492,7 +415,6 @@ export function validateConfigTypes(config: Record): ValidationErro } } - // purgeErrors if (strategies.purgeErrors) { if ( strategies.purgeErrors.enabled !== undefined && @@ -504,6 +426,7 @@ export function validateConfigTypes(config: Record): ValidationErro actual: typeof strategies.purgeErrors.enabled, }) } + if ( strategies.purgeErrors.turns !== undefined && typeof strategies.purgeErrors.turns !== "number" @@ -541,7 +464,6 @@ export function validateConfigTypes(config: Record): ValidationErro return errors } -// Show validation warnings for a config file function showConfigValidationWarnings( ctx: PluginInput, configPath: string, @@ -577,7 +499,7 @@ function showConfigValidationWarnings( try { ctx.client.tui.showToast({ body: { - title: `DCP: Invalid ${configType}`, + title: `DCP: ${configType} warning`, message: `${configPath}\n${messages.join("\n")}`, variant: "warning", duration: 7000, @@ -607,21 +529,12 @@ const defaultConfig: PluginConfig = { protectedFilePatterns: [], tools: { settings: { - nudgeEnabled: true, - nudgeFrequency: 10, - protectedTools: [...DEFAULT_PROTECTED_TOOLS], + nudgeFrequency: 5, contextLimit: 100000, }, - distill: { - permission: "allow", - showDistillation: false, - }, compress: { - permission: "deny", - showCompression: false, - }, - prune: { permission: "allow", + showCompression: false, }, }, strategies: { @@ -654,7 +567,9 @@ function findOpencodeDir(startDir: string): string | null { return candidate } const parent = dirname(current) - if (parent === current) break + if (parent === current) { + break + } current = parent } return null @@ -665,43 +580,39 @@ function getConfigPaths(ctx?: PluginInput): { configDir: string | null project: string | null } { - // Global: ~/.config/opencode/dcp.jsonc|json - let globalPath: string | null = null - if (existsSync(GLOBAL_CONFIG_PATH_JSONC)) { - globalPath = GLOBAL_CONFIG_PATH_JSONC - } else if (existsSync(GLOBAL_CONFIG_PATH_JSON)) { - globalPath = GLOBAL_CONFIG_PATH_JSON - } + const global = existsSync(GLOBAL_CONFIG_PATH_JSONC) + ? GLOBAL_CONFIG_PATH_JSONC + : existsSync(GLOBAL_CONFIG_PATH_JSON) + ? GLOBAL_CONFIG_PATH_JSON + : null - // Custom config directory: $OPENCODE_CONFIG_DIR/dcp.jsonc|json - let configDirPath: string | null = null + let configDir: string | null = null const opencodeConfigDir = process.env.OPENCODE_CONFIG_DIR if (opencodeConfigDir) { const configJsonc = join(opencodeConfigDir, "dcp.jsonc") const configJson = join(opencodeConfigDir, "dcp.json") - if (existsSync(configJsonc)) { - configDirPath = configJsonc - } else if (existsSync(configJson)) { - configDirPath = configJson - } + configDir = existsSync(configJsonc) + ? configJsonc + : existsSync(configJson) + ? configJson + : null } - // Project: /.opencode/dcp.jsonc|json - let projectPath: string | null = null + let project: string | null = null if (ctx?.directory) { const opencodeDir = findOpencodeDir(ctx.directory) if (opencodeDir) { const projectJsonc = join(opencodeDir, "dcp.jsonc") const projectJson = join(opencodeDir, "dcp.json") - if (existsSync(projectJsonc)) { - projectPath = projectJsonc - } else if (existsSync(projectJson)) { - projectPath = projectJson - } + project = existsSync(projectJsonc) + ? projectJsonc + : existsSync(projectJson) + ? projectJson + : null } } - return { global: globalPath, configDir: configDirPath, project: projectPath } + return { global, configDir, project } } function createDefaultConfig(): void { @@ -722,16 +633,15 @@ interface ConfigLoadResult { } function loadConfigFile(configPath: string): ConfigLoadResult { - let fileContent: string + let fileContent = "" try { fileContent = readFileSync(configPath, "utf-8") } catch { - // File doesn't exist or can't be read - not a parse error return { data: null } } try { - const parsed = parse(fileContent) + const parsed = parse(fileContent, undefined, { allowTrailingComma: true }) if (parsed === undefined || parsed === null) { return { data: null, parseError: "Config file is empty or invalid" } } @@ -745,7 +655,9 @@ function mergeStrategies( base: PluginConfig["strategies"], override?: Partial, ): PluginConfig["strategies"] { - if (!override) return base + if (!override) { + return base + } return { deduplication: { @@ -773,36 +685,21 @@ function mergeStrategies( } } -function mergeTools( - base: PluginConfig["tools"], - override?: Partial, -): PluginConfig["tools"] { - if (!override) return base +function mergeTools(base: PluginConfig["tools"], override?: ToolOverride): PluginConfig["tools"] { + if (!override) { + return base + } return { settings: { - nudgeEnabled: override.settings?.nudgeEnabled ?? base.settings.nudgeEnabled, nudgeFrequency: override.settings?.nudgeFrequency ?? base.settings.nudgeFrequency, - protectedTools: [ - ...new Set([ - ...base.settings.protectedTools, - ...(override.settings?.protectedTools ?? []), - ]), - ], contextLimit: override.settings?.contextLimit ?? base.settings.contextLimit, modelLimits: override.settings?.modelLimits ?? base.settings.modelLimits, }, - distill: { - permission: override.distill?.permission ?? base.distill.permission, - showDistillation: override.distill?.showDistillation ?? base.distill.showDistillation, - }, compress: { permission: override.compress?.permission ?? base.compress.permission, showCompression: override.compress?.showCompression ?? base.compress.showCompression, }, - prune: { - permission: override.prune?.permission ?? base.prune.permission, - }, } } @@ -810,7 +707,9 @@ function mergeCommands( base: PluginConfig["commands"], override?: Partial, ): PluginConfig["commands"] { - if (override === undefined) return base + if (!override) { + return base + } return { enabled: override.enabled ?? base.enabled, @@ -846,21 +745,16 @@ function deepCloneConfig(config: PluginConfig): PluginConfig { tools: { settings: { ...config.tools.settings, - protectedTools: [...config.tools.settings.protectedTools], modelLimits: { ...config.tools.settings.modelLimits }, }, - distill: { ...config.tools.distill }, compress: { ...config.tools.compress }, - prune: { ...config.tools.prune }, }, strategies: { deduplication: { ...config.strategies.deduplication, protectedTools: [...config.strategies.deduplication.protectedTools], }, - supersedeWrites: { - ...config.strategies.supersedeWrites, - }, + supersedeWrites: { ...config.strategies.supersedeWrites }, purgeErrors: { ...config.strategies.purgeErrors, protectedTools: [...config.strategies.purgeErrors.protectedTools], @@ -869,140 +763,76 @@ function deepCloneConfig(config: PluginConfig): PluginConfig { } } +function mergeLayer(config: PluginConfig, data: Record): PluginConfig { + return { + enabled: data.enabled ?? config.enabled, + debug: data.debug ?? config.debug, + pruneNotification: data.pruneNotification ?? config.pruneNotification, + pruneNotificationType: data.pruneNotificationType ?? config.pruneNotificationType, + commands: mergeCommands(config.commands, data.commands as any), + manualMode: mergeManualMode(config.manualMode, data.manualMode as any), + turnProtection: { + enabled: data.turnProtection?.enabled ?? config.turnProtection.enabled, + turns: data.turnProtection?.turns ?? config.turnProtection.turns, + }, + protectedFilePatterns: [ + ...new Set([...config.protectedFilePatterns, ...(data.protectedFilePatterns ?? [])]), + ], + tools: mergeTools(config.tools, data.tools as ToolOverride), + strategies: mergeStrategies(config.strategies, data.strategies as any), + } +} + +function scheduleParseWarning(ctx: PluginInput, title: string, message: string): void { + setTimeout(() => { + try { + ctx.client.tui.showToast({ + body: { + title, + message, + variant: "warning", + duration: 7000, + }, + }) + } catch {} + }, 7000) +} + export function getConfig(ctx: PluginInput): PluginConfig { let config = deepCloneConfig(defaultConfig) const configPaths = getConfigPaths(ctx) - // Load and merge global config - if (configPaths.global) { - const result = loadConfigFile(configPaths.global) - if (result.parseError) { - setTimeout(async () => { - try { - ctx.client.tui.showToast({ - body: { - title: "DCP: Invalid config", - message: `${configPaths.global}\n${result.parseError}\nUsing default values`, - variant: "warning", - duration: 7000, - }, - }) - } catch {} - }, 7000) - } else if (result.data) { - // Validate config keys and types - showConfigValidationWarnings(ctx, configPaths.global, result.data, false) - config = { - enabled: result.data.enabled ?? config.enabled, - debug: result.data.debug ?? config.debug, - pruneNotification: result.data.pruneNotification ?? config.pruneNotification, - pruneNotificationType: - result.data.pruneNotificationType ?? config.pruneNotificationType, - commands: mergeCommands(config.commands, result.data.commands as any), - manualMode: mergeManualMode(config.manualMode, result.data.manualMode as any), - turnProtection: { - enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, - turns: result.data.turnProtection?.turns ?? config.turnProtection.turns, - }, - protectedFilePatterns: [ - ...new Set([ - ...config.protectedFilePatterns, - ...(result.data.protectedFilePatterns ?? []), - ]), - ], - tools: mergeTools(config.tools, result.data.tools as any), - strategies: mergeStrategies(config.strategies, result.data.strategies as any), - } - } - } else { - // No config exists, create default + if (!configPaths.global) { createDefaultConfig() } - // Load and merge $OPENCODE_CONFIG_DIR/dcp.jsonc|json (overrides global) - if (configPaths.configDir) { - const result = loadConfigFile(configPaths.configDir) - if (result.parseError) { - setTimeout(async () => { - try { - ctx.client.tui.showToast({ - body: { - title: "DCP: Invalid configDir config", - message: `${configPaths.configDir}\n${result.parseError}\nUsing global/default values`, - variant: "warning", - duration: 7000, - }, - }) - } catch {} - }, 7000) - } else if (result.data) { - // Validate config keys and types - showConfigValidationWarnings(ctx, configPaths.configDir, result.data, true) - config = { - enabled: result.data.enabled ?? config.enabled, - debug: result.data.debug ?? config.debug, - pruneNotification: result.data.pruneNotification ?? config.pruneNotification, - pruneNotificationType: - result.data.pruneNotificationType ?? config.pruneNotificationType, - commands: mergeCommands(config.commands, result.data.commands as any), - manualMode: mergeManualMode(config.manualMode, result.data.manualMode as any), - turnProtection: { - enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, - turns: result.data.turnProtection?.turns ?? config.turnProtection.turns, - }, - protectedFilePatterns: [ - ...new Set([ - ...config.protectedFilePatterns, - ...(result.data.protectedFilePatterns ?? []), - ]), - ], - tools: mergeTools(config.tools, result.data.tools as any), - strategies: mergeStrategies(config.strategies, result.data.strategies as any), - } + const layers: Array<{ path: string | null; name: string; isProject: boolean }> = [ + { path: configPaths.global, name: "config", isProject: false }, + { path: configPaths.configDir, name: "configDir config", isProject: true }, + { path: configPaths.project, name: "project config", isProject: true }, + ] + + for (const layer of layers) { + if (!layer.path) { + continue } - } - // Load and merge project config (overrides global) - if (configPaths.project) { - const result = loadConfigFile(configPaths.project) + const result = loadConfigFile(layer.path) if (result.parseError) { - setTimeout(async () => { - try { - ctx.client.tui.showToast({ - body: { - title: "DCP: Invalid project config", - message: `${configPaths.project}\n${result.parseError}\nUsing global/default values`, - variant: "warning", - duration: 7000, - }, - }) - } catch {} - }, 7000) - } else if (result.data) { - // Validate config keys and types - showConfigValidationWarnings(ctx, configPaths.project, result.data, true) - config = { - enabled: result.data.enabled ?? config.enabled, - debug: result.data.debug ?? config.debug, - pruneNotification: result.data.pruneNotification ?? config.pruneNotification, - pruneNotificationType: - result.data.pruneNotificationType ?? config.pruneNotificationType, - commands: mergeCommands(config.commands, result.data.commands as any), - manualMode: mergeManualMode(config.manualMode, result.data.manualMode as any), - turnProtection: { - enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, - turns: result.data.turnProtection?.turns ?? config.turnProtection.turns, - }, - protectedFilePatterns: [ - ...new Set([ - ...config.protectedFilePatterns, - ...(result.data.protectedFilePatterns ?? []), - ]), - ], - tools: mergeTools(config.tools, result.data.tools as any), - strategies: mergeStrategies(config.strategies, result.data.strategies as any), - } + scheduleParseWarning( + ctx, + `DCP: Invalid ${layer.name}`, + `${layer.path}\n${result.parseError}\nUsing previous/default values`, + ) + continue } + + if (!result.data) { + continue + } + + showConfigValidationWarnings(ctx, layer.path, result.data, layer.isProject) + config = mergeLayer(config, result.data) } return config diff --git a/lib/hooks.ts b/lib/hooks.ts index 5a16f541..f5a4e7e7 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -4,7 +4,7 @@ import type { PluginConfig } from "./config" import { assignMessageRefs } from "./message-ids" import { syncToolCache } from "./state/tool-cache" import { deduplicate, supersedeWrites, purgeErrors } from "./strategies" -import { prune, insertPruneToolContext, insertMessageIdContext } from "./messages" +import { prune, insertCompressToolContext, insertMessageIdContext } from "./messages" import { buildToolIdList, isIgnoredUserMessage } from "./messages/utils" import { checkSession } from "./state" import { renderSystemPrompt } from "./prompts" @@ -14,6 +14,7 @@ import { handleHelpCommand } from "./commands/help" import { handleSweepCommand } from "./commands/sweep" import { handleManualToggleCommand, handleManualTriggerCommand } from "./commands/manual" import { ensureSessionInitialized } from "./state/state" +import { cacheSystemPromptTokens } from "./ui/utils" const INTERNAL_AGENT_SIGNATURES = [ "You are a title generator", @@ -81,18 +82,11 @@ export function createSystemPromptHandler( return } - const flags = { - prune: config.tools.prune.permission !== "deny", - distill: config.tools.distill.permission !== "deny", - compress: config.tools.compress.permission !== "deny", - manual: state.manualMode, - } - - if (!flags.prune && !flags.distill && !flags.compress) { + if (config.tools.compress.permission === "deny") { return } - output.system.push(renderSystemPrompt(flags)) + output.system.push(renderSystemPrompt(state.manualMode)) } } @@ -109,17 +103,21 @@ export function createChatMessageTransformHandler( return } + cacheSystemPromptTokens(state, output.messages) + assignMessageRefs(state, output.messages) syncToolCache(state, config, logger, output.messages) - buildToolIdList(state, output.messages, logger) + buildToolIdList(state, output.messages) deduplicate(state, logger, config, output.messages) supersedeWrites(state, logger, config, output.messages) purgeErrors(state, logger, config, output.messages) prune(state, logger, config, output.messages) - insertPruneToolContext(state, config, logger, output.messages) + + insertCompressToolContext(state, config, logger, output.messages) + insertMessageIdContext(state, config, output.messages) applyPendingManualTriggerPrompt(state, output.messages, logger) @@ -197,12 +195,9 @@ export function createCommandExecuteHandler( throw new Error("__DCP_MANUAL_HANDLED__") } - if ( - (subcommand === "prune" || subcommand === "distill" || subcommand === "compress") && - config.tools[subcommand].permission !== "deny" - ) { + if (subcommand === "compress" && config.tools.compress.permission !== "deny") { const userFocus = subArgs.join(" ").trim() - const prompt = await handleManualTriggerCommand(commandCtx, subcommand, userFocus) + const prompt = await handleManualTriggerCommand(commandCtx, "compress", userFocus) if (!prompt) { throw new Error("__DCP_MANUAL_TRIGGER_BLOCKED__") } diff --git a/lib/logger.ts b/lib/logger.ts index 05852abc..5655501d 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -180,6 +180,12 @@ export class Logger { if (part.state?.error) { toolPart.error = part.state.error } + if (part.metadata) { + toolPart.metadata = part.metadata + } + if (part.state?.title) { + toolPart.title = part.state.title + } return toolPart } diff --git a/lib/messages/index.ts b/lib/messages/index.ts index 78efa7b0..e0ec4ef0 100644 --- a/lib/messages/index.ts +++ b/lib/messages/index.ts @@ -1,3 +1,3 @@ export { prune } from "./prune" -export { insertPruneToolContext } from "./inject" -export { insertMessageIdContext } from "./inject" +export { insertCompressToolContext } from "./inject/inject" +export { insertMessageIdContext } from "./inject/inject" diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts deleted file mode 100644 index 71026a79..00000000 --- a/lib/messages/inject.ts +++ /dev/null @@ -1,370 +0,0 @@ -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 { formatMessageIdTag } from "../message-ids" -import { renderNudge, renderCompressNudge } from "../prompts" -import { - extractParameterKey, - createSyntheticTextPart, - createSyntheticToolPart, - isIgnoredUserMessage, - appendMessageIdTagToToolOutput, - findLastToolPart, -} from "./utils" -import { getFilePathsFromParameters, isProtected } from "../protected-file-patterns" -import { getLastUserMessage, isMessageCompacted } from "../shared-utils" -import { getCurrentTokenUsage } from "../strategies/utils" - -function parsePercentageString(value: string, total: number): number | undefined { - if (!value.endsWith("%")) return undefined - const percent = parseFloat(value.slice(0, -1)) - - if (isNaN(percent)) { - return undefined - } - - const roundedPercent = Math.round(percent) - const clampedPercent = Math.max(0, Math.min(100, roundedPercent)) - - return Math.round((clampedPercent / 100) * total) -} - -// XML wrappers -export const wrapPrunableTools = (content: string): string => { - return ` -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} -` -} - -export const wrapCompressContext = (messageCount: number): string => ` -Compress available. Conversation: ${messageCount} messages. -Compress collapses completed task sequences or exploration phases into summaries. -Uses ID boundaries [startId, endId, topic, summary]. -` - -export const wrapCooldownMessage = (flags: { - prune: boolean - distill: boolean - compress: boolean -}): string => { - const enabledTools: string[] = [] - if (flags.distill) enabledTools.push("distill") - if (flags.compress) enabledTools.push("compress") - if (flags.prune) enabledTools.push("prune") - - let toolName: string - if (enabledTools.length === 0) { - toolName = "pruning tools" - } else if (enabledTools.length === 1) { - toolName = `${enabledTools[0]} tool` - } else { - 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. -` -} - -const resolveContextLimit = ( - config: PluginConfig, - state: SessionState, - providerId: string | undefined, - modelId: string | undefined, -): number | undefined => { - const modelLimits = config.tools.settings.modelLimits - const contextLimit = config.tools.settings.contextLimit - - if (modelLimits) { - const providerModelId = - providerId !== undefined && modelId !== undefined - ? `${providerId}/${modelId}` - : undefined - const limit = providerModelId !== undefined ? modelLimits[providerModelId] : undefined - - if (limit !== undefined) { - if (typeof limit === "string" && limit.endsWith("%")) { - if (state.modelContextLimit === undefined) { - return undefined - } - return parsePercentageString(limit, state.modelContextLimit) - } - return typeof limit === "number" ? limit : undefined - } - } - - if (typeof contextLimit === "string") { - if (contextLimit.endsWith("%")) { - if (state.modelContextLimit === undefined) { - return undefined - } - return parsePercentageString(contextLimit, state.modelContextLimit) - } - return undefined - } - - return contextLimit -} - -const shouldInjectCompressNudge = ( - config: PluginConfig, - state: SessionState, - messages: WithParts[], - providerId: string | undefined, - modelId: string | undefined, -): boolean => { - if (config.tools.compress.permission === "deny") { - return false - } - - const lastAssistant = messages.findLast((msg) => msg.info.role === "assistant") - if (lastAssistant) { - const parts = Array.isArray(lastAssistant.parts) ? lastAssistant.parts : [] - const hasDcpTool = parts.some( - (part) => - part.type === "tool" && - part.state.status === "completed" && - (part.tool === "compress" || part.tool === "prune" || part.tool === "distill"), - ) - if (hasDcpTool) { - return false - } - } - - const contextLimit = resolveContextLimit(config, state, providerId, modelId) - if (contextLimit === undefined) { - return false - } - - const currentTokens = getCurrentTokenUsage(messages) - return currentTokens > contextLimit -} - -const getNudgeString = (config: PluginConfig): string => { - const flags = { - prune: config.tools.prune.permission !== "deny", - distill: config.tools.distill.permission !== "deny", - compress: config.tools.compress.permission !== "deny", - manual: false, - } - - if (!flags.prune && !flags.distill && !flags.compress) { - return "" - } - - return renderNudge(flags) -} - -const getCooldownMessage = (config: PluginConfig): string => { - return wrapCooldownMessage({ - prune: config.tools.prune.permission !== "deny", - distill: config.tools.distill.permission !== "deny", - compress: config.tools.compress.permission !== "deny", - }) -} - -const buildCompressContext = (state: SessionState, messages: WithParts[]): string => { - const messageCount = messages.filter((msg) => !isMessageCompacted(state, msg)).length - return wrapCompressContext(messageCount) -} - -export const buildPrunableToolsList = ( - state: SessionState, - config: PluginConfig, - logger: Logger, -): string => { - const lines: string[] = [] - const toolIdList = state.toolIdList - - state.toolParameters.forEach((toolParameterEntry, toolCallId) => { - if (state.prune.tools.has(toolCallId)) { - return - } - - const allProtectedTools = config.tools.settings.protectedTools - if (allProtectedTools.includes(toolParameterEntry.tool)) { - return - } - - const filePaths = getFilePathsFromParameters( - toolParameterEntry.tool, - toolParameterEntry.parameters, - ) - if (isProtected(filePaths, config.protectedFilePatterns)) { - return - } - - const numericId = toolIdList.indexOf(toolCallId) - if (numericId === -1) { - logger.warn(`Tool in cache but not in toolIdList - possible stale entry`, { - toolCallId, - tool: toolParameterEntry.tool, - }) - return - } - const paramKey = extractParameterKey(toolParameterEntry.tool, toolParameterEntry.parameters) - const description = paramKey - ? `${toolParameterEntry.tool}, ${paramKey}` - : toolParameterEntry.tool - const tokenSuffix = - toolParameterEntry.tokenCount !== undefined - ? ` (~${toolParameterEntry.tokenCount} tokens)` - : "" - lines.push(`${numericId}: ${description}${tokenSuffix}`) - logger.debug( - `Prunable tool found - ID: ${numericId}, Tool: ${toolParameterEntry.tool}, Call ID: ${toolCallId}`, - ) - }) - - if (lines.length === 0) { - return "" - } - - return wrapPrunableTools(lines.join("\n")) -} - -export const insertPruneToolContext = ( - state: SessionState, - config: PluginConfig, - logger: Logger, - messages: WithParts[], -): void => { - if (state.manualMode || state.pendingManualTrigger) { - return - } - - const pruneEnabled = config.tools.prune.permission !== "deny" - const distillEnabled = config.tools.distill.permission !== "deny" - const compressEnabled = config.tools.compress.permission !== "deny" - - if (!pruneEnabled && !distillEnabled && !compressEnabled) { - return - } - - const pruneOrDistillEnabled = pruneEnabled || distillEnabled - const contentParts: string[] = [] - const lastUserMessage = getLastUserMessage(messages) - const providerId = lastUserMessage - ? (lastUserMessage.info as UserMessage).model.providerID - : undefined - const modelId = lastUserMessage - ? (lastUserMessage.info as UserMessage).model.modelID - : undefined - - if (state.lastToolPrune) { - logger.debug("Last tool was prune - injecting cooldown message") - contentParts.push(getCooldownMessage(config)) - } else { - if (pruneOrDistillEnabled) { - const prunableToolsList = buildPrunableToolsList(state, config, logger) - if (prunableToolsList) { - // logger.debug("prunable-tools: \n" + prunableToolsList) - contentParts.push(prunableToolsList) - } - } - - if (compressEnabled) { - const compressContext = buildCompressContext(state, messages) - // logger.debug("compress-context: \n" + compressContext) - contentParts.push(compressContext) - } - - if (shouldInjectCompressNudge(config, state, messages, providerId, modelId)) { - logger.info("Inserting compress nudge - token usage exceeds contextLimit") - contentParts.push(renderCompressNudge()) - } else if ( - config.tools.settings.nudgeEnabled && - state.nudgeCounter >= Math.max(1, config.tools.settings.nudgeFrequency) - ) { - logger.info("Inserting prune nudge message") - contentParts.push(getNudgeString(config)) - } - } - - if (contentParts.length === 0) { - return - } - - const combinedContent = `\n${contentParts.join("\n")}` - - if (!lastUserMessage) { - return - } - - const lastNonIgnoredMessage = messages.findLast( - (msg) => !(msg.info.role === "user" && isIgnoredUserMessage(msg)), - ) - - if (!lastNonIgnoredMessage) { - return - } - - // When following a user message, append a synthetic text part since models like Claude - // expect assistant turns to start with reasoning parts which cannot be easily faked. - // For all other cases, append a synthetic tool part to the last message which works - // across all models without disrupting their behavior. - if (lastNonIgnoredMessage.info.role === "user") { - const textPart = createSyntheticTextPart( - lastNonIgnoredMessage, - combinedContent, - `${lastNonIgnoredMessage.info.id}:context`, - ) - lastNonIgnoredMessage.parts.push(textPart) - } else { - const toolPart = createSyntheticToolPart( - lastNonIgnoredMessage, - combinedContent, - modelId ?? "", - `${lastNonIgnoredMessage.info.id}:context`, - ) - lastNonIgnoredMessage.parts.push(toolPart) - } -} - -export const insertMessageIdContext = ( - state: SessionState, - config: PluginConfig, - messages: WithParts[], -): void => { - if (config.tools.compress.permission === "deny") { - return - } - - const lastUserMessage = getLastUserMessage(messages) - const toolModelId = lastUserMessage - ? ((lastUserMessage.info as UserMessage).model.modelID ?? "") - : "" - - for (const message of messages) { - if (message.info.role === "user" && isIgnoredUserMessage(message)) { - continue - } - - const messageRef = state.messageIds.byRawId.get(message.info.id) - if (!messageRef) { - continue - } - - const tag = formatMessageIdTag(messageRef) - const messageIdSeed = `${message.info.id}:message-id:${messageRef}` - - if (message.info.role === "user") { - message.parts.push(createSyntheticTextPart(message, tag, messageIdSeed)) - continue - } - - if (message.info.role !== "assistant") { - continue - } - - const lastToolPart = findLastToolPart(message) - if (lastToolPart && appendMessageIdTagToToolOutput(lastToolPart, tag)) { - continue - } - - message.parts.push(createSyntheticToolPart(message, tag, toolModelId, messageIdSeed)) - } -} diff --git a/lib/messages/inject/inject.ts b/lib/messages/inject/inject.ts new file mode 100644 index 00000000..9577cbf7 --- /dev/null +++ b/lib/messages/inject/inject.ts @@ -0,0 +1,134 @@ +import type { SessionState, WithParts } from "../../state" +import type { Logger } from "../../logger" +import type { PluginConfig } from "../../config" +import { formatMessageIdTag } from "../../message-ids" +import { saveSessionState } from "../../state/persistence" +import { + appendMessageIdTagToToolOutput, + createSyntheticTextPart, + createSyntheticToolPart, + findLastToolPart, + isIgnoredUserMessage, + acceptsTextParts, +} from "../utils" +import { + addAnchor, + applyAnchoredNudge, + findLastNonIgnoredMessage, + getNudgeFrequency, + getModelInfo, + isContextOverLimit, + messageHasCompress, +} from "./utils" +import { CONTEXT_LIMIT_NUDGE, SOFT_NUDGE_PROMPT } from "../../prompts" + +export const insertCompressToolContext = ( + state: SessionState, + config: PluginConfig, + logger: Logger, + messages: WithParts[], +): void => { + if (config.tools.compress.permission === "deny") { + return + } + + const lastAssistantMessage = messages.findLast((message) => message.info.role === "assistant") + if (lastAssistantMessage && messageHasCompress(lastAssistantMessage)) { + const hasPersistedNudgeAnchors = + state.contextLimitAnchors.size > 0 || state.softNudgeAnchors.size > 0 + if (hasPersistedNudgeAnchors) { + state.contextLimitAnchors.clear() + state.softNudgeAnchors.clear() + void saveSessionState(state, logger) + } + return + } + + const { providerId, modelId } = getModelInfo(messages) + let anchorsChanged = false + + const contextOverLimit = isContextOverLimit(config, state, providerId, modelId, messages) + + if (contextOverLimit) { + const lastNonIgnoredMessage = findLastNonIgnoredMessage(messages) + if (lastNonIgnoredMessage) { + const interval = getNudgeFrequency(config) + const added = addAnchor( + state.contextLimitAnchors, + lastNonIgnoredMessage.message.info.id, + lastNonIgnoredMessage.index, + messages, + interval, + ) + if (added) { + anchorsChanged = true + } + } + + applyAnchoredNudge(state.contextLimitAnchors, messages, modelId, CONTEXT_LIMIT_NUDGE) + } else { + const lastMessage = messages[messages.length - 1] + const isLastMessageNonIgnoredUser = + lastMessage?.info.role === "user" && !isIgnoredUserMessage(lastMessage) + + if (isLastMessageNonIgnoredUser && lastAssistantMessage) { + const previousSize = state.softNudgeAnchors.size + state.softNudgeAnchors.add(lastAssistantMessage.info.id) + if (state.softNudgeAnchors.size !== previousSize) { + anchorsChanged = true + } + } + + applyAnchoredNudge(state.softNudgeAnchors, messages, modelId, SOFT_NUDGE_PROMPT) + } + + if (anchorsChanged) { + void saveSessionState(state, logger) + } +} + +export const insertMessageIdContext = ( + state: SessionState, + config: PluginConfig, + messages: WithParts[], +): void => { + if (config.tools.compress.permission === "deny") { + return + } + + const { modelId } = getModelInfo(messages) + const toolModelId = modelId || "" + + for (const message of messages) { + if (message.info.role === "user" && isIgnoredUserMessage(message)) { + continue + } + + const messageRef = state.messageIds.byRawId.get(message.info.id) + if (!messageRef) { + continue + } + + const tag = formatMessageIdTag(messageRef) + + if (message.info.role === "user" && !isIgnoredUserMessage(message)) { + message.parts.push(createSyntheticTextPart(message, tag)) + continue + } + + if (message.info.role !== "assistant") { + continue + } + + const lastToolPart = findLastToolPart(message) + if (lastToolPart && appendMessageIdTagToToolOutput(lastToolPart, tag)) { + continue + } + + if (!acceptsTextParts(toolModelId)) { + message.parts.push(createSyntheticToolPart(message, tag, toolModelId)) + } else { + message.parts.push(createSyntheticTextPart(message, tag)) + } + } +} diff --git a/lib/messages/inject/utils.ts b/lib/messages/inject/utils.ts new file mode 100644 index 00000000..42299410 --- /dev/null +++ b/lib/messages/inject/utils.ts @@ -0,0 +1,183 @@ +import type { SessionState, WithParts } from "../../state" +import type { PluginConfig } from "../../config" +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { + createSyntheticTextPart, + createSyntheticToolPart, + isIgnoredUserMessage, + acceptsTextParts, +} from "../utils" +import { getLastUserMessage } from "../../shared-utils" +import { getCurrentTokenUsage } from "../../strategies/utils" + +export interface LastUserModelContext { + providerId: string | undefined + modelId: string | undefined +} + +export interface LastNonIgnoredMessage { + message: WithParts + index: number +} + +export function getNudgeFrequency(config: PluginConfig): number { + return Math.max(1, Math.floor(config.tools.settings.nudgeFrequency || 1)) +} + +export function findLastNonIgnoredMessage(messages: WithParts[]): LastNonIgnoredMessage | null { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message.info.role === "user" && isIgnoredUserMessage(message)) { + continue + } + return { message, index: i } + } + + return null +} + +export function messageHasCompress(message: WithParts): boolean { + const parts = Array.isArray(message.parts) ? message.parts : [] + return parts.some( + (part) => + part.type === "tool" && part.state.status === "completed" && part.tool === "compress", + ) +} + +export function getModelInfo(messages: WithParts[]): LastUserModelContext { + const lastUserMessage = getLastUserMessage(messages) + if (!lastUserMessage) { + return { + providerId: undefined, + modelId: undefined, + } + } + + const userInfo = lastUserMessage.info as UserMessage + return { + providerId: userInfo.model.providerID, + modelId: userInfo.model.modelID, + } +} + +function resolveContextLimit( + config: PluginConfig, + state: SessionState, + providerId: string | undefined, + modelId: string | undefined, +): number | undefined { + const parseLimitValue = (limit: number | `${number}%` | undefined): number | undefined => { + if (limit === undefined) { + return undefined + } + + if (typeof limit === "number") { + return limit + } + + if (!limit.endsWith("%") || state.modelContextLimit === undefined) { + return undefined + } + + const parsedPercent = parseFloat(limit.slice(0, -1)) + if (isNaN(parsedPercent)) { + return undefined + } + + const roundedPercent = Math.round(parsedPercent) + const clampedPercent = Math.max(0, Math.min(100, roundedPercent)) + return Math.round((clampedPercent / 100) * state.modelContextLimit) + } + + const modelLimits = config.tools.settings.modelLimits + if (modelLimits && providerId !== undefined && modelId !== undefined) { + const providerModelId = `${providerId}/${modelId}` + const modelLimit = modelLimits[providerModelId] + if (modelLimit !== undefined) { + return parseLimitValue(modelLimit) + } + } + + return parseLimitValue(config.tools.settings.contextLimit) +} + +export function isContextOverLimit( + config: PluginConfig, + state: SessionState, + providerId: string | undefined, + modelId: string | undefined, + messages: WithParts[], +): boolean { + const contextLimit = resolveContextLimit(config, state, providerId, modelId) + if (contextLimit === undefined) { + return false + } + + const currentTokens = getCurrentTokenUsage(messages) + return currentTokens > contextLimit +} + +export function addAnchor( + anchorMessageIds: Set, + anchorMessageId: string, + anchorMessageIndex: number, + messages: WithParts[], + interval: number, +): boolean { + if (anchorMessageIndex < 0) { + return false + } + + let latestAnchorMessageIndex = -1 + for (let i = messages.length - 1; i >= 0; i--) { + if (anchorMessageIds.has(messages[i].info.id)) { + latestAnchorMessageIndex = i + break + } + } + + const shouldAdd = + latestAnchorMessageIndex < 0 || anchorMessageIndex - latestAnchorMessageIndex >= interval + if (!shouldAdd) { + return false + } + + const previousSize = anchorMessageIds.size + anchorMessageIds.add(anchorMessageId) + return anchorMessageIds.size !== previousSize +} + +export function applyAnchoredNudge( + anchorMessageIds: Set, + messages: WithParts[], + modelId: string | undefined, + hintText: string, +): void { + if (anchorMessageIds.size === 0) { + return + } + + for (const anchorMessageId of anchorMessageIds) { + const messageIndex = messages.findIndex((message) => message.info.id === anchorMessageId) + if (messageIndex === -1) { + continue + } + + const message = messages[messageIndex] + if (message.info.role === "user") { + message.parts.push(createSyntheticTextPart(message, hintText)) + continue + } + + if (message.info.role !== "assistant") { + continue + } + + const toolModelId = modelId || "" + if (!acceptsTextParts(toolModelId)) { + message.parts.push(createSyntheticToolPart(message, hintText, toolModelId)) + } else { + message.parts.push(createSyntheticTextPart(message, hintText)) + } + } +} diff --git a/lib/messages/prune.ts b/lib/messages/prune.ts index 9d317f4e..8029d704 100644 --- a/lib/messages/prune.ts +++ b/lib/messages/prune.ts @@ -108,13 +108,14 @@ const pruneToolInputs = (state: SessionState, logger: Logger, messages: WithPart if (part.type !== "tool") { continue } - if (part.tool === "compress" && part.state.status === "completed") { - const content = part.state.input?.content - if (content && typeof content === "object" && "summary" in content) { - content.summary = PRUNED_COMPRESS_SUMMARY_REPLACEMENT - } - continue - } + + // if (part.tool === "compress" && part.state.status === "completed") { + // const content = part.state.input?.content + // if (content && typeof content === "object" && "summary" in content) { + // content.summary = PRUNED_COMPRESS_SUMMARY_REPLACEMENT + // } + // continue + // } if (!state.prune.tools.has(part.callID)) { continue diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index 1b9d31c7..a5fe1565 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -1,6 +1,5 @@ import { createHash } from "node:crypto" import { isMessageCompacted } from "../shared-utils" -import { Logger } from "../logger" import type { SessionState, WithParts } from "../state" import type { UserMessage } from "@opencode-ai/sdk/v2" @@ -11,14 +10,16 @@ const generateStableId = (prefix: string, seed: string): string => { return `${prefix}_${hash}` } -type MessagePart = WithParts["parts"][number] -type ToolPart = Extract - const isGeminiModel = (modelID: string): boolean => { const lowerModelID = modelID.toLowerCase() return lowerModelID.includes("gemini") } +export const acceptsTextParts = (modelID: string): boolean => { + const lowerModelID = modelID.toLowerCase() + return !lowerModelID.includes("claude") +} + export const createSyntheticUserMessage = ( baseMessage: WithParts, content: string, @@ -84,10 +85,16 @@ export const createSyntheticToolPart = ( const partId = generateStableId("prt_dcp_tool", deterministicSeed) const callId = generateStableId("call_dcp_tool", deterministicSeed) - // Gemini requires thoughtSignature bypass to accept synthetic tool parts + const finalContent = `\nThis tool was called by the environment and is not available to the LLM.\n\n${content}` + + // Gemini requires a thought signature on synthetic function calls. + // This must live on part metadata so it maps to callProviderMetadata. const toolPartMetadata = isGeminiModel(modelID) - ? { google: { thoughtSignature: "skip_thought_signature_validator" } } - : {} + ? { + google: { thoughtSignature: "skip_thought_signature_validator" }, + vertex: { thoughtSignature: "skip_thought_signature_validator" }, + } + : undefined return { id: partId, @@ -96,18 +103,24 @@ export const createSyntheticToolPart = ( type: "tool" as const, callID: callId, tool: "context_info", + ...(toolPartMetadata ? { metadata: toolPartMetadata } : {}), state: { status: "completed" as const, input: {}, - output: content, + output: finalContent, title: "Context Info", - metadata: toolPartMetadata, time: { start: now, end: now }, - }, + } as any, } } +type MessagePart = WithParts["parts"][number] +type ToolPart = Extract + export const appendMessageIdTagToToolOutput = (part: ToolPart, tag: string): boolean => { + if (part.type !== "tool") { + return false + } if (part.state?.status !== "completed" || typeof part.state.output !== "string") { return false } @@ -130,145 +143,7 @@ export const findLastToolPart = (message: WithParts): ToolPart | null => { return null } -/** - * Extracts a human-readable key from tool metadata for display purposes. - */ -export const extractParameterKey = (tool: string, parameters: any): string => { - if (!parameters) return "" - - if (tool === "read" && parameters.filePath) { - const offset = parameters.offset - const limit = parameters.limit - if (offset !== undefined && limit !== undefined) { - return `${parameters.filePath} (lines ${offset}-${offset + limit})` - } - if (offset !== undefined) { - return `${parameters.filePath} (lines ${offset}+)` - } - if (limit !== undefined) { - return `${parameters.filePath} (lines 0-${limit})` - } - return parameters.filePath - } - if ((tool === "write" || tool === "edit" || tool === "multiedit") && parameters.filePath) { - return parameters.filePath - } - - if (tool === "apply_patch" && typeof parameters.patchText === "string") { - const pathRegex = /\*\*\* (?:Add|Delete|Update) File: ([^\n\r]+)/g - const paths: string[] = [] - let match - while ((match = pathRegex.exec(parameters.patchText)) !== null) { - paths.push(match[1].trim()) - } - if (paths.length > 0) { - const uniquePaths = [...new Set(paths)] - const count = uniquePaths.length - const plural = count > 1 ? "s" : "" - if (count === 1) return uniquePaths[0] - if (count === 2) return uniquePaths.join(", ") - return `${count} file${plural}: ${uniquePaths[0]}, ${uniquePaths[1]}...` - } - return "patch" - } - - if (tool === "list") { - return parameters.path || "(current directory)" - } - if (tool === "glob") { - if (parameters.pattern) { - const pathInfo = parameters.path ? ` in ${parameters.path}` : "" - return `"${parameters.pattern}"${pathInfo}` - } - return "(unknown pattern)" - } - if (tool === "grep") { - if (parameters.pattern) { - const pathInfo = parameters.path ? ` in ${parameters.path}` : "" - return `"${parameters.pattern}"${pathInfo}` - } - return "(unknown pattern)" - } - - if (tool === "bash") { - if (parameters.description) return parameters.description - if (parameters.command) { - return parameters.command.length > 50 - ? parameters.command.substring(0, 50) + "..." - : parameters.command - } - } - - if (tool === "webfetch" && parameters.url) { - return parameters.url - } - if (tool === "websearch" && parameters.query) { - return `"${parameters.query}"` - } - if (tool === "codesearch" && parameters.query) { - return `"${parameters.query}"` - } - - if (tool === "todowrite") { - return `${parameters.todos?.length || 0} todos` - } - if (tool === "todoread") { - return "read todo list" - } - - if (tool === "task" && parameters.description) { - return parameters.description - } - if (tool === "skill" && parameters.name) { - return parameters.name - } - - if (tool === "lsp") { - const op = parameters.operation || "lsp" - const path = parameters.filePath || "" - const line = parameters.line - const char = parameters.character - if (path && line !== undefined && char !== undefined) { - return `${op} ${path}:${line}:${char}` - } - if (path) { - return `${op} ${path}` - } - return op - } - - if (tool === "question") { - const questions = parameters.questions - if (Array.isArray(questions) && questions.length > 0) { - const headers = questions - .map((q: any) => q.header || "") - .filter(Boolean) - .slice(0, 3) - - const count = questions.length - const plural = count > 1 ? "s" : "" - - if (headers.length > 0) { - const suffix = count > 3 ? ` (+${count - 3} more)` : "" - return `${count} question${plural}: ${headers.join(", ")}${suffix}` - } - return `${count} question${plural}` - } - return "question" - } - - const paramStr = JSON.stringify(parameters) - if (paramStr === "{}" || paramStr === "[]" || paramStr === "null") { - return "" - } - return paramStr.substring(0, 50) -} - -export function buildToolIdList( - state: SessionState, - messages: WithParts[], - logger: Logger, -): string[] { +export function buildToolIdList(state: SessionState, messages: WithParts[]): string[] { const toolIds: string[] = [] for (const msg of messages) { if (isMessageCompacted(state, msg)) { @@ -301,7 +176,3 @@ 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-nudge.md b/lib/prompts/compress-nudge.md deleted file mode 100644 index b9ce567b..00000000 --- a/lib/prompts/compress-nudge.md +++ /dev/null @@ -1,10 +0,0 @@ - -CRITICAL CONTEXT LIMIT -Your session context has exceeded the configured limit. Strict adherence to context compression 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 -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/compress.md b/lib/prompts/compress.md deleted file mode 100644 index 94bc3279..00000000 --- a/lib/prompts/compress.md +++ /dev/null @@ -1,61 +0,0 @@ -Use this tool to collapse a contiguous range of conversation into a preserved summary. - -THE PHILOSOPHY OF COMPRESS -`compress` transforms verbose conversation sequences into dense, high-fidelity summaries. This is not cleanup - it is crystallization. Your summary becomes the authoritative record of what transpired. - -Think of compression as phase transitions: raw exploration becomes refined understanding. The original context served its purpose; your summary now carries that understanding forward. - -THE SUMMARY -Your summary must be EXHAUSTIVE. Capture file paths, function signatures, decisions made, constraints discovered, key findings... EVERYTHING that maintains context integrity. This is not a brief note - it is an authoritative record so faithful that the original conversation adds no value. - -When the selected range includes user messages, preserve the user's intent with extra care. Do not change scope, constraints, priorities, acceptance criteria, or requested outcomes. - -Yet be LEAN. Strip away the noise: failed attempts that led nowhere, verbose tool outputs, back-and-forth exploration. What remains should be pure signal - golden nuggets of detail that preserve full understanding with zero ambiguity. - -THE WAYS OF COMPRESS -`compress` when a chapter closes - when a phase of work is truly complete and the raw conversation has served its purpose: - -Research concluded and findings are clear -Implementation finished and verified -Exploration exhausted and patterns understood - -Do NOT compress when: -You may need exact code, error messages, or file contents from the range -Work in that area is still active or may resume -You're mid-sprint on related functionality - -Before compressing, ask: _"Is this chapter closed?"_ Compression is irreversible. The summary replaces everything in the range. - -BOUNDARY IDS -You specify boundaries by ID. - -Use the injected IDs visible in the conversation: - -- `mNNNN` IDs identify raw messages -- `bN` IDs identify previously compressed blocks - -Rules: - -- Pick `startId` and `endId` directly from injected IDs in context. -- IDs must exist in the current visible context. -- `startId` must appear before `endId`. -- Do not invent IDs. - -COMPRESSED BLOCK PLACEHOLDERS -When the selected range includes previously compressed blocks, use placeholders in this exact format: - -- `(bN)` - -Rules: - -- Include every required placeholder exactly once. -- Do not include placeholders for blocks outside the selected range. -- Treat `(bN)` placeholders as reserved tokens and only use them intentionally. -- If needed in prose, refer to a block as plain text like `compressed b3` (not as a placeholder token). - -THE FORMAT OF COMPRESS -`topic`: Short label (3-5 words) for display - e.g., "Auth System Exploration" -`content`: Object containing: -`startId`: Boundary ID marking the beginning of the range (`mNNNN` or `bN`) -`endId`: Boundary ID marking the end of the range (`mNNNN` or `bN`) -`summary`: Complete technical summary replacing all content in the range diff --git a/lib/prompts/compress.ts b/lib/prompts/compress.ts new file mode 100644 index 00000000..0f7c94f9 --- /dev/null +++ b/lib/prompts/compress.ts @@ -0,0 +1,121 @@ +export const COMPRESS = `Your only tool for context management, use it to collapse a range in the conversation into a detailed summary + +THE PHILOSOPHY OF COMPRESS +\`compress\` transforms verbose conversation sequences into dense, high-fidelity summaries. This is not cleanup - it is crystallization. Your summary becomes the authoritative record of what transpired. + +Think of compression as phase transitions: raw exploration becomes refined understanding. The original context served its purpose; your summary now carries that understanding forward. + +One method, many safe ranges: + +- short, closed ranges for disposable noise +- short, closed ranges for resolved investigative slices +- short, closed ranges for completed implementation chunks + +Default to multiple short, bounded compressions. Prefer several safe range compressions over one large sweep whenever independent ranges are available. + +CADENCE, SIGNALS, AND LATENCY +Use \`compress\` during work whenever a slice is summary-safe; do not wait for the user to send another message. + +- no fixed threshold forces compression +- prioritize closedness and independence over raw range size +- qualitative signals still matter most (stale exploration, noisy tool bursts, resolved branches) + +PREFER smaller, regular compressions OVER infrequent large compressions for better latency and better summary fidelity. + +THE SUMMARY +Your summary must be EXHAUSTIVE. Capture file paths, function signatures, decisions made, constraints discovered, key findings... EVERYTHING that maintains context integrity. This is not a brief note - it is an authoritative record so faithful that the original conversation adds no value. + +USER INTENT FIDELITY +When the compressed range includes user messages, preserve the user's intent with extra care. Do not change scope, constraints, priorities, acceptance criteria, or requested outcomes. +Directly quote user messages when they are short enough to include safely. Direct quotes are preferred when they best preserve exact meaning. + +COMPRESSED BLOCK PLACEHOLDERS +When the selected range includes previously compressed blocks, use this exact placeholder format when referencing one: + +- \`(bN)\` + +Compressed block sections in context are clearly marked with a header: + +- \`[Compressed conversation section]\` + +Compressed block IDs always use the \`bN\` form (never \`mNNNN\`) and are represented in the same XML metadata tag format. + +Rules: + +- Include every required block placeholder exactly once. +- Do not invent placeholders for blocks outside the selected range. +- Treat \`(bN)\` placeholders as RESERVED TOKENS. Do not emit \`(bN)\` text anywhere except intentional placeholders. +- If you need to mention a block in prose, use plain text like \`compressed bN\` (not as a placeholder). +- Preflight check before finalizing: the set of \`(bN)\` placeholders in your summary must exactly match the required set, with no duplicates. + +These placeholders are semantic references. They will be replaced with the full stored compressed block content when the tool processes your output. + +FLOW PRESERVATION WITH PLACEHOLDERS +When you use compressed block placeholders, write the surrounding summary text so it still reads correctly AFTER placeholder expansion. + +- Treat each placeholder as a stand-in for a full conversation segment, not as a short label. +- Ensure transitions before and after each placeholder preserve chronology and causality. +- Do not write text that depends on the placeholder staying literal (for example, "as noted in (b2)"). +- Your final meaning must be coherent once each placeholder is replaced with its full compressed block content. + +Yet be LEAN. Strip away the noise: failed attempts that led nowhere, verbose tool outputs, back-and-forth exploration. What remains should be pure signal - golden nuggets of detail that preserve full understanding with zero ambiguity. + +THE WAYS OF COMPRESS +Compress when a range is genuinely closed and the raw conversation has served its purpose: + +Research concluded and findings are clear +Implementation finished and verified +Exploration exhausted and patterns understood + +Compress smaller ranges when: +You need to discard dead-end noise without waiting for a whole chapter to close +You need to preserve key findings from a narrow slice while freeing context quickly +You can bound a stale range cleanly with injected IDs + +Do NOT compress when: +You may need exact code, error messages, or file contents from the range in the immediate next steps +Work in that area is still active or likely to resume immediately +You cannot identify reliable boundaries yet + +Before compressing, ask: _"Is this range closed enough to become summary-only right now?"_ Compression is irreversible. The summary replaces everything in the range. + +BOUNDARY IDS +You specify boundaries by ID + +Use the injected IDs visible in the conversation: + +- \`mNNNN\` IDs identify raw messages +- \`bN\` IDs identify previously compressed blocks + +Rules: + +- Pick \`startId\` and \`endId\` directly from injected IDs in context. +- IDs must exist in the current visible context. +- \`startId\` must appear before \`endId\`. +- Prefer boundaries that produce short, closed ranges. + +ID SOURCES + +- There is always an ID available for each message in XML tags like \`...\`. +- Compressed blocks are addressable by \`bN\` IDs. + +Treat \`...\` as metadata only. It is not part of the tool result semantics. + +Do not invent IDs. Use only IDs that are present in context. + +PARALLEL COMPRESS EXECUTION +When multiple independent ranges are ready and their boundaries do not overlap, launch MULTIPLE \`compress\` calls in parallel in a single response. This is the PREFERRED pattern over a single large-range compression when the work can be safely split. Run compression sequentially only when ranges overlap or when a later range depends on the result of an earlier compression. + +THE FORMAT OF COMPRESS + +\`\`\` +{ + topic: string, // Short label (3-5 words) - e.g., "Auth System Exploration" + content: { + startId: string, // Boundary ID at range start: mNNNN or bN + endId: string, // Boundary ID at range end: mNNNN or bN + summary: string // Complete technical summary replacing all content in range + } +} +\`\`\` +` diff --git a/lib/prompts/distill.md b/lib/prompts/distill.md deleted file mode 100644 index 39a78cc9..00000000 --- a/lib/prompts/distill.md +++ /dev/null @@ -1,28 +0,0 @@ -Use this tool to distill relevant findings from a selection of raw tool outputs into preserved knowledge, in order to denoise key bits and parts of context. - -THE PRUNABLE TOOLS LIST -A will show in context when outputs are available for distillation (you don't need to look for it). Each entry follows the format `ID: tool, parameter (~token usage)` (e.g., `20: read, /path/to/file.ts (~1500 tokens)`). You MUST select outputs by their numeric ID. THESE ARE YOUR ONLY VALID TARGETS. - -THE PHILOSOPHY OF DISTILLATION -`distill` is your favored instrument for transforming raw tool outputs into preserved knowledge. This is not mere summarization; it is high-fidelity extraction that makes the original output obsolete. - -Your distillation must be COMPLETE. Capture function signatures, type definitions, business logic, constraints, configuration values... EVERYTHING essential. Think of it as creating a high signal technical substitute so faithful that re-fetching the original would yield no additional value. Be thorough; be comprehensive; leave no ambiguity, ensure that your distillation stands alone, and is designed for easy retrieval and comprehension. - -AIM FOR IMPACT. Distillation is most powerful when applied to outputs that contain signal buried in noise. A single line requires no distillation; a hundred lines of API documentation do. Make sure the distillation is meaningful. - -THE WAYS OF DISTILL -`distill` when you have extracted the essence from tool outputs and the raw form has served its purpose. -Here are some examples: -EXPLORATION: You've read extensively and grasp the architecture. The original file contents are no longer needed; your understanding, synthesized, is sufficient. -PRESERVATION: Valuable technical details (signatures, logic, constraints) coexist with noise. Preserve the former; discard the latter. - -Not everything should be distilled. Prefer keeping raw outputs when: -PRECISION MATTERS: You will edit the file, grep for exact strings, or need line-accurate references. Distillation sacrifices precision for essence. -UNCERTAINTY REMAINS: If you might need to re-examine the original, defer. Distillation is irreversible; be certain before you commit. - -Before distilling, ask yourself: _"Will I need the raw output for upcoming work?"_ If you plan to edit a file you just read, keep it intact. Distillation is for completed exploration, not active work. - -THE FORMAT OF DISTILL -`targets`: Array of objects, each containing: -`id`: Numeric ID (as string) from the `` list -`distillation`: Complete technical substitute for that tool output diff --git a/lib/prompts/index.ts b/lib/prompts/index.ts index d46f3eac..baa3bb94 100644 --- a/lib/prompts/index.ts +++ b/lib/prompts/index.ts @@ -1,59 +1,30 @@ -// Generated prompts (from .md files via scripts/generate-prompts.ts) import { SYSTEM as SYSTEM_PROMPT } from "./_codegen/system.generated" -import { NUDGE } from "./_codegen/nudge.generated" -import { COMPRESS_NUDGE } from "./_codegen/compress-nudge.generated" -import { PRUNE as PRUNE_TOOL_SPEC } from "./_codegen/prune.generated" -import { DISTILL as DISTILL_TOOL_SPEC } from "./_codegen/distill.generated" -import { COMPRESS as COMPRESS_TOOL_SPEC } from "./_codegen/compress.generated" +import { NUDGE } from "./nudge" +import { COMPRESS } from "./compress" +import { SOFT_NUDGE } from "./soft-nudge" -export interface ToolFlags { - distill: boolean - compress: boolean - prune: boolean - manual: boolean -} +export { COMPRESS as COMPRESS_TOOL_SPEC } -function processConditionals(template: string, flags: ToolFlags): string { - const tools = ["distill", "compress", "prune", "manual"] as const - let result = template - // Strip comments: // ... // +export function renderSystemPrompt(manual?: boolean): string { + let result = SYSTEM_PROMPT 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) -} + if (!manual) { + const regex = new RegExp(`[\\s\\S]*?`, "g") + result = result.replace(regex, "") + } -export function renderCompressNudge(): string { - return COMPRESS_NUDGE + return result.replace(/\n([ \t]*\n)+/g, "\n\n").trim() } -const PROMPTS: Record = { - "prune-tool-spec": PRUNE_TOOL_SPEC, - "distill-tool-spec": DISTILL_TOOL_SPEC, - "compress-tool-spec": COMPRESS_TOOL_SPEC, +function extractInstruction(content: string, name: string): string { + const regex = new RegExp( + `]*>[\\s\\S]*?<\\/instruction>`, + "i", + ) + const match = content.match(regex) + return match ? match[0] : content } -export function loadPrompt(name: string, vars?: Record): string { - let content = PROMPTS[name] - if (!content) { - throw new Error(`Prompt not found: ${name}`) - } - if (vars) { - for (const [key, value] of Object.entries(vars)) { - content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), value) - } - } - return content -} +export const CONTEXT_LIMIT_NUDGE = extractInstruction(NUDGE, "context_buildup_warning") +export const SOFT_NUDGE_PROMPT = extractInstruction(SOFT_NUDGE, "post_loop_soft_nudge") diff --git a/lib/prompts/nudge.md b/lib/prompts/nudge.md deleted file mode 100644 index 4e4d8e4a..00000000 --- a/lib/prompts/nudge.md +++ /dev/null @@ -1,12 +0,0 @@ - -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 -PHASE COMPLETION: If a phase is complete, use the `compress` tool to condense the entire sequence into a detailed summary -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 - diff --git a/lib/prompts/nudge.ts b/lib/prompts/nudge.ts new file mode 100644 index 00000000..61c33609 --- /dev/null +++ b/lib/prompts/nudge.ts @@ -0,0 +1,39 @@ +export const NUDGE = ` +CRITICAL CONTEXT WARNING +Your context window is filling with tool outputs. 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, compress the smallest closed range that preserves those findings. Write a high-fidelity summary so no key technical insight is lost. + +NOISE REMOVAL: If you read files or ran commands that yielded no value, compress those dead-end ranges into explicit noise summaries and move on. + +PHASE COMPLETION: If a chapter is complete, compress the entire sequence into a detailed technical summary with unambiguous outcomes. + + + +CRITICAL WARNING: CONTEXT BUILD-UP + +The context window is filling-up. You've reached a point where performance may increasingly degrade. Active context management is now strategically relevant. Mind your context footprint as the conversation grows + +YOU MUST COMPRESS NOW TO AVOID CONTEXT ROT. THIS IS CRITICAL. Do not delay context management any further. + +DOOOOO IT!!! + +Avoid unnecessary context build-up with targeted uses of the \`compress\` tool. Start with low hanging fruits and clearly identified ranges that can be compressed with minimal risk of losing critical information. Look BACK on the conversation history and avoid compressing the newest ranges until you have exhausted older ones + +RANGE STRATEGY (MANDATORY) +Prefer multiple short, closed range compressions. +When multiple independent stale ranges are ready, batch those short compressions in parallel. +Do not jump to a single broad range when the same cleanup can be done safely with several bounded ranges. + +If you are performing a critical atomic operation, do not interrupt it, but make sure to perform context management rapidly + +Use injected boundary IDs for compression (\`mNNNN\` for messages, \`bN\` for compressed blocks). Pick IDs that are visible in context and ensure \`startId\` appears before \`endId\`. + +Ensure your summaries are inclusive of all parts of the range. +If the compressed range includes user messages, preserve user intent exactly. Prefer direct quotes for short user messages to avoid semantic drift. + +` diff --git a/lib/prompts/prune.md b/lib/prompts/prune.md deleted file mode 100644 index 97c239f4..00000000 --- a/lib/prompts/prune.md +++ /dev/null @@ -1,18 +0,0 @@ -Use this tool to remove tool outputs from context entirely. No preservation - pure deletion. - -THE PRUNABLE TOOLS LIST -A `` section surfaces in context showing outputs eligible for removal. Each line reads `ID: tool, parameter (~token usage)` (e.g., `20: read, /path/to/file.ts (~1500 tokens)`). Reference outputs by their numeric ID - these are your ONLY valid targets for pruning. - -THE WAYS OF PRUNE -`prune` is surgical deletion - eliminating noise (irrelevant or unhelpful outputs), superseded information (older outputs replaced by newer data), or wrong targets (you accessed something that turned out to be irrelevant). Use it to keep your context lean and focused. - -BATCH WISELY! Pruning is most effective when consolidated. Don't prune a single tiny output - accumulate several candidates before acting. - -Do NOT prune when: -NEEDED LATER: You plan to edit the file or reference this context for implementation. -UNCERTAINTY: If you might need to re-examine the original, keep it. - -Before pruning, ask: _"Is this noise, or will it serve me?"_ If the latter, keep it. Pruning that forces re-fetching is a net loss. - -THE FORMAT OF PRUNE -`ids`: Array of numeric IDs (as strings) from the `` list diff --git a/lib/prompts/soft-nudge.ts b/lib/prompts/soft-nudge.ts new file mode 100644 index 00000000..229bfc8b --- /dev/null +++ b/lib/prompts/soft-nudge.ts @@ -0,0 +1,10 @@ +export const SOFT_NUDGE = ` +Agentic loop completed. Evaluate the just-finished portion against the next user message. + +At this stage, prefer one or more small, closed-range compressions over one broad compression. +The goal is not to nuke current context. The goal is to filter identified noise and distill key information so context accumulation stays under control. + +If a portion is closed and unlikely to be needed again, compress it. +If a portion is still active or likely to be referenced immediately, keep it uncompressed for now. + +` diff --git a/lib/prompts/system.md b/lib/prompts/system.md index 0e020ab0..dbcaa65a 100644 --- a/lib/prompts/system.md +++ b/lib/prompts/system.md @@ -1,54 +1,58 @@ - -You operate a context-constrained environment and MUST PROACTIVELY MANAGE IT TO AVOID CONTEXT ROT. Efficient context management is CRITICAL to maintaining performance and ensuring successful task completion. + +You operate in a context-constrained environment. Manage context continuously to avoid buildup and preserve retrieval quality. Efficient context management is paramount for your agentic performance. -AVAILABLE TOOLS FOR CONTEXT MANAGEMENT -`distill`: condense key findings from tool calls into high-fidelity distillation to preserve gained insights. Use to extract valuable knowledge to the user's request. BE THOROUGH, your distillation MUST be high-signal, low noise and complete -`compress`: squash contiguous portion of the conversation and replace it with a low level technical summary. Use to filter noise from the conversation and retain purified understanding. Compress conversation phases ORGANICALLY as they get completed, think meso, not micro nor macro. Do not be cheap with that low level technical summary and BE MINDFUL of specifics that must be crystallized to retain UNAMBIGUOUS full picture. -`prune`: remove individual tool calls that are noise, irrelevant, or superseded. No preservation of content. DO NOT let irrelevant tool calls accumulate. DO NOT PRUNE TOOL OUTPUTS THAT YOU MAY NEED LATER +The ONLY tool you have for context management is `compress`. It replaces a contiguous portion of the conversation (inclusive) with a technical summary you produce. -THE DISTILL TOOL -`distill` is the favored way to target specific tools and crystalize their value into high-signal low-noise knowledge nuggets. Your distillation must be comprehensive, capturing technical details (symbols, signatures, logic, constraints) such that the raw output is no longer needed. THINK complete technical substitute. `distill` is typically best used when you are certain the raw information is not needed anymore, but the knowledge it contains is valuable to retain so you maintain context authenticity and understanding. Be conservative in your approach to distilling, but do NOT hesitate to distill when appropriate. - +OPERATING STANCE +Prefer short, closed, summary-safe ranges. +When multiple independent stale ranges exist, prefer several short compressions (in parallel when possible) over one large-range compression. -THE COMPRESS TOOL -`compress` is a sledgehammer and should be used accordingly. It's purpose is to reduce whole part of the conversation to its essence and technical details in order to leave room for newer context. Your summary MUST be technical and specific enough to preserve FULL understanding of WHAT TRANSPIRED, such that NO AMBIGUITY remains about what was done, found, or decided. Your compress summary must be thorough and precise. `compress` will replace everything in the range you match, user and assistant messages, tool inputs and outputs. It is preferred to not compress preemptively, but rather wait for natural breakpoints in the conversation. Those breakpoints are to be infered from user messages. You WILL NOT compress based on thinking that you are done with the task, wait for conversation queues that the user has moved on from current phase. Use injected boundary IDs (`startId`/`endId`) to select ranges. +Use `compress` as steady housekeeping while you work. -Injected boundary IDs are surfaced as XML tags in conversation context, e.g. `m0001` for message IDs and `b3` for compressed blocks. These IDs are internal boundary markers for `compress` only. Do not reference, explain, or surface these IDs in normal user-facing responses unless you are actively constructing a `compress` tool call. +CADENCE, SIGNALS, AND LATENCY -This tool will typically be used at the end of a phase of work, when conversation starts to accumulate noise that would better served summarized, or when you've done significant exploration and can FULLY synthesize your findings and understanding into a technical summary. +- No fixed threshold mandates compression +- Prioritize closedness and independence over raw range size +- Prefer smaller, regular compressions over infrequent massive compressions for better latency and summary quality +- When multiple independent stale ranges are ready, batch compressions in parallel -Use only injected `mNNNN`/`bN` IDs that are visible in the current context. If compressed blocks are included in your range, preserve their content through required `(bN)` placeholders in your summary. Be VERY CAREFUL AND CONSERVATIVE when using `compress`. - +BOUNDARY MATCHING +`compress` uses inclusive ID boundaries via `content.startId` and `content.endId`. IDs are injected in context as message refs (`mNNNN`) and compressed block refs (`bN`). -THE PRUNE TOOL -`prune` is your last resort for context management. It is a blunt instrument that removes tool outputs entirely, without ANY preservation. It is best used to eliminate noise, irrelevant information, or superseded outputs that no longer add value to the conversation. You MUST NOT prune tool outputs that you may need later. Prune is a targeted nuke, not a general cleanup tool. +Each message has an ID inside XML metadata tags like `...`. +Treat these tags as boundary metadata only, not as tool result content. -Contemplate only pruning when you are certain that the tool output is irrelevant to the current task or has been superseded by more recent information. If in doubt, defer for when you are definitive. Evaluate WHAT SHOULD be pruned before jumping the gun. - +Only choose IDs currently visible in context. Do not invent IDs. -TIMING -Prefer managing context at the START of a new agentic loop (after receiving a user message) rather than at the END of your previous turn. At turn start, you have fresh signal about what the user needs next - you can better judge what's still relevant versus noise from prior work. Managing at turn end means making retention decisions before knowing what comes next. +RESPECT THE CHRONOLOGY OF THE RANGE +`content.startId` MUST refer to an item above/before `content.endId` +`content.endId` MUST refer to an item below/after `content.startId` +Always provide boundaries via the tool schema fields `content.startId` and `content.endId`. -EVALUATE YOUR CONTEXT AND MANAGE REGULARLY TO AVOID CONTEXT ROT. AVOID USING MANAGEMENT TOOLS AS THE ONLY TOOL CALLS IN YOUR RESPONSE, PARALLELIZE WITH OTHER RELEVANT TOOLS TO TASK CONTINUATION (read, edit, bash...). It is imperative you understand the value or lack thereof of the context you manage and make informed decisions to maintain a decluttered, high-quality and relevant context. +THE SUMMARY STANDARD +Your summary MUST be technical and specific enough to preserve FULL understanding of what transpired, such that NO ambiguity remains about what asked, found, planned, done, or decided - yet noise free -The session is your responsibility, and effective context management is CRITICAL to your success. Be PROACTIVE, DELIBERATE, and STRATEGIC in your approach to context management. The session is your oyster - keep it clean, relevant, and high-quality to ensure optimal performance and successful task completion. +When compressing ranges that include user messages, preserve user intent faithfully. Do not reinterpret or redirect the request. Directly quote short user messages when that is the most reliable way to preserve exact meaning. -Be respectful of the user's API usage, manage context methodically as you work through the task and avoid calling ONLY context management tools in your responses. +Preserve key details: file paths, symbols, signatures, constraints, decisions, outcomes, commands, etc.. in order to produce a high fidelity, authoritative technical record + +DO NOT COMPRESS IF + +- raw context is still relevant and needed for edits or precise references +- the task in the target range is still actively in progress + +Evaluate conversation signal-to-noise REGULARLY. Use `compress` deliberately with quality-first summaries. Prefer multiple short, independent range compressions before considering broader ranges, and prioritize ranges intelligently to maintain a high-signal context window that supports your agency + +It is of your responsibility to keep a sharp, high-quality context window for optimal performance -Manual mode is enabled. Do NOT use distill, compress, or prune unless the user has explicitly triggered it through a manual marker. +Manual mode is enabled. Do NOT use compress unless the user has explicitly triggered it through a manual marker. -Only use the prune tool after seeing `` in the current user instruction context. -Only use the distill tool after seeing `` in the current user instruction context. -Only use the compress tool after seeing `` in the current user instruction context. +Only use the compress tool after seeing `` in the current user instruction context. After completing a manually triggered context-management action, STOP IMMEDIATELY. Do NOT continue with any task execution. End your response right after the tool use completes and wait for the next user input. - -This chat environment injects context information on your behalf in the form of a list to help you manage context effectively. Carefully read the list and use it to inform your management decisions. The list is automatically updated after each turn to reflect the current state of manageable tools and context usage. If no list is present, do NOT attempt to prune anything. -There may be tools in session context that do not appear in the list, this is expected, remember that you can ONLY prune what you see in list. - diff --git a/lib/state/persistence.ts b/lib/state/persistence.ts index 4652ce18..dfbcfc7e 100644 --- a/lib/state/persistence.ts +++ b/lib/state/persistence.ts @@ -25,6 +25,8 @@ export interface PersistedSessionState { sessionName?: string prune: PersistedPrune compressSummaries: CompressSummary[] + contextLimitAnchors: string[] + softNudgeAnchors?: string[] stats: SessionStats lastUpdated: string } @@ -66,6 +68,8 @@ export async function saveSessionState( messages: Object.fromEntries(sessionState.prune.messages), }, compressSummaries: sessionState.compressSummaries, + contextLimitAnchors: Array.from(sessionState.contextLimitAnchors), + softNudgeAnchors: Array.from(sessionState.softNudgeAnchors), stats: sessionState.stats, lastUpdated: new Date().toISOString(), } @@ -110,61 +114,58 @@ export async function loadSessionState( } if (Array.isArray(state.compressSummaries)) { - const migratedSummaries: CompressSummary[] = [] - let nextBlockId = 1 - - for (const entry of state.compressSummaries) { - if ( - entry === null || - typeof entry !== "object" || - typeof entry.anchorMessageId !== "string" || - typeof entry.summary !== "string" - ) { - continue - } - - const blockId = - typeof entry.blockId === "number" && Number.isInteger(entry.blockId) - ? entry.blockId - : nextBlockId - migratedSummaries.push({ - blockId, - anchorMessageId: entry.anchorMessageId, - summary: entry.summary, - }) - nextBlockId = Math.max(nextBlockId, blockId + 1) - } - - if (migratedSummaries.length !== state.compressSummaries.length) { + const validSummaries = state.compressSummaries.filter( + (s): s is CompressSummary => + s !== null && + typeof s === "object" && + typeof s.blockId === "number" && + typeof s.anchorMessageId === "string" && + typeof s.summary === "string", + ) + if (validSummaries.length !== state.compressSummaries.length) { logger.warn("Filtered out malformed compressSummaries entries", { sessionId: sessionId, original: state.compressSummaries.length, - valid: migratedSummaries.length, - }) - } - - const seenBlockIds = new Set() - const dedupedSummaries = migratedSummaries.filter((summary) => { - if (seenBlockIds.has(summary.blockId)) { - return false - } - seenBlockIds.add(summary.blockId) - return true - }) - - if (dedupedSummaries.length !== migratedSummaries.length) { - logger.warn("Removed duplicate compress block IDs", { - sessionId: sessionId, - original: migratedSummaries.length, - valid: dedupedSummaries.length, + valid: validSummaries.length, }) } - - state.compressSummaries = dedupedSummaries + state.compressSummaries = validSummaries } else { state.compressSummaries = [] } + const rawContextLimitAnchors = Array.isArray(state.contextLimitAnchors) + ? state.contextLimitAnchors + : [] + const validAnchors = rawContextLimitAnchors.filter( + (entry): entry is string => typeof entry === "string", + ) + const dedupedAnchors = [...new Set(validAnchors)] + if (validAnchors.length !== rawContextLimitAnchors.length) { + logger.warn("Filtered out malformed contextLimitAnchors entries", { + sessionId: sessionId, + original: rawContextLimitAnchors.length, + valid: validAnchors.length, + }) + } + state.contextLimitAnchors = dedupedAnchors + + const rawSoftNudgeAnchors = Array.isArray(state.softNudgeAnchors) + ? state.softNudgeAnchors + : [] + const validSoftAnchors = rawSoftNudgeAnchors.filter( + (entry): entry is string => typeof entry === "string", + ) + const dedupedSoftAnchors = [...new Set(validSoftAnchors)] + if (validSoftAnchors.length !== rawSoftNudgeAnchors.length) { + logger.warn("Filtered out malformed softNudgeAnchors entries", { + sessionId: sessionId, + original: rawSoftNudgeAnchors.length, + valid: validSoftAnchors.length, + }) + } + state.softNudgeAnchors = dedupedSoftAnchors + logger.info("Loaded session state from disk", { sessionId: sessionId, }) diff --git a/lib/state/state.ts b/lib/state/state.ts index a9f34ddf..865a3467 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -7,6 +7,7 @@ import { countTurns, resetOnCompaction, loadPruneMap, + collectSoftNudgeAnchors, } from "./utils" import { getLastUserMessage } from "../shared-utils" @@ -69,6 +70,8 @@ export function createSessionState(): SessionState { messages: new Map(), }, compressSummaries: [], + contextLimitAnchors: new Set(), + softNudgeAnchors: new Set(), stats: { pruneTokenCounter: 0, totalPruneTokens: 0, @@ -80,12 +83,11 @@ export function createSessionState(): SessionState { byRef: new Map(), nextRef: 0, }, - nudgeCounter: 0, - lastToolPrune: false, lastCompaction: 0, currentTurn: 0, variant: undefined, modelContextLimit: undefined, + systemPromptTokens: undefined, } } @@ -99,6 +101,8 @@ export function resetSessionState(state: SessionState): void { messages: new Map(), } state.compressSummaries = [] + state.contextLimitAnchors = new Set() + state.softNudgeAnchors = new Set() state.stats = { pruneTokenCounter: 0, totalPruneTokens: 0, @@ -110,12 +114,11 @@ export function resetSessionState(state: SessionState): void { byRef: new Map(), nextRef: 0, } - state.nudgeCounter = 0 - state.lastToolPrune = false state.lastCompaction = 0 state.currentTurn = 0 state.variant = undefined state.modelContextLimit = undefined + state.systemPromptTokens = undefined } export async function ensureSessionInitialized( @@ -143,6 +146,7 @@ export async function ensureSessionInitialized( state.lastCompaction = findLastCompactionTimestamp(messages) state.currentTurn = countTurns(state, messages) + state.softNudgeAnchors = collectSoftNudgeAnchors(messages) const persisted = await loadSessionState(sessionId, logger) if (persisted === null) { @@ -152,6 +156,11 @@ export async function ensureSessionInitialized( state.prune.tools = loadPruneMap(persisted.prune.tools, persisted.prune.toolIds) state.prune.messages = loadPruneMap(persisted.prune.messages, persisted.prune.messageIds) state.compressSummaries = persisted.compressSummaries || [] + state.contextLimitAnchors = new Set(persisted.contextLimitAnchors || []) + state.softNudgeAnchors = new Set([ + ...state.softNudgeAnchors, + ...(persisted.softNudgeAnchors || []), + ]) state.stats = { pruneTokenCounter: persisted.stats?.pruneTokenCounter || 0, totalPruneTokens: persisted.stats?.totalPruneTokens || 0, diff --git a/lib/state/tool-cache.ts b/lib/state/tool-cache.ts index c903b48a..0678ed2d 100644 --- a/lib/state/tool-cache.ts +++ b/lib/state/tool-cache.ts @@ -18,7 +18,6 @@ export function syncToolCache( try { logger.info("Syncing tool parameters from OpenCode messages") - state.nudgeCounter = 0 let turnCounter = 0 for (const msg of messages) { @@ -44,17 +43,6 @@ export function syncToolCache( turnProtectionTurns > 0 && state.currentTurn - turnCounter < turnProtectionTurns - if (part.tool === "distill" || part.tool === "compress" || part.tool === "prune") { - state.nudgeCounter = 0 - state.lastToolPrune = true - } else { - state.lastToolPrune = false - const allProtectedTools = config.tools.settings.protectedTools - if (!allProtectedTools.includes(part.tool) && !isProtectedByTurn) { - state.nudgeCounter++ - } - } - if (state.toolParameters.has(part.callID)) { continue } @@ -63,9 +51,7 @@ export function syncToolCache( continue } - const allProtectedTools = config.tools.settings.protectedTools - const isProtectedTool = allProtectedTools.includes(part.tool) - const tokenCount = isProtectedTool ? undefined : countToolTokens(part) + const tokenCount = countToolTokens(part) state.toolParameters.set(part.callID, { tool: part.tool, @@ -76,13 +62,13 @@ export function syncToolCache( tokenCount, }) logger.info( - `Cached tool id: ${part.callID} (turn ${turnCounter}${tokenCount !== undefined ? `, ~${tokenCount} tokens` : ""})`, + `Cached tool id: ${part.callID} (turn ${turnCounter}${tokenCount !== undefined ? `, ${tokenCount} tokens` : ""})`, ) } } logger.info( - `Synced cache - size: ${state.toolParameters.size}, currentTurn: ${state.currentTurn}, nudgeCounter: ${state.nudgeCounter}`, + `Synced cache - size: ${state.toolParameters.size}, currentTurn: ${state.currentTurn}`, ) trimToolParametersCache(state) } catch (error) { diff --git a/lib/state/types.ts b/lib/state/types.ts index 218756cc..a6b9da2e 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -50,14 +50,15 @@ export interface SessionState { pendingManualTrigger: PendingManualTrigger | null prune: Prune compressSummaries: CompressSummary[] + contextLimitAnchors: Set + softNudgeAnchors: Set stats: SessionStats toolParameters: Map toolIdList: string[] messageIds: MessageIdState - nudgeCounter: number - lastToolPrune: boolean lastCompaction: number currentTurn: number variant: string | undefined modelContextLimit: number | undefined + systemPromptTokens: number | undefined } diff --git a/lib/state/utils.ts b/lib/state/utils.ts index f5e0918b..9a7304d0 100644 --- a/lib/state/utils.ts +++ b/lib/state/utils.ts @@ -1,5 +1,6 @@ import type { SessionState, WithParts } from "./types" import { isMessageCompacted } from "../shared-utils" +import { isIgnoredUserMessage } from "../messages/utils" export async function isSubAgentSession(client: any, sessionID: string): Promise { try { @@ -45,16 +46,50 @@ export function loadPruneMap( return new Map() } +function hasCompletedCompress(message: WithParts): boolean { + if (message.info.role !== "assistant") { + return false + } + + const parts = Array.isArray(message.parts) ? message.parts : [] + return parts.some( + (part) => + part.type === "tool" && part.tool === "compress" && part.state?.status === "completed", + ) +} + +export function collectSoftNudgeAnchors(messages: WithParts[]): Set { + const anchors = new Set() + let pendingUserMessage = false + + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + + if (hasCompletedCompress(message)) { + break + } + + if (message.info.role === "user") { + if (!isIgnoredUserMessage(message)) { + pendingUserMessage = true + } + continue + } + + if (message.info.role === "assistant" && pendingUserMessage) { + anchors.add(message.info.id) + pendingUserMessage = false + } + } + + return anchors +} + export function resetOnCompaction(state: SessionState): void { state.toolParameters.clear() state.prune.tools = new Map() state.prune.messages = new Map() state.compressSummaries = [] - state.messageIds = { - byRawId: new Map(), - byRef: new Map(), - nextRef: 0, - } - state.nudgeCounter = 0 - state.lastToolPrune = false + state.contextLimitAnchors = new Set() + state.softNudgeAnchors = new Set() } diff --git a/lib/strategies/index.ts b/lib/strategies/index.ts index e0680e6b..f8922df9 100644 --- a/lib/strategies/index.ts +++ b/lib/strategies/index.ts @@ -1,4 +1,3 @@ export { deduplicate } from "./deduplication" -export { createPruneTool, createDistillTool, createCompressTool } from "../tools" export { supersedeWrites } from "./supersede-writes" export { purgeErrors } from "./purge-errors" diff --git a/lib/strategies/utils.ts b/lib/strategies/utils.ts index a5b31b97..f9b8c9af 100644 --- a/lib/strategies/utils.ts +++ b/lib/strategies/utils.ts @@ -133,3 +133,17 @@ export function countMessageTextTokens(msg: WithParts): number { if (texts.length === 0) return 0 return estimateTokensBatch(texts) } + +export function countAllMessageTokens(msg: WithParts): number { + const parts = Array.isArray(msg.parts) ? msg.parts : [] + const texts: string[] = [] + for (const part of parts) { + if (part.type === "text") { + texts.push(part.text) + } else { + texts.push(...extractToolContent(part)) + } + } + if (texts.length === 0) return 0 + return estimateTokensBatch(texts) +} diff --git a/lib/tools/compress-utils.ts b/lib/tools/compress-utils.ts index 11c75f93..b1aea39f 100644 --- a/lib/tools/compress-utils.ts +++ b/lib/tools/compress-utils.ts @@ -1,7 +1,7 @@ import type { SessionState, WithParts, CompressSummary } from "../state" import { formatBlockRef, formatMessageIdTag, parseBoundaryId } from "../message-ids" import { isIgnoredUserMessage } from "../messages/utils" -import { countMessageTextTokens } from "../strategies/utils" +import { countAllMessageTokens } from "../strategies/utils" const BLOCK_PLACEHOLDER_REGEX = /\(b(\d+)\)|\{block_(\d+)\}/gi @@ -50,13 +50,12 @@ export interface InjectedSummaryResult { consumedBlockIds: number[] } -export function formatCompressedBlockHeader(_blockId: number): string { - return "[Compressed conversation section]" +export interface AppliedCompressionResult { + compressedTokens: number + messageIds: string[] } -export function formatCompressedBlockFooter(blockId: number): string { - return formatMessageIdTag(formatBlockRef(blockId)) -} +export const COMPRESSED_BLOCK_HEADER = "[Compressed conversation section]" export function formatBlockPlaceholder(blockId: number): string { return `(b${blockId})` @@ -139,21 +138,22 @@ export function resolveBoundaryIds( throwCombinedIssues(issues) } - const validStartId = parsedStartId as NonNullable - const validEndId = parsedEndId as NonNullable + if (!parsedStartId || !parsedEndId) { + throw new Error("Invalid boundary ID(s)") + } - const startReference = lookup.get(validStartId.ref) - const endReference = lookup.get(validEndId.ref) + const startReference = lookup.get(parsedStartId.ref) + const endReference = lookup.get(parsedEndId.ref) if (!startReference) { issues.push( - `startId ${validStartId.ref} is not available in the current conversation context. Choose an injected ID visible in context.`, + `startId ${parsedStartId.ref} is not available in the current conversation context. Choose an injected ID visible in context.`, ) } if (!endReference) { issues.push( - `endId ${validEndId.ref} is not available in the current conversation context. Choose an injected ID visible in context.`, + `endId ${parsedEndId.ref} is not available in the current conversation context. Choose an injected ID visible in context.`, ) } @@ -167,7 +167,7 @@ export function resolveBoundaryIds( if (startReference.rawIndex > endReference.rawIndex) { throw new Error( - `startId ${validStartId.ref} appears after endId ${validEndId.ref} in the conversation. Start must come before end.`, + `startId ${parsedStartId.ref} appears after endId ${parsedEndId.ref} in the conversation. Start must come before end.`, ) } @@ -261,7 +261,7 @@ export function resolveRange( } if (!messageTokenById.has(messageId)) { - messageTokenById.set(messageId, countMessageTextTokens(rawMessage)) + messageTokenById.set(messageId, countAllMessageTokens(rawMessage)) } const parts = Array.isArray(rawMessage.parts) ? rawMessage.parts : [] @@ -422,9 +422,7 @@ export function validateSummaryPlaceholders( if (duplicateIds.size > 0) { issues.push( - `Duplicate block placeholders are not allowed: ${[...duplicateIds] - .map(formatBlockPlaceholder) - .join(", ")}`, + `Duplicate block placeholders are not allowed: ${[...duplicateIds].map(formatBlockPlaceholder).join(", ")}`, ) } @@ -505,9 +503,9 @@ export function allocateBlockId(summaries: CompressSummary[]): number { return max + 1 } -export function addCompressedBlockHeader(blockId: number, summary: string): string { - const header = formatCompressedBlockHeader(blockId) - const footer = formatCompressedBlockFooter(blockId) +export function wrapCompressedSummary(blockId: number, summary: string): string { + const header = COMPRESSED_BLOCK_HEADER + const footer = formatMessageIdTag(formatBlockRef(blockId)) const body = summary.trim() if (body.length === 0) { return `${header}\n${footer}` @@ -515,21 +513,43 @@ export function addCompressedBlockHeader(blockId: number, summary: string): stri return `${header}\n${body}\n\n${footer}` } -export function upsertCompressedSummary( - summaries: CompressSummary[], - blockId: number, +export function applyCompressionState( + state: SessionState, + range: RangeResolution, anchorMessageId: string, + blockId: number, summary: string, consumedBlockIds: number[], -): CompressSummary[] { +): AppliedCompressionResult { const consumed = new Set(consumedBlockIds) - const next = summaries.filter((s) => !consumed.has(s.blockId)) - next.push({ + state.compressSummaries = (state.compressSummaries || []).filter( + (s) => !consumed.has(s.blockId), + ) + state.compressSummaries.push({ blockId, anchorMessageId, summary, }) - return next + + let compressedTokens = 0 + for (const messageId of range.messageIds) { + if (state.prune.messages.has(messageId)) { + continue + } + + const tokenCount = range.messageTokenById.get(messageId) || 0 + state.prune.messages.set(messageId, tokenCount) + compressedTokens += tokenCount + } + + state.stats.pruneTokenCounter += compressedTokens + state.stats.totalPruneTokens += state.stats.pruneTokenCounter + state.stats.pruneTokenCounter = 0 + + return { + compressedTokens, + messageIds: range.messageIds, + } } function restoreStoredCompressedSummary(summary: string): string { diff --git a/lib/tools/compress.ts b/lib/tools/compress.ts index 9408642b..b462ddee 100644 --- a/lib/tools/compress.ts +++ b/lib/tools/compress.ts @@ -1,32 +1,30 @@ import { tool } from "@opencode-ai/plugin" -import type { BoundaryReference, CompressToolArgs } from "./compress-utils" -import type { PruneToolContext } from "./types" +import type { ToolContext } from "./types" +import { COMPRESS_TOOL_SPEC } from "../prompts" import { ensureSessionInitialized } from "../state" -import { saveSessionState } from "../state/persistence" -import { loadPrompt } from "../prompts" -import { getCurrentParams, getTotalToolTokens } from "../strategies/utils" -import { sendCompressNotification } from "../ui/notification" -import { assignMessageRefs } from "../message-ids" import { - addCompressedBlockHeader, + wrapCompressedSummary, allocateBlockId, + applyCompressionState, buildSearchContext, fetchSessionMessages, + COMPRESSED_BLOCK_HEADER, injectBlockPlaceholders, parseBlockPlaceholders, resolveAnchorMessageId, resolveBoundaryIds, resolveRange, - upsertCompressedSummary, validateCompressArgs, validateSummaryPlaceholders, + type CompressToolArgs, } from "./compress-utils" +import { getCurrentParams, getCurrentTokenUsage, countTokens } from "../strategies/utils" +import { saveSessionState } from "../state/persistence" +import { sendCompressNotification } from "../ui/notification" -const COMPRESS_TOOL_DESCRIPTION = loadPrompt("compress-tool-spec") - -export function createCompressTool(ctx: PruneToolContext): ReturnType { +export function createCompressTool(ctx: ToolContext): ReturnType { return tool({ - description: COMPRESS_TOOL_DESCRIPTION, + description: COMPRESS_TOOL_SPEC, args: { topic: tool.schema .string() @@ -48,9 +46,6 @@ export function createCompressTool(ctx: PruneToolContext): ReturnType !state.prune.messages.has(id), - ) - let textTokens = 0 - for (const messageId of compressedMessageIds) { - const tokenCount = range.messageTokenById.get(messageId) || 0 - textTokens += tokenCount - state.prune.messages.set(messageId, tokenCount) - } - - const compressedToolIds = range.toolIds.filter((id) => !state.prune.tools.has(id)) - const toolTokens = getTotalToolTokens(state, compressedToolIds) - for (const id of compressedToolIds) { - const entry = state.toolParameters.get(id) - state.prune.tools.set(id, entry?.tokenCount ?? 0) - } - - const estimatedCompressedTokens = textTokens + toolTokens - state.stats.pruneTokenCounter += estimatedCompressedTokens - - const rawStartResult = { - messageId: getBoundaryMessageId(startReference), - messageIndex: startReference.rawIndex, - } - const rawEndResult = { - messageId: getBoundaryMessageId(endReference), - messageIndex: endReference.rawIndex, - } - - const currentParams = getCurrentParams(state, rawMessages, logger) + await saveSessionState(ctx.state, ctx.logger) + + const params = getCurrentParams(ctx.state, rawMessages, ctx.logger) + const totalSessionTokens = getCurrentTokenUsage(rawMessages) + const sessionMessageIds = rawMessages.map((msg) => msg.info.id) + await sendCompressNotification( - client, - logger, + ctx.client, + ctx.logger, ctx.config, - state, - sessionId, - compressedToolIds, - compressedMessageIds, + ctx.state, + toolCtx.sessionID, + range.toolIds, + applied.messageIds, compressArgs.topic, injected.expandedSummary, - rawStartResult, - rawEndResult, + summaryTokens, + totalSessionTokens, + applied.compressedTokens, + sessionMessageIds, rawMessages.length, - currentParams, + params, ) - state.stats.totalPruneTokens += state.stats.pruneTokenCounter - state.stats.pruneTokenCounter = 0 - state.nudgeCounter = 0 - - await saveSessionState(state, logger) - - return `Compressed ${compressedMessageIds.length} messages (${compressedToolIds.length} tool calls) into summary. The content will be replaced with your summary.` + return `Compressed ${applied.messageIds.length} messages into ${COMPRESSED_BLOCK_HEADER}.` }, }) } - -function getBoundaryMessageId(reference: BoundaryReference): string { - if (reference.kind === "message") { - if (!reference.messageId) { - throw new Error("Failed to map boundary matches back to raw messages") - } - return reference.messageId - } - - if (!reference.anchorMessageId) { - throw new Error("Failed to map boundary matches back to raw messages") - } - return reference.anchorMessageId -} diff --git a/lib/tools/distill.ts b/lib/tools/distill.ts deleted file mode 100644 index b67d56b4..00000000 --- a/lib/tools/distill.ts +++ /dev/null @@ -1,60 +0,0 @@ -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: { - targets: tool.schema - .array( - tool.schema.object({ - id: tool.schema - .string() - .describe("Numeric ID from the list"), - distillation: tool.schema - .string() - .describe("Complete technical distillation for this tool output"), - }), - ) - .describe("Tool outputs to distill, each pairing an ID with its distillation"), - }, - async execute(args, toolCtx) { - if (!args.targets || !Array.isArray(args.targets) || args.targets.length === 0) { - ctx.logger.debug("Distill tool called without targets: " + JSON.stringify(args)) - throw new Error("Missing targets. Provide at least one { id, distillation } entry.") - } - - for (const target of args.targets) { - if (!target.id || typeof target.id !== "string" || target.id.trim() === "") { - ctx.logger.debug("Distill target missing id: " + JSON.stringify(target)) - throw new Error( - "Each target must have an id (numeric string from ).", - ) - } - if (!target.distillation || typeof target.distillation !== "string") { - ctx.logger.debug( - "Distill target missing distillation: " + JSON.stringify(target), - ) - throw new Error("Each target must have a distillation string.") - } - } - - const ids = args.targets.map((t) => t.id) - const distillations = args.targets.map((t) => t.distillation) - - return executePruneOperation( - ctx, - toolCtx, - ids, - "extraction" as PruneReason, - "Distill", - distillations, - ) - }, - }) -} diff --git a/lib/tools/index.ts b/lib/tools/index.ts index 32a5e9c8..77f3cfcf 100644 --- a/lib/tools/index.ts +++ b/lib/tools/index.ts @@ -1,4 +1,2 @@ -export { PruneToolContext } from "./types" -export { createPruneTool } from "./prune" -export { createDistillTool } from "./distill" +export { ToolContext } from "./types" export { createCompressTool } from "./compress" diff --git a/lib/tools/prune-shared.ts b/lib/tools/prune-shared.ts deleted file mode 100644 index 6555c4ae..00000000 --- a/lib/tools/prune-shared.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { SessionState, ToolParameterEntry, WithParts } from "../state" -import type { PluginConfig } from "../config" -import type { Logger } from "../logger" -import type { PruneToolContext } from "./types" -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 { getTotalToolTokens, getCurrentParams } from "../strategies/utils" -import { getFilePathsFromParameters, isProtected } from "../protected-file-patterns" -import { buildToolIdList } from "../messages/utils" - -// 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 - - // These 3 are probably not needed as they should always be set in the message - // transform handler, but in case something causes state to reset, this is a safety net - await ensureSessionInitialized( - ctx.client, - state, - sessionId, - logger, - messages, - config.manualMode.enabled, - ) - syncToolCache(state, config, logger, messages) - buildToolIdList(state, messages, logger) - - const currentParams = getCurrentParams(state, messages, logger) - - const toolIdList = state.toolIdList - - const validNumericIds: number[] = [] - const skippedIds: string[] = [] - - // Validate and filter IDs - for (const index of numericToolIds) { - // Validate that index is 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 filePaths = getFilePathsFromParameters(metadata.tool, metadata.parameters) - if (isProtected(filePaths, config.protectedFilePatterns)) { - logger.debug("Rejecting prune request - protected file path", { - index, - id, - tool: metadata.tool, - filePaths, - }) - 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]) - for (const id of pruneToolIds) { - const entry = state.toolParameters.get(id) - state.prune.tools.set(id, entry?.tokenCount ?? 0) - } - - 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 += getTotalToolTokens(state, 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 deleted file mode 100644 index 17065aa9..00000000 --- a/lib/tools/prune.ts +++ /dev/null @@ -1,36 +0,0 @@ -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()) - .describe("Numeric IDs as strings from the list to prune"), - }, - async execute(args, toolCtx) { - if (!args.ids || !Array.isArray(args.ids) || args.ids.length === 0) { - ctx.logger.debug("Prune tool called without ids: " + JSON.stringify(args)) - throw new Error("Missing ids. You must provide at least one ID to prune.") - } - - if (!args.ids.every((id) => typeof id === "string" && id.trim() !== "")) { - ctx.logger.debug("Prune tool called with invalid ids: " + JSON.stringify(args)) - throw new Error( - 'Invalid ids. All IDs must be numeric strings (e.g., "1", "23") from the list.', - ) - } - - 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 index c4950e47..277618bb 100644 --- a/lib/tools/types.ts +++ b/lib/tools/types.ts @@ -2,7 +2,7 @@ import type { SessionState } from "../state" import type { PluginConfig } from "../config" import type { Logger } from "../logger" -export interface PruneToolContext { +export interface ToolContext { client: any state: SessionState logger: Logger diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index 9d628175..42084fad 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -1,15 +1,14 @@ import type { Logger } from "../logger" import type { SessionState } from "../state" import { - countDistillationTokens, - formatExtracted, formatPrunedItemsList, + formatSessionMap, formatStatsHeader, formatTokenCount, - formatProgressBar, } from "./utils" import { ToolParameterEntry } from "../state" import { PluginConfig } from "../config" +import { clog, C } from "../compress-logger" export type PruneReason = "completion" | "noise" | "extraction" export const PRUNE_REASON_LABELS: Record = { @@ -18,22 +17,12 @@ export const PRUNE_REASON_LABELS: Record = { extraction: "Extraction", } -function buildMinimalMessage( - state: SessionState, - reason: PruneReason | undefined, - distillation: string[] | undefined, - showDistillation: boolean, -): string { - const extractedTokens = countDistillationTokens(distillation) - const extractedSuffix = - extractedTokens > 0 ? ` (distilled ${formatTokenCount(extractedTokens)})` : "" - const reasonSuffix = reason && extractedTokens === 0 ? ` — ${PRUNE_REASON_LABELS[reason]}` : "" - let message = +function buildMinimalMessage(state: SessionState, reason: PruneReason | undefined): string { + const reasonSuffix = reason ? ` — ${PRUNE_REASON_LABELS[reason]}` : "" + return ( formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter) + - reasonSuffix + - extractedSuffix - - return message + formatExtracted(showDistillation ? distillation : undefined) + reasonSuffix + ) } function buildDetailedMessage( @@ -42,25 +31,19 @@ function buildDetailedMessage( pruneToolIds: string[], toolMetadata: Map, workingDirectory: string, - distillation: string[] | undefined, - showDistillation: boolean, ): string { let message = formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter) if (pruneToolIds.length > 0) { const pruneTokenCounterStr = `~${formatTokenCount(state.stats.pruneTokenCounter)}` - const extractedTokens = countDistillationTokens(distillation) - const extractedSuffix = - extractedTokens > 0 ? `, distilled ${formatTokenCount(extractedTokens)}` : "" - const reasonLabel = - reason && extractedTokens === 0 ? ` — ${PRUNE_REASON_LABELS[reason]}` : "" - message += `\n\n▣ Pruning (${pruneTokenCounterStr}${extractedSuffix})${reasonLabel}` + const reasonLabel = reason ? ` — ${PRUNE_REASON_LABELS[reason]}` : "" + message += `\n\n▣ Pruning (${pruneTokenCounterStr})${reasonLabel}` const itemLines = formatPrunedItemsList(pruneToolIds, toolMetadata, workingDirectory) message += "\n" + itemLines.join("\n") } - return (message + formatExtracted(showDistillation ? distillation : undefined)).trim() + return message.trim() } const TOAST_BODY_MAX_LINES = 12 @@ -110,7 +93,6 @@ export async function sendUnifiedNotification( reason: PruneReason | undefined, params: any, workingDirectory: string, - distillation?: string[], ): Promise { const hasPruned = pruneToolIds.length > 0 if (!hasPruned) { @@ -121,20 +103,10 @@ export async function sendUnifiedNotification( return false } - const showDistillation = config.tools.distill.showDistillation - const message = config.pruneNotification === "minimal" - ? buildMinimalMessage(state, reason, distillation, showDistillation) - : buildDetailedMessage( - state, - reason, - pruneToolIds, - toolMetadata, - workingDirectory, - distillation, - showDistillation, - ) + ? buildMinimalMessage(state, reason) + : buildDetailedMessage(state, reason, pruneToolIds, toolMetadata, workingDirectory) if (config.pruneNotificationType === "toast") { let toastMessage = truncateExtractedSection(message) @@ -143,7 +115,7 @@ export async function sendUnifiedNotification( await client.tui.showToast({ body: { - title: "DCP: Prune Notification", + title: "DCP: Compress Notification", message: toastMessage, variant: "info", duration: 5000, @@ -166,8 +138,10 @@ export async function sendCompressNotification( messageIds: string[], topic: string, summary: string, - startResult: any, - endResult: any, + summaryTokens: number, + totalSessionTokens: number, + compressedTokens: number, + sessionMessageIds: string[], totalMessages: number, params: any, ): Promise { @@ -176,20 +150,29 @@ export async function sendCompressNotification( } let message: string + const summaryTokensStr = formatTokenCount(summaryTokens) 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}` + const pruneTokenCounterStr = `~${formatTokenCount(compressedTokens)}` + + clog.info(C.COMPRESS, `sendCompressNotification inputs`, { + summaryTokens, + totalSessionTokens, + compressedTokens, + ratio: + totalSessionTokens > 0 ? (compressedTokens / totalSessionTokens).toFixed(4) : "N/A", + }) + + const progressBar = formatSessionMap(sessionMessageIds, state.prune.messages, 50) + const reduction = + totalSessionTokens > 0 ? Math.round((compressedTokens / totalSessionTokens) * 100) : 0 + + message += `\n\n▣ Compressing (${pruneTokenCounterStr} removed, ${reduction}% reduction)` + message += `\n${progressBar}` message += `\n→ Topic: ${topic}` message += `\n→ Items: ${messageIds.length} messages` if (toolIds.length > 0) { @@ -198,7 +181,7 @@ export async function sendCompressNotification( message += ` condensed` } if (config.tools.compress.showCompression) { - message += `\n→ Compression: ${summary}` + message += `\n→ Compression (~${summaryTokensStr}): ${summary}` } } @@ -208,8 +191,8 @@ export async function sendCompressNotification( const truncatedSummary = truncateToastSummary(summary) if (truncatedSummary !== summary) { toastMessage = toastMessage.replace( - `\n→ Compression: ${summary}`, - `\n→ Compression: ${truncatedSummary}`, + `\n→ Compression (~${summaryTokensStr}): ${summary}`, + `\n→ Compression (~${summaryTokensStr}): ${truncatedSummary}`, ) } } diff --git a/lib/ui/utils.ts b/lib/ui/utils.ts index 2f6fc754..e2e54836 100644 --- a/lib/ui/utils.ts +++ b/lib/ui/utils.ts @@ -1,21 +1,140 @@ -import { ToolParameterEntry } from "../state" -import { extractParameterKey } from "../messages/utils" +import { SessionState, ToolParameterEntry, WithParts } from "../state" import { countTokens } from "../strategies/utils" +import { isIgnoredUserMessage } from "../messages/utils" -export function countDistillationTokens(distillation?: string[]): number { - if (!distillation || distillation.length === 0) return 0 - return countTokens(distillation.join("\n")) -} +function extractParameterKey(tool: string, parameters: any): string { + if (!parameters) return "" -export function formatExtracted(distillation?: string[]): string { - if (!distillation || distillation.length === 0) { - return "" + if (tool === "read" && parameters.filePath) { + const offset = parameters.offset + const limit = parameters.limit + if (offset !== undefined && limit !== undefined) { + return `${parameters.filePath} (lines ${offset}-${offset + limit})` + } + if (offset !== undefined) { + return `${parameters.filePath} (lines ${offset}+)` + } + if (limit !== undefined) { + return `${parameters.filePath} (lines 0-${limit})` + } + return parameters.filePath + } + + if ((tool === "write" || tool === "edit" || tool === "multiedit") && parameters.filePath) { + return parameters.filePath + } + + if (tool === "apply_patch" && typeof parameters.patchText === "string") { + const pathRegex = /\*\*\* (?:Add|Delete|Update) File: ([^\n\r]+)/g + const paths: string[] = [] + let match + while ((match = pathRegex.exec(parameters.patchText)) !== null) { + paths.push(match[1].trim()) + } + if (paths.length > 0) { + const uniquePaths = [...new Set(paths)] + const count = uniquePaths.length + const plural = count > 1 ? "s" : "" + if (count === 1) return uniquePaths[0] + if (count === 2) return uniquePaths.join(", ") + return `${count} file${plural}: ${uniquePaths[0]}, ${uniquePaths[1]}...` + } + return "patch" + } + + if (tool === "list") { + return parameters.path || "(current directory)" + } + + if (tool === "glob") { + if (parameters.pattern) { + const pathInfo = parameters.path ? ` in ${parameters.path}` : "" + return `"${parameters.pattern}"${pathInfo}` + } + return "(unknown pattern)" + } + + if (tool === "grep") { + if (parameters.pattern) { + const pathInfo = parameters.path ? ` in ${parameters.path}` : "" + return `"${parameters.pattern}"${pathInfo}` + } + return "(unknown pattern)" } - let result = `\n\n▣ Extracted` - for (const finding of distillation) { - result += `\n───\n${finding}` + + if (tool === "bash") { + if (parameters.description) return parameters.description + if (parameters.command) { + return parameters.command.length > 50 + ? parameters.command.substring(0, 50) + "..." + : parameters.command + } + } + + if (tool === "webfetch" && parameters.url) { + return parameters.url + } + if (tool === "websearch" && parameters.query) { + return `"${parameters.query}"` + } + if (tool === "codesearch" && parameters.query) { + return `"${parameters.query}"` + } + + if (tool === "todowrite") { + return `${parameters.todos?.length || 0} todos` + } + if (tool === "todoread") { + return "read todo list" + } + + if (tool === "task" && parameters.description) { + return parameters.description } - return result + if (tool === "skill" && parameters.name) { + return parameters.name + } + + if (tool === "lsp") { + const op = parameters.operation || "lsp" + const path = parameters.filePath || "" + const line = parameters.line + const char = parameters.character + if (path && line !== undefined && char !== undefined) { + return `${op} ${path}:${line}:${char}` + } + if (path) { + return `${op} ${path}` + } + return op + } + + if (tool === "question") { + const questions = parameters.questions + if (Array.isArray(questions) && questions.length > 0) { + const headers = questions + .map((q: any) => q.header || "") + .filter(Boolean) + .slice(0, 3) + + const count = questions.length + const plural = count > 1 ? "s" : "" + + if (headers.length > 0) { + const suffix = count > 3 ? ` (+${count - 3} more)` : "" + return `${count} question${plural}: ${headers.join(", ")}${suffix}` + } + return `${count} question${plural}` + } + return "question" + } + + const paramStr = JSON.stringify(parameters) + if (paramStr === "{}" || paramStr === "[]" || paramStr === "null") { + return "" + } + + return paramStr.substring(0, 50) } export function formatStatsHeader(totalTokensSaved: number, pruneTokenCounter: number): string { @@ -35,27 +154,65 @@ 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, +export function formatSessionMap( + messageIds: string[], + prunedMessages: Map, + width: number = 50, ): string { - if (total <= 0) return `│${" ".repeat(width)}│` + const total = messageIds.length + if (total === 0) return `│${"░".repeat(width)}│` - const startIdx = Math.floor((start / total) * width) - const endIdx = Math.min(width - 1, Math.floor((end / total) * width)) + const bar = new Array(width).fill("█") - let bar = "" - for (let i = 0; i < width; i++) { - if (i >= startIdx && i <= endIdx) { - bar += "░" - } else { - bar += "█" + for (let m = 0; m < total; m++) { + if (prunedMessages.has(messageIds[m])) { + const start = Math.floor((m / total) * width) + const end = Math.floor(((m + 1) / total) * width) + for (let i = start; i < end; i++) { + bar[i] = "░" + } + } + } + + return `│${bar.join("")}│` +} + +export function cacheSystemPromptTokens(state: SessionState, messages: WithParts[]): void { + let firstInputTokens = 0 + for (const msg of messages) { + if (msg.info.role !== "assistant") { + continue + } + const info = msg.info as any + const input = info?.tokens?.input || 0 + const cacheRead = info?.tokens?.cache?.read || 0 + if (input > 0 || cacheRead > 0) { + firstInputTokens = input + cacheRead + break + } + } + + if (firstInputTokens <= 0) { + state.systemPromptTokens = undefined + return + } + + let firstUserText = "" + for (const msg of messages) { + if (msg.info.role !== "user" || isIgnoredUserMessage(msg)) { + continue + } + const parts = Array.isArray(msg.parts) ? msg.parts : [] + for (const part of parts) { + if (part.type === "text" && !(part as any).ignored) { + firstUserText += part.text + } } + break } - return `│${bar}│` + const estimatedSystemTokens = Math.max(0, firstInputTokens - countTokens(firstUserText)) + state.systemPromptTokens = estimatedSystemTokens > 0 ? estimatedSystemTokens : undefined } export function shortenPath(input: string, workingDirectory?: string): string { diff --git a/package-lock.json b/package-lock.json index df1af712..d93b2f4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "@tarquinen/opencode-dcp", - "version": "2.1.6", + "version": "2.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "2.1.6", + "version": "2.1.3", "license": "AGPL-3.0-or-later", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", "@opencode-ai/sdk": "^1.1.48", + "fuzzball": "^2.2.3", "jsonc-parser": "^3.3.1", "zod": "^4.3.6" }, @@ -586,6 +587,17 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fuzzball": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/fuzzball/-/fuzzball-2.2.3.tgz", + "integrity": "sha512-sQDb3kjI7auA4YyE1YgEW85MTparcSgRgcCweUK06Cn0niY5lN+uhFiRUZKN4MQVGGiHxlbrYCA4nL1QjOXBLQ==", + "license": "MIT", + "dependencies": { + "heap": ">=0.2.0", + "lodash": "^4.17.21", + "setimmediate": "^1.0.5" + } + }, "node_modules/get-tsconfig": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", @@ -599,12 +611,24 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/heap": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz", + "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==", + "license": "MIT" + }, "node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "license": "MIT" }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/prettier": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", @@ -631,6 +655,12 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/tiktoken": { "version": "1.0.22", "resolved": "https://registry.npmjs.org/tiktoken/-/tiktoken-1.0.22.tgz", diff --git a/package.json b/package.json index 0856b443..6c1d4342 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@tarquinen/opencode-dcp", - "version": "2.1.6", + "version": "2.1.3", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js", @@ -44,6 +44,7 @@ "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", "@opencode-ai/sdk": "^1.1.48", + "fuzzball": "^2.2.3", "jsonc-parser": "^3.3.1", "zod": "^4.3.6" }, diff --git a/scripts/README.md b/scripts/README.md index a99c256b..9ca2bf24 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,41 +1,32 @@ # DCP CLI -Dev tool for previewing prompt outputs. Verify parsing works correctly and quickly check specific tool combinations. +Dev tool for previewing prompt outputs. ## Usage ```bash -bun run dcp [TYPE] [-p] [-d] [-c] +bun run dcp [TYPE] ``` ## 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. +| Flag | Description | +| -------------------- | ---------------------------------------- | +| `--system` | System prompt | +| `--nudge` | Standard nudge prompt | +| `--compress-nudge` | Context-limit compress nudge | +| `--context-tools` | Example `` block | +| `--compress-context` | Example `` block | +| `--cooldown` | Cooldown context-info block | ## 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 +bun run dcp --system +bun run dcp --nudge +bun run dcp --context-tools ``` ## 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. +This CLI does not ship with the plugin. It is for local DX while iterating on injected prompts. diff --git a/scripts/generate-prompts.ts b/scripts/generate-prompts.ts index e83ffe75..48ac59e6 100644 --- a/scripts/generate-prompts.ts +++ b/scripts/generate-prompts.ts @@ -5,8 +5,8 @@ * 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. + * Only system.md remains markdown-backed because it includes optional manual + * sections that are conditionally rendered. Other prompts are plain .ts files. */ import { readFileSync, writeFileSync, readdirSync, mkdirSync, unlinkSync } from "node:fs" @@ -27,8 +27,18 @@ for (const file of oldGeneratedFiles) { console.log(`Cleaned up old: ${file}`) } -// Find all .md files in the prompts directory -const mdFiles = readdirSync(PROMPTS_DIR).filter((f) => f.endsWith(".md")) +const SYSTEM_PROMPT_FILE = "system.md" +const mdFiles = [SYSTEM_PROMPT_FILE] + +// Remove stale generated files in _codegen that are no longer generated +const expectedGenerated = new Set([`${basename(SYSTEM_PROMPT_FILE, ".md")}.generated.ts`]) +const existingGenerated = readdirSync(CODEGEN_DIR).filter((f) => f.endsWith(".generated.ts")) +for (const file of existingGenerated) { + if (!expectedGenerated.has(file)) { + unlinkSync(join(CODEGEN_DIR, file)) + console.log(`Removed stale: ${file}`) + } +} for (const mdFile of mdFiles) { const mdPath = join(PROMPTS_DIR, mdFile) diff --git a/scripts/opencode-dcp-stats b/scripts/opencode-dcp-stats index ab0059d5..44a4322f 100755 --- a/scripts/opencode-dcp-stats +++ b/scripts/opencode-dcp-stats @@ -8,15 +8,16 @@ Usage: opencode-dcp-stats [--sessions N] [--min-messages M] [--json] [--verbose] import json import argparse -from pathlib import Path from datetime import datetime from collections import defaultdict from typing import Optional -# DCP tool names (across different plugin versions) +from opencode_api import APIError, add_api_arguments, create_client_from_args, list_sessions_across_projects + +# DCP tool names across versions (compress is canonical; others are legacy aliases) DCP_TOOLS = { - "prune", "discard", "extract", "context_pruning", - "squash", "compress", "consolidate", "distill" + "compress", "prune", "distill", + "discard", "extract", "context_pruning", "squash", "consolidate" } # Anthropic pricing: cache read is ~10% of input cost @@ -24,42 +25,9 @@ CACHE_READ_COST_PER_1K = 0.00030 # $0.30 per 1M tokens INPUT_COST_PER_1K = 0.003 # $3.00 per 1M tokens -def get_session_messages(storage: Path, session_id: str) -> list[dict]: - """Get all messages for a session, sorted by creation order.""" - message_dir = storage / "message" / session_id - if not message_dir.exists(): - return [] - - messages = [] - for msg_file in message_dir.glob("*.json"): - try: - msg = json.loads(msg_file.read_text()) - msg["_file"] = msg_file - msg["_id"] = msg_file.stem - messages.append(msg) - except (json.JSONDecodeError, IOError): - pass - - return sorted(messages, key=lambda m: m.get("_id", "")) - - -def get_message_parts(storage: Path, message_id: str) -> list[dict]: - """Get all parts for a message, sorted by creation order.""" - parts_dir = storage / "part" / message_id - if not parts_dir.exists(): - return [] - - parts = [] - for part_file in parts_dir.glob("*.json"): - try: - part = json.loads(part_file.read_text()) - part["_file"] = part_file - part["_id"] = part_file.stem - parts.append(part) - except (json.JSONDecodeError, IOError): - pass - - return sorted(parts, key=lambda p: p.get("_id", "")) +def get_session_messages(client, session: dict) -> list[dict]: + """Get all messages for a session.""" + return client.get_session_messages(session["id"], directory=session.get("directory")) def is_ignored_message(message: dict, parts: list[dict]) -> bool: @@ -83,22 +51,20 @@ def is_ignored_message(message: dict, parts: list[dict]) -> bool: return True -def count_real_user_messages(storage: Path, session_id: str) -> int: +def count_real_user_messages(messages: list[dict]) -> int: """Count user messages that are not ignored (real user interactions).""" - messages = get_session_messages(storage, session_id) count = 0 - + for msg in messages: + info = msg.get("info", {}) + parts = msg.get("parts", []) # Only count user role messages - if msg.get("role") != "user": + if info.get("role") != "user": continue - - msg_id = msg.get("_id", "") - parts = get_message_parts(storage, msg_id) - + if not is_ignored_message(msg, parts): count += 1 - + return count @@ -136,10 +102,9 @@ def calc_cache_hit_rate(tokens: dict) -> float: return (cache_read / total_context) * 100 -def analyze_session(storage: Path, session_id: str) -> dict: +def analyze_session(messages: list[dict], session_id: str) -> dict: """Analyze DCP impact for a single session.""" - messages = get_session_messages(storage, session_id) - + result = { "session_id": session_id, "dcp_events": [], @@ -164,14 +129,15 @@ def analyze_session(storage: Path, session_id: str) -> dict: prev_step = None prev_dcp_tools = [] steps_since_dcp = None # None = no DCP yet, 0 = just had DCP, 1+ = steps after - + for i, msg in enumerate(messages): - msg_id = msg.get("_id", "") - parts = get_message_parts(storage, msg_id) - + msg_info = msg.get("info", {}) + msg_id = msg_info.get("id", "") + parts = msg.get("parts", []) + step_finish = extract_step_finish(parts) dcp_tools = extract_dcp_tools(parts) - + if step_finish: result["total_steps"] += 1 tokens = step_finish.get("tokens", {}) @@ -239,27 +205,24 @@ def analyze_session(storage: Path, session_id: str) -> dict: return result -def analyze_sessions(num_sessions: int = 20, min_messages: int = 5, output_json: bool = False, verbose: bool = False, session_id: str = None): +def analyze_sessions( + client, + num_sessions: int = 20, + min_messages: int = 5, + output_json: bool = False, + verbose: bool = False, + session_id: str = None, + session_list_limit: int = 5000, +): """Analyze DCP impact across recent sessions.""" - storage = Path.home() / ".local/share/opencode/storage" - message_dir = storage / "message" - session_dir = storage / "session" - - if not message_dir.exists(): - print("Error: OpenCode storage not found at", storage) - return - + # Get sessions to analyze if session_id: # Analyze specific session - session_path = message_dir / session_id - if not session_path.exists(): - print(f"Error: Session {session_id} not found") - return - sessions = [session_path] + sessions = [client.get_session(session_id)] else: - sessions = sorted(message_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True)[:num_sessions] - + sessions = list_sessions_across_projects(client, per_project_limit=session_list_limit)[:num_sessions] + all_results = [] grand_totals = { "sessions_analyzed": 0, @@ -283,44 +246,34 @@ def analyze_sessions(num_sessions: int = 20, min_messages: int = 5, output_json: "hit_rates_by_distance": defaultdict(list) } - for session_path in sessions: - session_id = session_path.name - + for session in sessions: + session_id = session.get("id", "") + messages = get_session_messages(client, session) + # Check minimum message count (excluding ignored messages) - real_user_messages = count_real_user_messages(storage, session_id) + real_user_messages = count_real_user_messages(messages) if real_user_messages < min_messages: grand_totals["sessions_skipped_short"] += 1 continue - - result = analyze_session(storage, session_id) + + result = analyze_session(messages, session_id) result["user_messages"] = real_user_messages - - # Get session metadata - title = "Unknown" - for s_dir in session_dir.iterdir(): - s_file = s_dir / f"{session_id}.json" - if s_file.exists(): - try: - sess = json.loads(s_file.read_text()) - title = sess.get("title", "Untitled")[:50] - except (json.JSONDecodeError, IOError): - pass - break - - result["title"] = title - + + # Session metadata from API + result["title"] = session.get("title", "Untitled")[:50] + if result["total_dcp_calls"] > 0: all_results.append(result) grand_totals["sessions_with_dcp"] += 1 - + grand_totals["sessions_analyzed"] += 1 grand_totals["total_dcp_calls"] += result["total_dcp_calls"] grand_totals["total_steps"] += result["total_steps"] - + for tool, stats in result["by_tool"].items(): for key in stats: grand_totals["by_tool"][tool][key] += stats[key] - + # Aggregate hit rates by distance for dist, rates in result["hit_rates_by_distance"].items(): grand_totals["hit_rates_by_distance"][dist].extend(rates) @@ -474,16 +427,26 @@ def main(): help="Output as JSON") parser.add_argument("--verbose", "-v", action="store_true", help="Show detailed per-event breakdown") + add_api_arguments(parser) args = parser.parse_args() - - analyze_sessions( - num_sessions=args.sessions, - min_messages=args.min_messages, - output_json=args.json, - verbose=args.verbose, - session_id=args.session - ) + + try: + with create_client_from_args(args) as client: + analyze_sessions( + client, + num_sessions=args.sessions, + min_messages=args.min_messages, + output_json=args.json, + verbose=args.verbose, + session_id=args.session, + session_list_limit=args.session_list_limit, + ) + except APIError as err: + print(f"Error: {err}") + return 1 + + return 0 if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/scripts/opencode-session-timeline b/scripts/opencode-session-timeline index 3a24a049..fff6ed19 100755 --- a/scripts/opencode-session-timeline +++ b/scripts/opencode-session-timeline @@ -134,11 +134,28 @@ def analyze_session(client, session: dict) -> dict: total_context = step["input"] + step["cache_read"] step["cache_hit_rate"] = (step["cache_read"] / total_context * 100) if total_context > 0 else 0 + total_input = sum(s["input"] for s in steps) + total_output = sum(s["output"] for s in steps) + total_cache_read = sum(s["cache_read"] for s in steps) + total_cost = sum(s["cost"] for s in steps) + total_duration_ms = sum((s["duration_ms"] or 0) for s in steps) + total_dcp_tools = sum(len(s["dcp_tools_used"]) for s in steps) + + total_context = total_input + total_cache_read + avg_cache_hit_rate = (total_cache_read / total_context * 100) if total_context > 0 else 0 + return { "session_id": session_id, "title": title, "steps": steps, "total_steps": len(steps), + "total_input": total_input, + "total_output": total_output, + "total_cache_read": total_cache_read, + "total_cost": total_cost, + "total_duration_ms": total_duration_ms, + "total_dcp_tools": total_dcp_tools, + "avg_cache_hit_rate": avg_cache_hit_rate, } @@ -187,6 +204,24 @@ def print_timeline(result: dict, colors: Colors): print("-" * 130) + print(f"\n{c.BOLD}SESSION SUMMARY{c.RESET}") + print(f" Total Input Tokens: {result.get('total_input', 0):,}") + print(f" Total Output Tokens: {result.get('total_output', 0):,}") + print(f" Total Cache Read: {result.get('total_cache_read', 0):,}") + + avg_pct = result.get('avg_cache_hit_rate', 0) + if avg_pct >= 80: + avg_pct_str = f"{c.GREEN}{avg_pct:.1f}%{c.RESET}" + elif avg_pct >= 50: + avg_pct_str = f"{c.YELLOW}{avg_pct:.1f}%{c.RESET}" + else: + avg_pct_str = f"{c.RED}{avg_pct:.1f}%{c.RESET}" + + print(f" Avg Cache Hit Rate: {avg_pct_str}") + print(f" Total Cost: ${result.get('total_cost', 0):.4f}") + print(f" Total Duration: {format_duration(result.get('total_duration_ms', 0))}") + print(f" Total DCP Uses: {result.get('total_dcp_tools', 0)}") + def main(): parser = argparse.ArgumentParser(description="Analyze token values at each step within an OpenCode session") diff --git a/scripts/opencode-token-stats b/scripts/opencode-token-stats index 3a7d6dba..aaf2c443 100755 --- a/scripts/opencode-token-stats +++ b/scripts/opencode-token-stats @@ -6,30 +6,18 @@ Usage: opencode-token-stats [--sessions N] [--json] import json import argparse -from pathlib import Path from datetime import datetime -def analyze_sessions(num_sessions=10, output_json=False, session_id=None): - storage = Path.home() / ".local/share/opencode/storage" - message_dir = storage / "message" - part_dir = storage / "part" - session_dir = storage / "session" - - if not message_dir.exists(): - print("Error: OpenCode storage not found at", storage) - return +from opencode_api import APIError, add_api_arguments, create_client_from_args, list_sessions_across_projects +def analyze_sessions(client, num_sessions=10, output_json=False, session_id=None, session_list_limit=5000): # Get sessions to analyze if session_id: # Analyze specific session - session_path = message_dir / session_id - if not session_path.exists(): - print(f"Error: Session {session_id} not found") - return - sessions = [session_path] + sessions = [client.get_session(session_id)] else: - # Get recent sessions sorted by modification time - sessions = sorted(message_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True)[:num_sessions] + # Get recent sessions sorted by API updated time across projects + sessions = list_sessions_across_projects(client, per_project_limit=session_list_limit)[:num_sessions] results = [] grand_totals = { @@ -39,8 +27,9 @@ def analyze_sessions(num_sessions=10, output_json=False, session_id=None): "reasons": {"tool-calls": 0, "stop": 0, "other": 0} } - for session_path in sessions: - session_id = session_path.name + for session in sessions: + session_id = session.get("id", "") + directory = session.get("directory") totals = { "input": 0, "output": 0, "reasoning": 0, "cache_read": 0, "cache_write": 0, @@ -49,47 +38,31 @@ def analyze_sessions(num_sessions=10, output_json=False, session_id=None): } # Get messages for this session - msg_files = list(session_path.glob("*.json")) - - for msg_file in msg_files: - msg_id = msg_file.stem - parts_path = part_dir / msg_id - if parts_path.exists(): - for part_file in parts_path.glob("*.json"): - try: - part = json.loads(part_file.read_text()) - if part.get("type") == "step-finish" and "tokens" in part: - t = part["tokens"] - totals["input"] += t.get("input", 0) - totals["output"] += t.get("output", 0) - totals["reasoning"] += t.get("reasoning", 0) - cache = t.get("cache", {}) - totals["cache_read"] += cache.get("read", 0) - totals["cache_write"] += cache.get("write", 0) - totals["cost"] += part.get("cost", 0) - totals["steps"] += 1 - - reason = part.get("reason", "other") - if reason in totals["reasons"]: - totals["reasons"][reason] += 1 - else: - totals["reasons"]["other"] += 1 - except (json.JSONDecodeError, KeyError): - pass + messages = client.get_session_messages(session_id, directory=directory) + + for message in messages: + for part in message.get("parts", []): + if part.get("type") != "step-finish" or "tokens" not in part: + continue + t = part["tokens"] + totals["input"] += t.get("input", 0) + totals["output"] += t.get("output", 0) + totals["reasoning"] += t.get("reasoning", 0) + cache = t.get("cache", {}) + totals["cache_read"] += cache.get("read", 0) + totals["cache_write"] += cache.get("write", 0) + totals["cost"] += part.get("cost", 0) + totals["steps"] += 1 + + reason = part.get("reason", "other") + if reason in totals["reasons"]: + totals["reasons"][reason] += 1 + else: + totals["reasons"]["other"] += 1 # Get session metadata (title, timestamps) - title = "Unknown" - created = None - for s_dir in session_dir.iterdir(): - s_file = s_dir / f"{session_id}.json" - if s_file.exists(): - try: - sess = json.loads(s_file.read_text()) - title = sess.get("title", "Untitled")[:60] - created = sess.get("createdAt") - except (json.JSONDecodeError, KeyError): - pass - break + title = session.get("title", "Untitled")[:60] + created = session.get("time", {}).get("created") # Calculate derived metrics total_tokens = totals["input"] + totals["output"] + totals["cache_read"] @@ -187,9 +160,23 @@ def main(): parser.add_argument("--sessions", "-n", type=int, default=10, help="Number of recent sessions to analyze (default: 10)") parser.add_argument("--session", "-s", type=str, default=None, help="Analyze specific session ID") parser.add_argument("--json", "-j", action="store_true", help="Output as JSON instead of formatted text") + add_api_arguments(parser) args = parser.parse_args() - analyze_sessions(num_sessions=args.sessions, output_json=args.json, session_id=args.session) + try: + with create_client_from_args(args) as client: + analyze_sessions( + client, + num_sessions=args.sessions, + output_json=args.json, + session_id=args.session, + session_list_limit=args.session_list_limit, + ) + except APIError as err: + print(f"Error: {err}") + return 1 + + return 0 if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/scripts/print.ts b/scripts/print.ts index 484bc023..ca59bdcf 100644 --- a/scripts/print.ts +++ b/scripts/print.ts @@ -1,117 +1,45 @@ #!/usr/bin/env npx tsx -import { renderSystemPrompt, renderNudge, type ToolFlags } from "../lib/prompts/index.js" -import { - wrapPrunableTools, - wrapCompressContext, - wrapCooldownMessage, -} from "../lib/messages/inject.js" +import { renderSystemPrompt, renderNudge } from "../lib/prompts" const args = process.argv.slice(2) +const showHelp = args.includes("-h") || args.includes("--help") -const flags: ToolFlags = { - distill: args.includes("-d") || args.includes("--distill"), - compress: args.includes("-c") || args.includes("--compress"), - prune: args.includes("-p") || args.includes("--prune"), -} - -// 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) -) { +if (showHelp) { console.log(` -Usage: bun run dcp [TYPE] [-d] [-c] [-p] - -Types: - --system System prompt - --nudge Nudge prompt - --prune-list Example prunable tools list - --compress-context Example compress context - --cooldown Cooldown message after pruning +DCP Prompt Preview CLI -Tool flags (for --system and --nudge): - -d, --distill Enable distill tool - -c, --compress Enable compress tool - -p, --prune Enable prune tool +Usage: + bun run dcp [TYPE] -If no tool flags specified, all are enabled. +Types: + --system Print system prompt + --nudge Print standard nudge prompt + --compress-nudge Print context-limit compress nudge Examples: - bun run dcp --system -d -c -p # 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 + bun run dcp --system + bun run dcp --nudge + bun run dcp --compress-nudge `) 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.distill && "distill", - flags.compress && "compress", - flags.prune && "prune", - ] - .filter(Boolean) - .join(", ") - header(`SYSTEM PROMPT (tools: ${enabled})`) - console.log(renderSystemPrompt(flags)) -} - -if (showNudge) { - const enabled = [ - flags.distill && "distill", - flags.compress && "compress", - flags.prune && "prune", - ] - .filter(Boolean) - .join(", ") - header(`NUDGE (tools: ${enabled})`) - console.log(renderNudge(flags)) -} +const isSystem = args.includes("--system") || args.length === 0 +const isNudge = args.includes("--nudge") +const isCompressNudge = args.includes("--compress-nudge") -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 (isSystem) { + console.log("=== SYSTEM ===\n") + console.log(renderSystemPrompt()) } -if (showCompressContext) { - header("COMPRESS CONTEXT (mock example)") - console.log(wrapCompressContext(45)) +if (isNudge) { + console.log("=== NUDGE ===\n") + console.log(renderNudge("frequency")) } -if (showCooldown) { - const enabled = [ - flags.distill && "distill", - flags.compress && "compress", - flags.prune && "prune", - ] - .filter(Boolean) - .join(", ") - header(`COOLDOWN MESSAGE (tools: ${enabled})`) - console.log(wrapCooldownMessage(flags)) +if (isCompressNudge) { + console.log("=== COMPRESS NUDGE ===\n") + console.log(renderNudge("context-limit")) }