From 0d8147e477ebc39074c9ab33301f90bb260182b8 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sat, 24 Jan 2026 18:52:41 -0500 Subject: [PATCH 001/113] WIP: dev stash changes --- README.md | 10 +- dcp.schema.json | 20 ++++ index.ts | 12 ++- lib/config.ts | 38 ++++++++ lib/hooks.ts | 13 ++- lib/messages/inject.ts | 94 ++++++++++++++----- lib/messages/prune.ts | 54 ++++++++++- lib/messages/utils.ts | 30 +++++- lib/prompts/discard-tool-spec.ts | 57 ++++------- lib/prompts/extract-tool-spec.ts | 69 +++++--------- lib/prompts/index.ts | 26 ++++- lib/prompts/nudge/both.ts | 10 -- lib/prompts/nudge/discard.ts | 12 +-- lib/prompts/nudge/extract.ts | 12 +-- lib/prompts/system/both.ts | 60 ------------ lib/prompts/system/discard.ts | 61 ++++-------- lib/prompts/system/extract.ts | 61 ++++-------- lib/shared-utils.ts | 8 +- lib/state/state.ts | 6 ++ lib/state/tool-cache.ts | 6 +- lib/state/types.ts | 7 ++ lib/strategies/index.ts | 2 +- lib/strategies/utils.ts | 8 +- lib/tools/discard.ts | 25 +++++ lib/tools/extract.ts | 48 ++++++++++ lib/tools/index.ts | 4 + .../tools.ts => tools/prune-shared.ts} | 87 +---------------- lib/tools/types.ts | 11 +++ lib/ui/notification.ts | 51 ++++++++++ lib/ui/utils.ts | 23 +++++ 30 files changed, 538 insertions(+), 387 deletions(-) delete mode 100644 lib/prompts/nudge/both.ts delete mode 100644 lib/prompts/system/both.ts create mode 100644 lib/tools/discard.ts create mode 100644 lib/tools/extract.ts create mode 100644 lib/tools/index.ts rename lib/{strategies/tools.ts => tools/prune-shared.ts} (62%) create mode 100644 lib/tools/types.ts diff --git a/README.md b/README.md index 4d39d2cd..d9f85a30 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ DCP uses multiple tools and strategies to reduce context size: **Extract** — Exposes an `extract` tool that the AI can call to distill valuable context into concise summaries before removing the tool content. +**Squash** — Exposes a `squash` tool that the AI can call to collapse a large section of conversation (messages and tools) into a single summary. + ### Strategies **Deduplication** — Identifies repeated tool calls (e.g., reading the same file multiple times) and keeps only the most recent output. Runs automatically on every request with zero LLM cost. @@ -105,6 +107,12 @@ DCP uses its own config file: // Show distillation content as an ignored message notification "showDistillation": false, }, + // Collapses a range of conversation content into a single summary + "squash": { + "enabled": true, + // Show summary content as an ignored message notification + "showSummary": true, + }, }, // Automatic pruning strategies "strategies": { @@ -148,7 +156,7 @@ When enabled, turn protection prevents tool outputs from being pruned for a conf ### Protected Tools By default, these tools are always protected from pruning across all strategies: -`task`, `todowrite`, `todoread`, `discard`, `extract`, `batch`, `write`, `edit` +`task`, `todowrite`, `todoread`, `discard`, `extract`, `squash`, `batch`, `write`, `edit` The `protectedTools` arrays in each section add to this default list. diff --git a/dcp.schema.json b/dcp.schema.json index 91db1b3c..8e4ff104 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -106,6 +106,7 @@ "todoread", "discard", "extract", + "squash", "batch", "write", "edit" @@ -142,6 +143,23 @@ "description": "Show distillation output in the UI" } } + }, + "squash": { + "type": "object", + "description": "Configuration for the squash tool", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable the squash tool" + }, + "showSummary": { + "type": "boolean", + "default": true, + "description": "Show summary output in the UI" + } + } } } }, @@ -171,6 +189,7 @@ "todoread", "discard", "extract", + "squash", "batch", "write", "edit" @@ -217,6 +236,7 @@ "todoread", "discard", "extract", + "squash", "batch", "write", "edit" diff --git a/index.ts b/index.ts index 0c7ae2a7..4cd77603 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 { createDiscardTool, createExtractTool } from "./lib/strategies" +import { createDiscardTool, createExtractTool, createSquashTool } from "./lib/strategies" import { createChatMessageTransformHandler, createCommandExecuteHandler, @@ -73,6 +73,15 @@ const plugin: Plugin = (async (ctx) => { workingDirectory: ctx.directory, }), }), + ...(config.tools.squash.enabled && { + squash: createSquashTool({ + client: ctx.client, + state, + logger, + config, + workingDirectory: ctx.directory, + }), + }), }, config: async (opencodeConfig) => { if (config.commands.enabled) { @@ -86,6 +95,7 @@ const plugin: Plugin = (async (ctx) => { const toolsToAdd: string[] = [] if (config.tools.discard.enabled) toolsToAdd.push("discard") if (config.tools.extract.enabled) toolsToAdd.push("extract") + if (config.tools.squash.enabled) toolsToAdd.push("squash") if (toolsToAdd.length > 0) { const existingPrimaryTools = opencodeConfig.experimental?.primary_tools ?? [] diff --git a/lib/config.ts b/lib/config.ts index f24e9680..e0b0b7f8 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -18,6 +18,11 @@ export interface ExtractTool { showDistillation: boolean } +export interface SquashTool { + enabled: boolean + showSummary: boolean +} + export interface ToolSettings { nudgeEnabled: boolean nudgeFrequency: number @@ -28,6 +33,7 @@ export interface Tools { settings: ToolSettings discard: DiscardTool extract: ExtractTool + squash: SquashTool } export interface Commands { @@ -71,6 +77,7 @@ const DEFAULT_PROTECTED_TOOLS = [ "todoread", "discard", "extract", + "squash", "batch", "write", "edit", @@ -103,6 +110,9 @@ export const VALID_CONFIG_KEYS = new Set([ "tools.extract", "tools.extract.enabled", "tools.extract.showDistillation", + "tools.squash", + "tools.squash.enabled", + "tools.squash.showSummary", "strategies", // strategies.deduplication "strategies.deduplication", @@ -295,6 +305,25 @@ function validateConfigTypes(config: Record): ValidationError[] { }) } } + if (tools.squash) { + if (tools.squash.enabled !== undefined && typeof tools.squash.enabled !== "boolean") { + errors.push({ + key: "tools.squash.enabled", + expected: "boolean", + actual: typeof tools.squash.enabled, + }) + } + if ( + tools.squash.showSummary !== undefined && + typeof tools.squash.showSummary !== "boolean" + ) { + errors.push({ + key: "tools.squash.showSummary", + expected: "boolean", + actual: typeof tools.squash.showSummary, + }) + } + } } // Strategies validators @@ -446,6 +475,10 @@ const defaultConfig: PluginConfig = { enabled: true, showDistillation: false, }, + squash: { + enabled: true, + showSummary: true, + }, }, strategies: { deduplication: { @@ -618,6 +651,10 @@ function mergeTools( enabled: override.extract?.enabled ?? base.extract.enabled, showDistillation: override.extract?.showDistillation ?? base.extract.showDistillation, }, + squash: { + enabled: override.squash?.enabled ?? base.squash.enabled, + showSummary: override.squash?.showSummary ?? base.squash.showSummary, + }, } } @@ -649,6 +686,7 @@ function deepCloneConfig(config: PluginConfig): PluginConfig { }, discard: { ...config.tools.discard }, extract: { ...config.tools.extract }, + squash: { ...config.tools.squash }, }, strategies: { deduplication: { diff --git a/lib/hooks.ts b/lib/hooks.ts index aaf43883..eee5801b 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -35,14 +35,23 @@ export function createSystemPromptHandler( const discardEnabled = config.tools.discard.enabled const extractEnabled = config.tools.extract.enabled + const squashEnabled = config.tools.squash.enabled let promptName: string - if (discardEnabled && extractEnabled) { - promptName = "system/system-prompt-both" + if (discardEnabled && extractEnabled && squashEnabled) { + promptName = "system/system-prompt-all" + } else if (discardEnabled && extractEnabled) { + promptName = "system/system-prompt-discard-extract" + } else if (discardEnabled && squashEnabled) { + promptName = "system/system-prompt-discard-squash" + } else if (extractEnabled && squashEnabled) { + promptName = "system/system-prompt-extract-squash" } else if (discardEnabled) { promptName = "system/system-prompt-discard" } else if (extractEnabled) { promptName = "system/system-prompt-extract" + } else if (squashEnabled) { + promptName = "system/system-prompt-squash" } else { return } diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 491ecd6c..8f092512 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -13,43 +13,69 @@ import { isIgnoredUserMessage, } from "./utils" import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" -import { getLastUserMessage } from "../shared-utils" +import { getLastUserMessage, isMessageCompacted } from "../shared-utils" const getNudgeString = (config: PluginConfig): string => { const discardEnabled = config.tools.discard.enabled const extractEnabled = config.tools.extract.enabled - - if (discardEnabled && extractEnabled) { - return loadPrompt(`nudge/nudge-both`) + const squashEnabled = config.tools.squash.enabled + + if (discardEnabled && extractEnabled && squashEnabled) { + return loadPrompt(`nudge/nudge-all`) + } else if (discardEnabled && extractEnabled) { + return loadPrompt(`nudge/nudge-discard-extract`) + } else if (discardEnabled && squashEnabled) { + return loadPrompt(`nudge/nudge-discard-squash`) + } else if (extractEnabled && squashEnabled) { + return loadPrompt(`nudge/nudge-extract-squash`) } else if (discardEnabled) { return loadPrompt(`nudge/nudge-discard`) } else if (extractEnabled) { return loadPrompt(`nudge/nudge-extract`) + } else if (squashEnabled) { + return loadPrompt(`nudge/nudge-squash`) } return "" } const wrapPrunableTools = (content: string): string => ` -The following tools have been invoked and are available for pruning. This list does not mandate immediate action. Consider your current goals and the resources you need before discarding valuable tool inputs or outputs. Consolidate your prunes for efficiency; it is rarely worth pruning a single tiny tool output. Keep the context free of noise. +The following tools have been invoked and are available for pruning. This list does not mandate immediate action. Consider your current goals and the resources you need before pruning valuable tool inputs or outputs. Consolidate your prunes for efficiency; it is rarely worth pruning a single tiny tool output. Keep the context free of noise. ${content} ` const getCooldownMessage = (config: PluginConfig): string => { const discardEnabled = config.tools.discard.enabled const extractEnabled = config.tools.extract.enabled + const squashEnabled = config.tools.squash.enabled + + const enabledTools: string[] = [] + if (discardEnabled) enabledTools.push("discard") + if (extractEnabled) enabledTools.push("extract") + if (squashEnabled) enabledTools.push("squash") let toolName: string - if (discardEnabled && extractEnabled) { - toolName = "discard or extract tools" - } else if (discardEnabled) { - toolName = "discard tool" + if (enabledTools.length === 0) { + toolName = "pruning tools" + } else if (enabledTools.length === 1) { + toolName = `${enabledTools[0]} tool` } else { - toolName = "extract tool" + const last = enabledTools.pop() + toolName = `${enabledTools.join(", ")} or ${last} tools` } - return ` + return ` Context management was just performed. Do not use the ${toolName} again. A fresh list will be available after your next tool use. -` +` +} + +const buildSquashContext = (state: SessionState, messages: WithParts[]): string => { + const messageCount = messages.filter((msg) => !isMessageCompacted(state, msg)).length + + return ` +Squash available. Conversation: ${messageCount} messages. +Squash collapses completed task sequences or exploration phases into summaries. +Uses text boundaries [startString, endString, topic, summary]. +` } const buildPrunableToolsList = ( @@ -107,35 +133,53 @@ export const insertPruneToolContext = ( logger: Logger, messages: WithParts[], ): void => { - if (!config.tools.discard.enabled && !config.tools.extract.enabled) { + const discardEnabled = config.tools.discard.enabled + const extractEnabled = config.tools.extract.enabled + const squashEnabled = config.tools.squash.enabled + + if (!discardEnabled && !extractEnabled && !squashEnabled) { return } - let prunableToolsContent: string + const discardOrExtractEnabled = discardEnabled || extractEnabled + const contentParts: string[] = [] if (state.lastToolPrune) { logger.debug("Last tool was prune - injecting cooldown message") - prunableToolsContent = getCooldownMessage(config) + contentParts.push(getCooldownMessage(config)) } else { - const prunableToolsList = buildPrunableToolsList(state, config, logger, messages) - if (!prunableToolsList) { - return + // Inject only when discard or extract is enabled + if (discardOrExtractEnabled) { + const prunableToolsList = buildPrunableToolsList(state, config, logger, messages) + if (prunableToolsList) { + // logger.debug("prunable-tools: \n" + prunableToolsList) + contentParts.push(prunableToolsList) + } } - logger.debug("prunable-tools: \n" + prunableToolsList) + // Inject always when squash is enabled (every turn) + if (squashEnabled) { + const squashContext = buildSquashContext(state, messages) + // logger.debug("squash-context: \n" + squashContext) + contentParts.push(squashContext) + } - let nudgeString = "" + // Add nudge if threshold reached if ( config.tools.settings.nudgeEnabled && state.nudgeCounter >= config.tools.settings.nudgeFrequency ) { logger.info("Inserting prune nudge message") - nudgeString = "\n" + getNudgeString(config) + contentParts.push(getNudgeString(config)) } + } - prunableToolsContent = prunableToolsList + nudgeString + if (contentParts.length === 0) { + return } + const combinedContent = contentParts.join("\n") + const lastUserMessage = getLastUserMessage(messages) if (!lastUserMessage) { return @@ -154,17 +198,17 @@ export const insertPruneToolContext = ( } if (!lastNonIgnoredMessage || lastNonIgnoredMessage.info.role === "user") { - messages.push(createSyntheticUserMessage(lastUserMessage, prunableToolsContent, variant)) + messages.push(createSyntheticUserMessage(lastUserMessage, combinedContent, variant)) } else { const providerID = userInfo.model?.providerID || "" const modelID = userInfo.model?.modelID || "" if (isDeepSeekOrKimi(providerID, modelID)) { - const toolPart = createSyntheticToolPart(lastNonIgnoredMessage, prunableToolsContent) + const toolPart = createSyntheticToolPart(lastNonIgnoredMessage, combinedContent) lastNonIgnoredMessage.parts.push(toolPart) } else { messages.push( - createSyntheticAssistantMessage(lastUserMessage, prunableToolsContent, variant), + createSyntheticAssistantMessage(lastUserMessage, combinedContent, variant), ) } } diff --git a/lib/messages/prune.ts b/lib/messages/prune.ts index fb86036e..d05a6b8f 100644 --- a/lib/messages/prune.ts +++ b/lib/messages/prune.ts @@ -1,7 +1,9 @@ import type { SessionState, WithParts } from "../state" import type { Logger } from "../logger" import type { PluginConfig } from "../config" -import { isMessageCompacted } from "../shared-utils" +import { isMessageCompacted, getLastUserMessage } from "../shared-utils" +import { createSyntheticUserMessage, SQUASH_SUMMARY_PREFIX } from "./utils" +import type { UserMessage } from "@opencode-ai/sdk/v2" const PRUNED_TOOL_OUTPUT_REPLACEMENT = "[Output removed to save context - information superseded or no longer needed]" @@ -14,6 +16,7 @@ export const prune = ( config: PluginConfig, messages: WithParts[], ): void => { + filterSquashedRanges(state, logger, messages) pruneToolOutputs(state, logger, messages) pruneToolInputs(state, logger, messages) pruneToolErrors(state, logger, messages) @@ -103,3 +106,52 @@ const pruneToolErrors = (state: SessionState, logger: Logger, messages: WithPart } } } + +const filterSquashedRanges = (state: SessionState, logger: Logger, messages: WithParts[]): void => { + if (!state.prune.messageIds?.length) { + return + } + + const result: WithParts[] = [] + + for (const msg of messages) { + const msgId = msg.info.id + + // Check if there's a summary to inject at this anchor point + const summary = state.prune.squashSummaries?.find((s) => s.anchorMessageId === msgId) + if (summary) { + // Find user message for variant and as base for synthetic message + const msgIndex = messages.indexOf(msg) + const userMessage = getLastUserMessage(messages, msgIndex) + + if (userMessage) { + const userInfo = userMessage.info as UserMessage + const summaryContent = SQUASH_SUMMARY_PREFIX + summary.summary + result.push( + createSyntheticUserMessage(userMessage, summaryContent, userInfo.variant), + ) + + logger.info("Injected squash summary", { + anchorMessageId: msgId, + summaryLength: summary.summary.length, + }) + } else { + logger.warn("No user message found for squash summary", { + anchorMessageId: msgId, + }) + } + } + + // Skip messages that are in the prune list + if (state.prune.messageIds.includes(msgId)) { + continue + } + + // Normal message, include it + result.push(msg) + } + + // Replace messages array contents + messages.length = 0 + messages.push(...result) +} diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index 406b6f42..c6d0cec3 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -3,10 +3,25 @@ import { isMessageCompacted } from "../shared-utils" import type { SessionState, WithParts } from "../state" import type { UserMessage } from "@opencode-ai/sdk/v2" +export const SQUASH_SUMMARY_PREFIX = "[Squashed conversation block]\n\n" const SYNTHETIC_MESSAGE_ID = "msg_01234567890123456789012345" const SYNTHETIC_PART_ID = "prt_01234567890123456789012345" const SYNTHETIC_CALL_ID = "call_01234567890123456789012345" +// Counter for generating unique IDs within the same millisecond +let idCounter = 0 +let lastTimestamp = 0 + +const generateUniqueId = (prefix: string): string => { + const now = Date.now() + if (now !== lastTimestamp) { + lastTimestamp = now + idCounter = 0 + } + idCounter++ + return `${prefix}_${now}_${idCounter}` +} + export const isDeepSeekOrKimi = (providerID: string, modelID: string): boolean => { const lowerProviderID = providerID.toLowerCase() const lowerModelID = modelID.toLowerCase() @@ -26,21 +41,24 @@ export const createSyntheticUserMessage = ( const userInfo = baseMessage.info as UserMessage const now = Date.now() + const messageId = generateUniqueId("msg") + const partId = generateUniqueId("prt") + return { info: { - id: SYNTHETIC_MESSAGE_ID, + id: messageId, sessionID: userInfo.sessionID, role: "user" as const, - agent: userInfo.agent || "code", + agent: userInfo.agent, model: userInfo.model, time: { created: now }, ...(variant !== undefined && { variant }), }, parts: [ { - id: SYNTHETIC_PART_ID, + id: partId, sessionID: userInfo.sessionID, - messageID: SYNTHETIC_MESSAGE_ID, + messageID: messageId, type: "text", text: content, }, @@ -264,3 +282,7 @@ export const isIgnoredUserMessage = (message: WithParts): boolean => { return true } + +export const findMessageIndex = (messages: WithParts[], messageId: string): number => { + return messages.findIndex((msg) => msg.info.id === messageId) +} diff --git a/lib/prompts/discard-tool-spec.ts b/lib/prompts/discard-tool-spec.ts index e5084212..54f2bef1 100644 --- a/lib/prompts/discard-tool-spec.ts +++ b/lib/prompts/discard-tool-spec.ts @@ -1,40 +1,17 @@ -export const DISCARD_TOOL_SPEC = `Discards tool outputs from context to manage conversation size and reduce noise. - -## IMPORTANT: The Prunable List -A \`\` list is provided to you showing available tool outputs you can discard when there are tools available for pruning. Each line has the format \`ID: tool, parameter\` (e.g., \`20: read, /path/to/file.ts\`). You MUST only use numeric IDs that appear in this list to select which tools to discard. - -## When to Use This Tool - -Use \`discard\` for removing tool content that is no longer needed - -- **Noise:** Irrelevant, unhelpful, or superseded outputs that provide no value. -- **Task Completion:** Work is complete and there's no valuable information worth preserving. - -## When NOT to Use This Tool - -- **If the output contains useful information:** Keep it in context rather than discarding. -- **If you'll need the output later:** Don't discard files you plan to edit or context you'll need for implementation. - -## Best Practices -- **Strategic Batching:** Don't discard single small tool outputs (like short bash commands) unless they are pure noise. Wait until you have several items to perform high-impact discards. -- **Think ahead:** Before discarding, ask: "Will I need this output for an upcoming task?" If yes, keep it. - -## Format - -- \`ids\`: Array where the first element is the reason, followed by numeric IDs from the \`\` list - -Reasons: \`noise\` | \`completion\` - -## Example - - -Assistant: [Reads 'wrong_file.ts'] -This file isn't relevant to the auth system. I'll remove it to clear the context. -[Uses discard with ids: ["noise", "5"]] - - - -Assistant: [Runs tests, they pass] -The tests passed and I don't need to preserve any details. I'll clean up now. -[Uses discard with ids: ["completion", "20", "21"]] -` +export const DISCARD_TOOL_SPEC = `**Purpose:** Discard tool outputs from context to manage size and reduce noise. +**IDs:** Use numeric IDs from \`\` (format: \`ID: tool, parameter\`). +**Use When:** +- Noise → irrelevant, unhelpful, or superseded outputs +**Do NOT Use When:** +- Output contains useful information +- Output needed later (files to edit, implementation context) +**Best Practices:** +- Batch multiple items; avoid single small outputs (unless pure noise) +- Criterion: "Needed for upcoming task?" → keep it +**Format:** +- \`ids\`: string[] — numeric IDs from prunable list +**Example:** +Noise removal: + ids: ["5"] + Context: Read wrong_file.ts — not relevant to auth system +` diff --git a/lib/prompts/extract-tool-spec.ts b/lib/prompts/extract-tool-spec.ts index 9324dc0c..20d94107 100644 --- a/lib/prompts/extract-tool-spec.ts +++ b/lib/prompts/extract-tool-spec.ts @@ -1,47 +1,22 @@ -export const EXTRACT_TOOL_SPEC = `Extracts key findings from tool outputs into distilled knowledge, then removes the raw outputs from context. - -## IMPORTANT: The Prunable List -A \`\` list is provided to you showing available tool outputs you can extract from when there are tools available for pruning. Each line has the format \`ID: tool, parameter\` (e.g., \`20: read, /path/to/file.ts\`). You MUST only use numeric IDs that appear in this list to select which tools to extract. - -## When to Use This Tool - -Use \`extract\` when you have gathered useful information that you want to **preserve in distilled form** before removing the raw outputs: - -- **Task Completion:** You completed a unit of work and want to preserve key findings. -- **Knowledge Preservation:** You have context that contains valuable information, but also a lot of unnecessary detail - you only need to preserve some specifics. - -## When NOT to Use This Tool - -- **If you need precise syntax:** If you'll edit a file or grep for exact strings, keep the raw output. -- **If uncertain:** Prefer keeping over re-fetching. - - -## Best Practices -- **Strategic Batching:** Wait until you have several items or a few large outputs to extract, rather than doing tiny, frequent extractions. Aim for high-impact extractions that significantly reduce context size. -- **Think ahead:** Before extracting, ask: "Will I need the raw output for an upcoming task?" If you researched a file you'll later edit, do NOT extract it. - -## Format - -- \`ids\`: Array of numeric IDs as strings from the \`\` list -- \`distillation\`: Array of strings, one per ID (positional: distillation[0] is for ids[0], etc.) - -Each distillation string should capture the essential information you need to preserve - function signatures, logic, constraints, values, etc. Be as detailed as needed for your task. - -## Example - - -Assistant: [Reads auth service and user types] -I'll preserve the key details before extracting. -[Uses extract with: - ids: ["10", "11"], - distillation: [ - "auth.ts: validateToken(token: string) -> User|null checks cache first (5min TTL) then OIDC. hashPassword uses bcrypt 12 rounds. Tokens must be 128+ chars.", - "user.ts: interface User { id: string; email: string; permissions: ('read'|'write'|'admin')[]; status: 'active'|'suspended' }" - ] -] - - - -Assistant: [Reads 'auth.ts' to understand the login flow] -I've understood the auth flow. I'll need to modify this file to add the new validation, so I'm keeping this read in context rather than extracting. -` +export const EXTRACT_TOOL_SPEC = `**Purpose:** Extract key findings from tool outputs into distilled knowledge; remove raw outputs from context. +**IDs:** Use numeric IDs from \`\` (format: \`ID: tool, parameter\`). +**Use When:** +- Task complete → preserve findings +- Distill context → keep specifics, drop noise +**Do NOT Use When:** +- Need exact syntax (edits/grep) → keep raw output +- Planning modifications → keep read output +**Best Practices:** +- Batch multiple items; avoid frequent small extractions +- Preserve raw output if editing/modifying later +**Format:** +- \`ids\`: string[] — numeric IDs from prunable list +- \`distillation\`: string[] — positional mapping (distillation[i] for ids[i]) +- Detail level: signatures, logic, constraints, values +**Example:** + \`ids\`: ["10", "11"] + \`distillation\`: [ + "auth.ts: validateToken(token: string)→User|null. Cache 5min TTL then OIDC. bcrypt 12 rounds. Tokens ≥128 chars.", + "user.ts: interface User {id: string; email: string; permissions: ('read'|'write'|'admin')[]; status: 'active'|'suspended'}" + ] +` diff --git a/lib/prompts/index.ts b/lib/prompts/index.ts index bdfbc865..8a6bf745 100644 --- a/lib/prompts/index.ts +++ b/lib/prompts/index.ts @@ -1,26 +1,44 @@ // Tool specs import { DISCARD_TOOL_SPEC } from "./discard-tool-spec" import { EXTRACT_TOOL_SPEC } from "./extract-tool-spec" +import { SQUASH_TOOL_SPEC } from "./squash-tool-spec" // System prompts -import { SYSTEM_PROMPT_BOTH } from "./system/both" import { SYSTEM_PROMPT_DISCARD } from "./system/discard" import { SYSTEM_PROMPT_EXTRACT } from "./system/extract" +import { SYSTEM_PROMPT_SQUASH } from "./system/squash" +import { SYSTEM_PROMPT_DISCARD_EXTRACT } from "./system/discard-extract" +import { SYSTEM_PROMPT_DISCARD_SQUASH } from "./system/discard-squash" +import { SYSTEM_PROMPT_EXTRACT_SQUASH } from "./system/extract-squash" +import { SYSTEM_PROMPT_ALL } from "./system/all" // Nudge prompts -import { NUDGE_BOTH } from "./nudge/both" import { NUDGE_DISCARD } from "./nudge/discard" import { NUDGE_EXTRACT } from "./nudge/extract" +import { NUDGE_SQUASH } from "./nudge/squash" +import { NUDGE_DISCARD_EXTRACT } from "./nudge/discard-extract" +import { NUDGE_DISCARD_SQUASH } from "./nudge/discard-squash" +import { NUDGE_EXTRACT_SQUASH } from "./nudge/extract-squash" +import { NUDGE_ALL } from "./nudge/all" const PROMPTS: Record = { "discard-tool-spec": DISCARD_TOOL_SPEC, "extract-tool-spec": EXTRACT_TOOL_SPEC, - "system/system-prompt-both": SYSTEM_PROMPT_BOTH, + "squash-tool-spec": SQUASH_TOOL_SPEC, "system/system-prompt-discard": SYSTEM_PROMPT_DISCARD, "system/system-prompt-extract": SYSTEM_PROMPT_EXTRACT, - "nudge/nudge-both": NUDGE_BOTH, + "system/system-prompt-squash": SYSTEM_PROMPT_SQUASH, + "system/system-prompt-discard-extract": SYSTEM_PROMPT_DISCARD_EXTRACT, + "system/system-prompt-discard-squash": SYSTEM_PROMPT_DISCARD_SQUASH, + "system/system-prompt-extract-squash": SYSTEM_PROMPT_EXTRACT_SQUASH, + "system/system-prompt-all": SYSTEM_PROMPT_ALL, "nudge/nudge-discard": NUDGE_DISCARD, "nudge/nudge-extract": NUDGE_EXTRACT, + "nudge/nudge-squash": NUDGE_SQUASH, + "nudge/nudge-discard-extract": NUDGE_DISCARD_EXTRACT, + "nudge/nudge-discard-squash": NUDGE_DISCARD_SQUASH, + "nudge/nudge-extract-squash": NUDGE_EXTRACT_SQUASH, + "nudge/nudge-all": NUDGE_ALL, } export function loadPrompt(name: string, vars?: Record): string { diff --git a/lib/prompts/nudge/both.ts b/lib/prompts/nudge/both.ts deleted file mode 100644 index 50fc0a9d..00000000 --- a/lib/prompts/nudge/both.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const NUDGE_BOTH = ` -**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. - -**Immediate Actions Required:** -1. **Task Completion:** If a sub-task is complete, decide: use \`discard\` if no valuable context to preserve (default), or use \`extract\` if insights are worth keeping. -2. **Noise Removal:** If you read files or ran commands that yielded no value, use \`discard\` to remove them. -3. **Knowledge Preservation:** If you are holding valuable raw data you'll need to reference later, use \`extract\` to distill the insights and remove the raw entry. - -**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must perform context management. -` diff --git a/lib/prompts/nudge/discard.ts b/lib/prompts/nudge/discard.ts index 18e92504..c274c1a1 100644 --- a/lib/prompts/nudge/discard.ts +++ b/lib/prompts/nudge/discard.ts @@ -1,9 +1,7 @@ export const NUDGE_DISCARD = ` -**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. - -**Immediate Actions Required:** -1. **Task Completion:** If a sub-task is complete, use the \`discard\` tool to remove the tools used. -2. **Noise Removal:** If you read files or ran commands that yielded no value, use the \`discard\` tool to remove them. - -**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must discard unneeded tool outputs. +**CONTEXT WARNING:** Context filling with tool outputs. Context hygiene required. +**Actions:** +1. Noise → files/commands with no value, use \`discard\` +2. Outdated → outputs no longer relevant, discard +**Protocol:** Prioritize cleanup. Don't interrupt atomic ops. After immediate step, discard unneeded outputs. ` diff --git a/lib/prompts/nudge/extract.ts b/lib/prompts/nudge/extract.ts index 243f5855..95258891 100644 --- a/lib/prompts/nudge/extract.ts +++ b/lib/prompts/nudge/extract.ts @@ -1,9 +1,7 @@ export const NUDGE_EXTRACT = ` -**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. - -**Immediate Actions Required:** -1. **Task Completion:** If you have completed work, extract key findings from the tools used. Scale distillation depth to the value of the content. -2. **Knowledge Preservation:** If you are holding valuable raw data you'll need to reference later, use the \`extract\` tool with high-fidelity distillation to preserve the insights and remove the raw entry. - -**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must extract valuable findings from tool outputs. +**CONTEXT WARNING:** Context filling with tool outputs. Context hygiene required. +**Actions:** +1. Knowledge → valuable raw data to reference later, use \`extract\` with high-fidelity distillation +2. Phase done → extract key findings to keep context focused +**Protocol:** Prioritize cleanup. Don't interrupt atomic ops. After immediate step, extract valuable findings. ` diff --git a/lib/prompts/system/both.ts b/lib/prompts/system/both.ts deleted file mode 100644 index 9c53a748..00000000 --- a/lib/prompts/system/both.ts +++ /dev/null @@ -1,60 +0,0 @@ -export const SYSTEM_PROMPT_BOTH = ` - - -ENVIRONMENT -You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` and \`extract\` tools. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to prune. - -IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. - -TWO TOOLS FOR CONTEXT MANAGEMENT -- \`discard\`: Remove tool outputs that are no longer needed (completed tasks, noise, outdated info). No preservation of content. -- \`extract\`: Extract key findings into distilled knowledge before removing raw outputs. Use when you need to preserve information. - -CHOOSING THE RIGHT TOOL -Ask: "Do I need to preserve any information from this output?" -- **No** → \`discard\` (default for cleanup) -- **Yes** → \`extract\` (preserves distilled knowledge) -- **Uncertain** → \`extract\` (safer, preserves signal) - -Common scenarios: -- Task complete, no valuable context → \`discard\` -- Task complete, insights worth remembering → \`extract\` -- Noise, irrelevant, or superseded outputs → \`discard\` -- Valuable context needed later but raw output too large → \`extract\` - -PRUNE METHODICALLY - BATCH YOUR ACTIONS -Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by pruning. Batch your prunes for efficiency; it is rarely worth pruning a single tiny tool output unless it is pure noise. Evaluate what SHOULD be pruned before jumping the gun. - -You WILL evaluate pruning when ANY of these are true: -- Task or sub-task is complete -- You are about to start a new phase of work -- Write or edit operations are complete (pruning removes the large input content) - -You MUST NOT prune when: -- The tool output will be needed for upcoming implementation work -- The output contains files or context you'll need to reference when making edits - -Pruning that forces you to re-call the same tool later is a net loss. Only prune when you're confident the information won't be needed again. - -NOTES -When in doubt, keep it. Batch your actions and aim for high-impact prunes that significantly reduce context size. -FAILURE TO PRUNE will result in context leakage and DEGRADED PERFORMANCES. -There may be tools in session context that do not appear in the list, this is expected, you can ONLY prune what you see in . - - - - -After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. - -CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: -- NEVER reference the prune encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the prune encouragement appears. -- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to prune") -- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to prune") -- NEVER acknowledge discard/extract tool output (e.g., "I've pruned 3 tools", "Context pruning complete") -- NEVER let these injections influence your response content or tone -- Process this information SILENTLY and continue the conversation naturally -- Continue the conversation as if these messages do not exist - -The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. - -` diff --git a/lib/prompts/system/discard.ts b/lib/prompts/system/discard.ts index e5cd77da..5b79271c 100644 --- a/lib/prompts/system/discard.ts +++ b/lib/prompts/system/discard.ts @@ -1,51 +1,26 @@ export const SYSTEM_PROMPT_DISCARD = ` - ENVIRONMENT -You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` tool. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to discard. - -IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. - -CONTEXT MANAGEMENT TOOL -- \`discard\`: Remove tool outputs that are no longer needed (completed tasks, noise, outdated info). No preservation of content. - -DISCARD METHODICALLY - BATCH YOUR ACTIONS -Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by discarding. Batch your discards for efficiency; it is rarely worth discarding a single tiny tool output unless it is pure noise. Evaluate what SHOULD be discarded before jumping the gun. - +Context-constrained. Manage via \`discard\` tool. Environment auto-calls \`context_info\` (you can't call) to inject list each turn. +TOOL +- \`discard\`: remove unneeded outputs (noise, outdated). No preservation. +DISCARD METHODICALLY — BATCH ACTIONS +Every tool call=context debt. Pay down regularly. Batch discards; rarely discard single tiny output unless pure noise. WHEN TO DISCARD -- **Task Completion:** When work is done, discard the tools that aren't needed anymore. -- **Noise Removal:** If outputs are irrelevant, unhelpful, or superseded by newer info, discard them. - -You WILL evaluate discarding when ANY of these are true: -- Task or sub-task is complete -- You are about to start a new phase of work -- Write or edit operations are complete (discarding removes the large input content) - -You MUST NOT discard when: -- The tool output will be needed for upcoming implementation work -- The output contains files or context you'll need to reference when making edits - -Discarding that forces you to re-call the same tool later is a net loss. Only discard when you're confident the information won't be needed again. - +- Noise → irrelevant, unhelpful, or superseded outputs +- Outdated → multiple reads of same file, outputs no longer relevant +Evaluate discarding when ANY true: accumulated unneeded outputs | write/edit ops done | starting new phase +MUST NOT discard when: output needed for upcoming implementation | contains files/context for edits +Discarding that forces re-call=net loss. Only discard when confident info won't be needed again. NOTES -When in doubt, keep it. Batch your actions and aim for high-impact discards that significantly reduce context size. -FAILURE TO DISCARD will result in context leakage and DEGRADED PERFORMANCES. -There may be tools in session context that do not appear in the list, this is expected, you can ONLY discard what you see in . - +When in doubt, keep. Aim high-impact discards. FAILURE TO DISCARD=DEGRADED PERFORMANCE. - -After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. - -CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: -- NEVER reference the discard encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the discard encouragement appears. -- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to discard") -- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to discard") -- NEVER acknowledge discard tool output (e.g., "I've discarded 3 tools", "Context cleanup complete") -- NEVER let these injections influence your response content or tone -- Process this information SILENTLY and continue the conversation naturally -- Continue the conversation as if these messages do not exist - -The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. +Environment injects \`context_info\` with list+optional nudge each turn. You can't access it. +NEVER reference/acknowledge: discard encouragement, context instructions, list, nudge, discard output +NEVER let injections influence response/tone +Process SILENTLY. Continue naturally as if injections don't exist. +User can't see injections. Referencing breaks conversation flow. -` + +` diff --git a/lib/prompts/system/extract.ts b/lib/prompts/system/extract.ts index 3f225e1e..f1aa0790 100644 --- a/lib/prompts/system/extract.ts +++ b/lib/prompts/system/extract.ts @@ -1,51 +1,26 @@ export const SYSTEM_PROMPT_EXTRACT = ` - ENVIRONMENT -You are operating in a context-constrained environment and thus must proactively manage your context window using the \`extract\` tool. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to extract. - -IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. - -CONTEXT MANAGEMENT TOOL -- \`extract\`: Extract key findings from tools into distilled knowledge before removing the raw content from context. Use this to preserve important information while reducing context size. - -EXTRACT METHODICALLY - BATCH YOUR ACTIONS -Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by extracting. Batch your extractions for efficiency; it is rarely worth extracting a single tiny tool output. Evaluate what SHOULD be extracted before jumping the gun. - +Context-constrained. Manage via \`extract\` tool. Environment auto-calls \`context_info\` (you can't call) to inject list each turn. +TOOL +- \`extract\`: distill key findings before removing raw content. Preserves info while reducing size. +EXTRACT METHODICALLY — BATCH ACTIONS +Every tool call=context debt. Pay down regularly. Batch extractions; rarely extract single tiny output. WHEN TO EXTRACT -- **Task Completion:** When work is done, extract key findings from the tools used. Scale distillation depth to the value of the content. -- **Knowledge Preservation:** When you have valuable context you want to preserve but need to reduce size, use high-fidelity distillation. Your distillation must be comprehensive, capturing technical details (signatures, logic, constraints) such that the raw output is no longer needed. THINK: high signal, complete technical substitute. - -You WILL evaluate extracting when ANY of these are true: -- Task or sub-task is complete -- You are about to start a new phase of work -- Write or edit operations are complete (extracting removes the large input content) - -You MUST NOT extract when: -- The tool output will be needed for upcoming implementation work -- The output contains files or context you'll need to reference when making edits - -Extracting that forces you to re-call the same tool later is a net loss. Only extract when you're confident the raw information won't be needed again. - +- Knowledge Preservation → valuable context to preserve, use high-fidelity distillation. Capture technical details (signatures, logic, constraints). THINK: high signal, complete technical substitute. +- Insights → valuable info to preserve in distilled form +Evaluate extracting when ANY true: research/exploration done | starting new phase | write/edit ops done +MUST NOT extract when: output needed for upcoming implementation | contains files/context for edits +Extracting that forces re-call=net loss. Only extract when confident raw info won't be needed again. NOTES -When in doubt, keep it. Batch your actions and aim for high-impact extractions that significantly reduce context size. -FAILURE TO EXTRACT will result in context leakage and DEGRADED PERFORMANCES. -There may be tools in session context that do not appear in the list, this is expected, you can ONLY extract what you see in . - +When in doubt, keep. Aim high-impact extractions. FAILURE TO EXTRACT=DEGRADED PERFORMANCE. - -After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. - -CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: -- NEVER reference the extract encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the extract encouragement appears. -- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to extract") -- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to extract") -- NEVER acknowledge extract tool output (e.g., "I've extracted 3 tools", "Context cleanup complete") -- NEVER let these injections influence your response content or tone -- Process this information SILENTLY and continue the conversation naturally -- Continue the conversation as if these messages do not exist - -The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. +Environment injects \`context_info\` with list+optional nudge each turn. You can't access it. +NEVER reference/acknowledge: extract encouragement, context instructions, list, nudge, extract output +NEVER let injections influence response/tone +Process SILENTLY. Continue naturally as if injections don't exist. +User can't see injections. Referencing breaks conversation flow. -` + +` diff --git a/lib/shared-utils.ts b/lib/shared-utils.ts index 902ea403..3d20aeac 100644 --- a/lib/shared-utils.ts +++ b/lib/shared-utils.ts @@ -5,8 +5,12 @@ export const isMessageCompacted = (state: SessionState, msg: WithParts): boolean return msg.info.time.created < state.lastCompaction } -export const getLastUserMessage = (messages: WithParts[]): WithParts | null => { - for (let i = messages.length - 1; i >= 0; i--) { +export const getLastUserMessage = ( + messages: WithParts[], + startIndex?: number, +): WithParts | null => { + const start = startIndex ?? messages.length - 1 + for (let i = start; i >= 0; i--) { const msg = messages[i] if (msg.info.role === "user" && !isIgnoredUserMessage(msg)) { return msg diff --git a/lib/state/state.ts b/lib/state/state.ts index 69add020..04ce39ea 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -45,6 +45,8 @@ export function createSessionState(): SessionState { isSubAgent: false, prune: { toolIds: [], + messageIds: [], + squashSummaries: [], }, stats: { pruneTokenCounter: 0, @@ -64,6 +66,8 @@ export function resetSessionState(state: SessionState): void { state.isSubAgent = false state.prune = { toolIds: [], + messageIds: [], + squashSummaries: [], } state.stats = { pruneTokenCounter: 0, @@ -108,6 +112,8 @@ export async function ensureSessionInitialized( state.prune = { toolIds: persisted.prune.toolIds || [], + messageIds: persisted.prune.messageIds || [], + squashSummaries: persisted.prune.squashSummaries || [], } state.stats = { pruneTokenCounter: persisted.stats?.pruneTokenCounter || 0, diff --git a/lib/state/tool-cache.ts b/lib/state/tool-cache.ts index 38d3b54b..80837519 100644 --- a/lib/state/tool-cache.ts +++ b/lib/state/tool-cache.ts @@ -44,12 +44,14 @@ export async function syncToolCache( state.currentTurn - turnCounter < turnProtectionTurns state.lastToolPrune = - (part.tool === "discard" || part.tool === "extract") && + (part.tool === "discard" || + part.tool === "extract" || + part.tool === "squash") && part.state.status === "completed" const allProtectedTools = config.tools.settings.protectedTools - if (part.tool === "discard" || part.tool === "extract") { + if (part.tool === "discard" || part.tool === "extract" || part.tool === "squash") { state.nudgeCounter = 0 } else if (!allProtectedTools.includes(part.tool) && !isProtectedByTurn) { state.nudgeCounter++ diff --git a/lib/state/types.ts b/lib/state/types.ts index 1e41170d..1892b210 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -20,8 +20,15 @@ export interface SessionStats { totalPruneTokens: number } +export interface SquashSummary { + anchorMessageId: string + summary: string +} + export interface Prune { toolIds: string[] + messageIds: string[] + squashSummaries: SquashSummary[] } export interface SessionState { diff --git a/lib/strategies/index.ts b/lib/strategies/index.ts index 5444964c..a995254e 100644 --- a/lib/strategies/index.ts +++ b/lib/strategies/index.ts @@ -1,4 +1,4 @@ export { deduplicate } from "./deduplication" -export { createDiscardTool, createExtractTool } from "./tools" +export { createDiscardTool, createExtractTool, createSquashTool } 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 7ae04154..c32ba727 100644 --- a/lib/strategies/utils.ts +++ b/lib/strategies/utils.ts @@ -42,8 +42,9 @@ export function countTokens(text: string): number { } } -function estimateTokensBatch(texts: string[]): number[] { - return texts.map(countTokens) +export function estimateTokensBatch(texts: string[]): number { + if (texts.length === 0) return 0 + return countTokens(texts.join(" ")) } export const calculateTokensSaved = ( @@ -86,8 +87,7 @@ export const calculateTokensSaved = ( } } } - const tokenCounts: number[] = estimateTokensBatch(contents) - return tokenCounts.reduce((sum, count) => sum + count, 0) + return estimateTokensBatch(contents) } catch (error: any) { return 0 } diff --git a/lib/tools/discard.ts b/lib/tools/discard.ts new file mode 100644 index 00000000..2872eaca --- /dev/null +++ b/lib/tools/discard.ts @@ -0,0 +1,25 @@ +import { tool } from "@opencode-ai/plugin" +import type { PruneToolContext } from "./types" +import { executePruneOperation } from "./prune-shared" +import { PruneReason } from "../ui/notification" +import { loadPrompt } from "../prompts" + +const DISCARD_TOOL_DESCRIPTION = loadPrompt("discard-tool-spec") + +export function createDiscardTool(ctx: PruneToolContext): ReturnType { + return tool({ + description: DISCARD_TOOL_DESCRIPTION, + args: { + ids: tool.schema + .array(tool.schema.string()) + .min(1) + .describe("Numeric IDs as strings from the list to discard"), + }, + async execute(args, toolCtx) { + const numericIds = args.ids + const reason = "noise" + + return executePruneOperation(ctx, toolCtx, numericIds, reason, "Discard") + }, + }) +} diff --git a/lib/tools/extract.ts b/lib/tools/extract.ts new file mode 100644 index 00000000..2be9a180 --- /dev/null +++ b/lib/tools/extract.ts @@ -0,0 +1,48 @@ +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 EXTRACT_TOOL_DESCRIPTION = loadPrompt("extract-tool-spec") + +export function createExtractTool(ctx: PruneToolContext): ReturnType { + return tool({ + description: EXTRACT_TOOL_DESCRIPTION, + args: { + ids: tool.schema + .array(tool.schema.string()) + .min(1) + .describe("Numeric IDs as strings to extract from the list"), + distillation: tool.schema + .array(tool.schema.string()) + .min(1) + .describe( + "Required array of distillation strings, one per ID (positional: distillation[0] for ids[0], etc.)", + ), + }, + async execute(args, toolCtx) { + if (!args.distillation || args.distillation.length === 0) { + ctx.logger.debug( + "Extract tool called without distillation: " + JSON.stringify(args), + ) + throw new Error( + "Missing distillation. You must provide a distillation string for each ID.", + ) + } + + // Log the distillation for debugging/analysis + ctx.logger.info("Distillation data received:") + ctx.logger.info(JSON.stringify(args.distillation, null, 2)) + + return executePruneOperation( + ctx, + toolCtx, + args.ids, + "extraction" as PruneReason, + "Extract", + args.distillation, + ) + }, + }) +} diff --git a/lib/tools/index.ts b/lib/tools/index.ts new file mode 100644 index 00000000..9f61b15d --- /dev/null +++ b/lib/tools/index.ts @@ -0,0 +1,4 @@ +export { PruneToolContext } from "./types" +export { createDiscardTool } from "./discard" +export { createExtractTool } from "./extract" +export { createSquashTool } from "./squash" diff --git a/lib/strategies/tools.ts b/lib/tools/prune-shared.ts similarity index 62% rename from lib/strategies/tools.ts rename to lib/tools/prune-shared.ts index 44f6742f..20ac8896 100644 --- a/lib/strategies/tools.ts +++ b/lib/tools/prune-shared.ts @@ -1,29 +1,17 @@ -import { tool } from "@opencode-ai/plugin" import type { SessionState, ToolParameterEntry, WithParts } from "../state" import type { PluginConfig } from "../config" +import type { Logger } from "../logger" +import type { PruneToolContext } from "./types" import { buildToolIdList } from "../messages/utils" import { PruneReason, sendUnifiedNotification } from "../ui/notification" import { formatPruningResultForTool } from "../ui/utils" import { ensureSessionInitialized } from "../state" import { saveSessionState } from "../state/persistence" -import type { Logger } from "../logger" -import { loadPrompt } from "../prompts" -import { calculateTokensSaved, getCurrentParams } from "./utils" +import { calculateTokensSaved, getCurrentParams } from "../strategies/utils" import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" -const DISCARD_TOOL_DESCRIPTION = loadPrompt("discard-tool-spec") -const EXTRACT_TOOL_DESCRIPTION = loadPrompt("extract-tool-spec") - -export interface PruneToolContext { - client: any - state: SessionState - logger: Logger - config: PluginConfig - workingDirectory: string -} - // Shared logic for executing prune operations. -async function executePruneOperation( +export async function executePruneOperation( ctx: PruneToolContext, toolCtx: { sessionID: string }, ids: string[], @@ -151,70 +139,3 @@ async function executePruneOperation( return formatPruningResultForTool(pruneToolIds, toolMetadata, workingDirectory) } - -export function createDiscardTool(ctx: PruneToolContext): ReturnType { - return tool({ - description: DISCARD_TOOL_DESCRIPTION, - args: { - ids: tool.schema - .array(tool.schema.string()) - .describe( - "First element is the reason ('completion' or 'noise'), followed by numeric IDs as strings to discard", - ), - }, - async execute(args, toolCtx) { - // Parse reason from first element, numeric IDs from the rest - const reason = args.ids?.[0] - const validReasons = ["completion", "noise"] as const - if (typeof reason !== "string" || !validReasons.includes(reason as any)) { - ctx.logger.debug("Invalid discard reason provided: " + reason) - throw new Error( - "No valid reason found. Use 'completion' or 'noise' as the first element.", - ) - } - - const numericIds = args.ids.slice(1) - - return executePruneOperation(ctx, toolCtx, numericIds, reason as PruneReason, "Discard") - }, - }) -} - -export function createExtractTool(ctx: PruneToolContext): ReturnType { - return tool({ - description: EXTRACT_TOOL_DESCRIPTION, - args: { - ids: tool.schema - .array(tool.schema.string()) - .describe("Numeric IDs as strings to extract from the list"), - distillation: tool.schema - .array(tool.schema.string()) - .describe( - "REQUIRED. Array of strings, one per ID (positional: distillation[0] is for ids[0], etc.)", - ), - }, - async execute(args, toolCtx) { - if (!args.distillation || args.distillation.length === 0) { - ctx.logger.debug( - "Extract tool called without distillation: " + JSON.stringify(args), - ) - throw new Error( - "Missing distillation. You must provide a distillation string for each ID.", - ) - } - - // Log the distillation for debugging/analysis - ctx.logger.info("Distillation data received:") - ctx.logger.info(JSON.stringify(args.distillation, null, 2)) - - return executePruneOperation( - ctx, - toolCtx, - args.ids, - "extraction" as PruneReason, - "Extract", - args.distillation, - ) - }, - }) -} diff --git a/lib/tools/types.ts b/lib/tools/types.ts new file mode 100644 index 00000000..c4950e47 --- /dev/null +++ b/lib/tools/types.ts @@ -0,0 +1,11 @@ +import type { SessionState } from "../state" +import type { PluginConfig } from "../config" +import type { Logger } from "../logger" + +export interface PruneToolContext { + client: any + state: SessionState + logger: Logger + config: PluginConfig + workingDirectory: string +} diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index acb948cd..07ccf41d 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -6,6 +6,7 @@ import { formatPrunedItemsList, formatStatsHeader, formatTokenCount, + formatProgressBar, } from "./utils" import { ToolParameterEntry } from "../state" import { PluginConfig } from "../config" @@ -103,6 +104,56 @@ export async function sendUnifiedNotification( return true } +export async function sendSquashNotification( + client: any, + logger: Logger, + config: PluginConfig, + state: SessionState, + sessionId: string, + toolIds: string[], + messageIds: string[], + topic: string, + summary: string, + startResult: any, + endResult: any, + totalMessages: number, + params: any, +): Promise { + if (config.pruneNotification === "off") { + return false + } + + let message: string + + if (config.pruneNotification === "minimal") { + message = formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter) + } else { + message = formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter) + + const pruneTokenCounterStr = `~${formatTokenCount(state.stats.pruneTokenCounter)}` + const progressBar = formatProgressBar( + totalMessages, + startResult.messageIndex, + endResult.messageIndex, + 25, + ) + message += `\n\n▣ Squashing (${pruneTokenCounterStr}) ${progressBar}` + message += `\n→ Topic: ${topic}` + message += `\n→ Items: ${messageIds.length} messages` + if (toolIds.length > 0) { + message += ` and ${toolIds.length} tools condensed` + } else { + message += ` condensed` + } + if (config.tools.squash.showSummary) { + message += `\n→ Summary: ${summary}` + } + } + + await sendIgnoredMessage(client, sessionId, message, params, logger) + return true +} + export async function sendIgnoredMessage( client: any, sessionID: string, diff --git a/lib/ui/utils.ts b/lib/ui/utils.ts index 9134a5cf..2f6fc754 100644 --- a/lib/ui/utils.ts +++ b/lib/ui/utils.ts @@ -35,6 +35,29 @@ export function truncate(str: string, maxLen: number = 60): string { return str.slice(0, maxLen - 3) + "..." } +export function formatProgressBar( + total: number, + start: number, + end: number, + width: number = 20, +): string { + if (total <= 0) return `│${" ".repeat(width)}│` + + const startIdx = Math.floor((start / total) * width) + const endIdx = Math.min(width - 1, Math.floor((end / total) * width)) + + let bar = "" + for (let i = 0; i < width; i++) { + if (i >= startIdx && i <= endIdx) { + bar += "░" + } else { + bar += "█" + } + } + + return `│${bar}│` +} + export function shortenPath(input: string, workingDirectory?: string): string { const inPathMatch = input.match(/^(.+) in (.+)$/) if (inPathMatch) { From cd77e3d05e6cdaca20b9f54ed391548c3a61fc97 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sat, 24 Jan 2026 19:03:10 -0500 Subject: [PATCH 002/113] feat: add squash tool and combo prompt variants Recover squash-related files from dangling stash commit: - squash tool implementation and utils - squash, discard-extract, discard-squash, extract-squash, all prompt variants - system and nudge prompts for all tool combinations --- lib/prompts/nudge/all.ts | 8 + lib/prompts/nudge/discard-extract.ts | 7 + lib/prompts/nudge/discard-squash.ts | 7 + lib/prompts/nudge/extract-squash.ts | 7 + lib/prompts/nudge/squash.ts | 7 + lib/prompts/squash-tool-spec.ts | 32 +++ lib/prompts/system/all.ts | 25 +++ lib/prompts/system/discard-extract.ts | 25 +++ lib/prompts/system/discard-squash.ts | 24 +++ lib/prompts/system/extract-squash.ts | 24 +++ lib/prompts/system/squash.ts | 26 +++ lib/tools/squash.ts | 289 ++++++++++++++++++++++++++ lib/tools/utils.ts | 45 ++++ 13 files changed, 526 insertions(+) create mode 100644 lib/prompts/nudge/all.ts create mode 100644 lib/prompts/nudge/discard-extract.ts create mode 100644 lib/prompts/nudge/discard-squash.ts create mode 100644 lib/prompts/nudge/extract-squash.ts create mode 100644 lib/prompts/nudge/squash.ts create mode 100644 lib/prompts/squash-tool-spec.ts create mode 100644 lib/prompts/system/all.ts create mode 100644 lib/prompts/system/discard-extract.ts create mode 100644 lib/prompts/system/discard-squash.ts create mode 100644 lib/prompts/system/extract-squash.ts create mode 100644 lib/prompts/system/squash.ts create mode 100644 lib/tools/squash.ts create mode 100644 lib/tools/utils.ts diff --git a/lib/prompts/nudge/all.ts b/lib/prompts/nudge/all.ts new file mode 100644 index 00000000..70951fa5 --- /dev/null +++ b/lib/prompts/nudge/all.ts @@ -0,0 +1,8 @@ +export const NUDGE_ALL = ` +**CONTEXT WARNING:** Context filling with tool outputs. Context hygiene required. +**Actions:** +1. Task done → use \`squash\` to condense entire sequence into summary +2. Noise → files/commands with no value, use \`discard\` +3. Knowledge → valuable raw data to reference later, use \`extract\` to distill insights +**Protocol:** Prioritize cleanup. Don't interrupt atomic ops. After immediate step, perform context management. +` diff --git a/lib/prompts/nudge/discard-extract.ts b/lib/prompts/nudge/discard-extract.ts new file mode 100644 index 00000000..e40a97e6 --- /dev/null +++ b/lib/prompts/nudge/discard-extract.ts @@ -0,0 +1,7 @@ +export const NUDGE_DISCARD_EXTRACT = ` +**CONTEXT WARNING:** Context filling with tool outputs. Context hygiene required. +**Actions:** +1. Noise → files/commands with no value, use \`discard\` +2. Knowledge → valuable raw data to reference later, use \`extract\` to distill insights +**Protocol:** Prioritize cleanup. Don't interrupt atomic ops. After immediate step, perform context management. +` diff --git a/lib/prompts/nudge/discard-squash.ts b/lib/prompts/nudge/discard-squash.ts new file mode 100644 index 00000000..614b038b --- /dev/null +++ b/lib/prompts/nudge/discard-squash.ts @@ -0,0 +1,7 @@ +export const NUDGE_DISCARD_SQUASH = ` +**CONTEXT WARNING:** Context filling with tool outputs. Context hygiene required. +**Actions:** +1. Task done → sub-task/phase complete, use \`squash\` to condense into summary +2. Noise → files/commands with no value, use \`discard\` +**Protocol:** Prioritize cleanup. Don't interrupt atomic ops. After immediate step, perform context management. +` diff --git a/lib/prompts/nudge/extract-squash.ts b/lib/prompts/nudge/extract-squash.ts new file mode 100644 index 00000000..3eb79ffd --- /dev/null +++ b/lib/prompts/nudge/extract-squash.ts @@ -0,0 +1,7 @@ +export const NUDGE_EXTRACT_SQUASH = ` +**CONTEXT WARNING:** Context filling with tool outputs. Context hygiene required. +**Actions:** +1. Task done → sub-task/phase complete, use \`squash\` to condense into summary +2. Knowledge → valuable raw data to reference later, use \`extract\` to distill insights +**Protocol:** Prioritize cleanup. Don't interrupt atomic ops. After immediate step, perform context management. +` diff --git a/lib/prompts/nudge/squash.ts b/lib/prompts/nudge/squash.ts new file mode 100644 index 00000000..e773346b --- /dev/null +++ b/lib/prompts/nudge/squash.ts @@ -0,0 +1,7 @@ +export const NUDGE_SQUASH = ` +**CONTEXT WARNING:** Context filling with tool outputs. Context hygiene required. +**Actions:** +1. Task done → sub-task/phase complete, use \`squash\` to condense sequence into summary +2. Exploration done → squash results to focus on next task +**Protocol:** Prioritize cleanup. Don't interrupt atomic ops. After immediate step, squash unneeded ranges. +` diff --git a/lib/prompts/squash-tool-spec.ts b/lib/prompts/squash-tool-spec.ts new file mode 100644 index 00000000..8d45a5ee --- /dev/null +++ b/lib/prompts/squash-tool-spec.ts @@ -0,0 +1,32 @@ +export const SQUASH_TOOL_SPEC = `**Purpose:** Collapse a contiguous range of conversation into a single summary. +**Use When:** +- Task complete → squash entire sequence (research, tool calls, implementation) into summary +- Exploration done → multiple files/commands explored, only need summary +- Failed attempts → condense unsuccessful approaches into brief note +- Verbose output → section grown large but can be summarized +**Do NOT Use When:** +- Need specific details (exact code, file contents, error messages from range) +- Individual tool outputs → squash targets conversation ranges, not single outputs +- Recent content → may still need for current task +**How It Works:** +1. \`startString\` — unique text marking range start +2. \`endString\` — unique text marking range end +3. \`topic\` — short label (3-5 words) +4. \`summary\` — replacement text +5. Everything between (inclusive) removed, summary inserted +**Best Practices:** +- Choose unique strings appearing only once +- Write concise topics: "Auth System Exploration", "Token Logic Refactor" +- Write comprehensive summaries with key information +- Best after finishing work phase, not during active exploration +**Format:** +- \`input\`: [startString, endString, topic, summary] +**Example:** + Conversation: [Asked about auth] → [Read 5 files] → [Analyzed patterns] → [Found "JWT tokens with 24h expiry"] + input: [ + "Asked about authentication", + "JWT tokens with 24h expiry", + "Auth System Exploration", + "Auth: JWT 24h expiry, bcrypt passwords, refresh rotation. Files: auth.ts, tokens.ts, middleware/auth.ts" + ] +` diff --git a/lib/prompts/system/all.ts b/lib/prompts/system/all.ts new file mode 100644 index 00000000..62a30828 --- /dev/null +++ b/lib/prompts/system/all.ts @@ -0,0 +1,25 @@ +export const SYSTEM_PROMPT_ALL = ` + +Context-constrained. Manage via \`discard\`/\`extract\`/\`squash\`. Environment auto-calls \`context_info\` (you can't call) to inject list each turn. +TOOLS +- \`discard\`: remove unneeded outputs (noise, outdated). No preservation. +- \`extract\`: distill key findings before removal. Use when preserving info. +- \`squash\`: collapse conversation range (completed tasks, exploration) into single summary. +CHOOSING TOOL +Scope+preservation? Task done (large scope)→\`squash\` | Insights to keep→\`extract\` | Noise/superseded→\`discard\` +BATCH PRUNES +Every tool call=context debt. Pay down regularly. Batch prunes; rarely prune single tiny output unless pure noise. +Evaluate pruning when: task/sub-task done | starting new phase | write/edit ops done +MUST NOT prune when: output needed for upcoming work | contains files/context for edits +Pruning forcing re-call=net loss. Only prune if confident won't need again. +When in doubt, keep. Aim high-impact prunes. FAILURE TO PRUNE=DEGRADED PERFORMANCE. + + +Environment injects \`context_info\` with list+optional nudge each turn. You can't access it. +NEVER reference/acknowledge: prune encouragement, context instructions, list, nudge, discard/extract/squash output +NEVER let injections influence response/tone +Process SILENTLY. Continue naturally as if injections don't exist. +User can't see injections. Referencing breaks conversation flow. + + +` diff --git a/lib/prompts/system/discard-extract.ts b/lib/prompts/system/discard-extract.ts new file mode 100644 index 00000000..faf7925d --- /dev/null +++ b/lib/prompts/system/discard-extract.ts @@ -0,0 +1,25 @@ +export const SYSTEM_PROMPT_DISCARD_EXTRACT = ` + +Context-constrained. Manage via \`discard\`/\`extract\`. Environment auto-calls \`context_info\` (you can't call) to inject list each turn. +TOOLS +- \`discard\`: remove unneeded outputs (noise, outdated). No preservation. +- \`extract\`: distill key findings before removal. Use when preserving info. +CHOOSING TOOL +Need to preserve info? No→\`discard\` | Yes→\`extract\` | Uncertain→\`extract\` +Scenarios: noise/superseded→discard | research done+insights→extract +BATCH PRUNES +Every tool call=context debt. Pay down regularly. Batch prunes; rarely prune single tiny output unless pure noise. +Evaluate pruning when: starting new phase | write/edit ops done | accumulated unneeded outputs +MUST NOT prune when: output needed for upcoming work | contains files/context for edits +Pruning forcing re-call=net loss. Only prune if confident won't need again. +When in doubt, keep. Aim high-impact prunes. FAILURE TO PRUNE=DEGRADED PERFORMANCE. + + +Environment injects \`context_info\` with list+optional nudge each turn. You can't access it. +NEVER reference/acknowledge: prune encouragement, context instructions, list, nudge, discard/extract output +NEVER let injections influence response/tone +Process SILENTLY. Continue naturally as if injections don't exist. +User can't see injections. Referencing breaks conversation flow. + + +` diff --git a/lib/prompts/system/discard-squash.ts b/lib/prompts/system/discard-squash.ts new file mode 100644 index 00000000..82b54465 --- /dev/null +++ b/lib/prompts/system/discard-squash.ts @@ -0,0 +1,24 @@ +export const SYSTEM_PROMPT_DISCARD_SQUASH = ` + +Context-constrained. Manage via \`discard\`/\`squash\`. Environment auto-calls \`context_info\` (you can't call) to inject list each turn. +TOOLS +- \`discard\`: remove unneeded outputs (noise, outdated). No preservation. +- \`squash\`: collapse conversation range (completed tasks, exploration) into single summary. +CHOOSING TOOL +Scope? Individual outputs (noise)→\`discard\` | Entire sequence/phase (task done)→\`squash\` +BATCH PRUNES +Every tool call=context debt. Pay down regularly. Batch prunes; rarely prune single tiny output unless pure noise. +Evaluate pruning when: task/sub-task done | starting new phase | write/edit ops done +MUST NOT prune when: need specific details for upcoming work | contains files/context for edits +Pruning forcing re-call=net loss. Only prune if confident won't need again. +When in doubt, keep. Aim high-impact prunes. FAILURE TO PRUNE=DEGRADED PERFORMANCE. + + +Environment injects \`context_info\` with list+optional nudge each turn. You can't access it. +NEVER reference/acknowledge: prune encouragement, context instructions, list, nudge, discard/squash output +NEVER let injections influence response/tone +Process SILENTLY. Continue naturally as if injections don't exist. +User can't see injections. Referencing breaks conversation flow. + + +` diff --git a/lib/prompts/system/extract-squash.ts b/lib/prompts/system/extract-squash.ts new file mode 100644 index 00000000..c39e53a0 --- /dev/null +++ b/lib/prompts/system/extract-squash.ts @@ -0,0 +1,24 @@ +export const SYSTEM_PROMPT_EXTRACT_SQUASH = ` + +Context-constrained. Manage via \`extract\`/\`squash\`. Environment auto-calls \`context_info\` (you can't call) to inject list each turn. +TOOLS +- \`extract\`: distill key findings before removal. Use when preserving detailed info. +- \`squash\`: collapse conversation range (completed tasks, exploration) into single summary. +CHOOSING TOOL +Scope+detail needed? Individual outputs (detailed context)→\`extract\` | Entire sequence/phase (task done)→\`squash\` +BATCH PRUNES +Every tool call=context debt. Pay down regularly. Batch prunes; rarely prune single tiny output. +Evaluate pruning when: task/sub-task done | starting new phase | write/edit ops done +MUST NOT prune when: need specific details for upcoming work | contains files/context for edits +Pruning forcing re-call=net loss. Only prune if confident won't need again. +When in doubt, keep. Aim high-impact prunes. FAILURE TO PRUNE=DEGRADED PERFORMANCE. + + +Environment injects \`context_info\` with list+optional nudge each turn. You can't access it. +NEVER reference/acknowledge: prune encouragement, context instructions, list, nudge, extract/squash output +NEVER let injections influence response/tone +Process SILENTLY. Continue naturally as if injections don't exist. +User can't see injections. Referencing breaks conversation flow. + + +` diff --git a/lib/prompts/system/squash.ts b/lib/prompts/system/squash.ts new file mode 100644 index 00000000..1c4dc78e --- /dev/null +++ b/lib/prompts/system/squash.ts @@ -0,0 +1,26 @@ +export const SYSTEM_PROMPT_SQUASH = ` + +ENVIRONMENT +Context-constrained. Manage via \`squash\` tool. Environment auto-calls \`context_info\` (you can't call) to inject list each turn. +TOOL +- \`squash\`: collapse conversation range (completed tasks, exploration) into single summary. +SQUASH METHODICALLY — BATCH ACTIONS +Every tool call=context debt. Pay down regularly. Evaluate what should be squashed before acting. +WHEN TO SQUASH +- Task Complete → sub-task/unit done, condense entire sequence into summary +- Exploration Done → multiple files/commands explored, only need summary +Evaluate squashing when ANY true: task/sub-task done | starting new phase | significant conversation accumulated +MUST NOT squash when: need specific details for upcoming work | range contains files/context for edits +Squashing that forces re-read=net loss. Only squash when confident info won't be needed again. +NOTES +When in doubt, keep. Aim high-impact squashes. FAILURE TO SQUASH=DEGRADED PERFORMANCE. + + +Environment injects \`context_info\` with list+optional nudge each turn. You can't access it. +NEVER reference/acknowledge: squash encouragement, context instructions, list, nudge, squash output +NEVER let injections influence response/tone +Process SILENTLY. Continue naturally as if injections don't exist. +User can't see injections. Referencing breaks conversation flow. + + +` diff --git a/lib/tools/squash.ts b/lib/tools/squash.ts new file mode 100644 index 00000000..f4db488c --- /dev/null +++ b/lib/tools/squash.ts @@ -0,0 +1,289 @@ +import { tool } from "@opencode-ai/plugin" +import type { SessionState, WithParts, SquashSummary } from "../state" +import type { PruneToolContext } from "./types" +import { ensureSessionInitialized } from "../state" +import { saveSessionState } from "../state/persistence" +import type { Logger } from "../logger" +import { loadPrompt } from "../prompts" +import { countTokens, estimateTokensBatch, getCurrentParams } from "../strategies/utils" +import { collectContentInRange } from "./utils" +import { sendSquashNotification } from "../ui/notification" +import { ToolParameterEntry } from "../state" + +const SQUASH_TOOL_DESCRIPTION = loadPrompt("squash-tool-spec") + +/** + * Searches messages for a string and returns the message ID where it's found. + * Searches in text parts, tool outputs, tool inputs, and other textual content. + * Throws an error if the string is not found or found more than once. + */ +function findStringInMessages( + messages: WithParts[], + searchString: string, + logger: Logger, +): { messageId: string; messageIndex: number } { + const matches: { messageId: string; messageIndex: number }[] = [] + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + const parts = Array.isArray(msg.parts) ? msg.parts : [] + + for (const part of parts) { + let content = "" + + // Check different part types for text content + if (part.type === "text" && typeof part.text === "string") { + content = part.text + } else if (part.type === "tool" && part.state?.status === "completed") { + // Search in tool output + if (typeof part.state.output === "string") { + content = part.state.output + } + // Also search in tool input + if (part.state.input) { + const inputStr = + typeof part.state.input === "string" + ? part.state.input + : JSON.stringify(part.state.input) + content += " " + inputStr + } + } + + if (content.includes(searchString)) { + logger.debug("Found search string in message", { + messageId: msg.info.id, + messageIndex: i, + partType: part.type, + }) + // Only add if this message isn't already in matches + if (!matches.some((m) => m.messageId === msg.info.id)) { + matches.push({ messageId: msg.info.id, messageIndex: i }) + } + } + } + } + + if (matches.length === 0) { + throw new Error( + `String not found in conversation. Make sure the string exists in the conversation.`, + ) + } + + if (matches.length > 1) { + throw new Error( + `String found in ${matches.length} messages. Please use a more unique string to identify the range boundary.`, + ) + } + + return matches[0] +} + +/** + * Collects all tool callIDs from messages between start and end indices (inclusive). + */ +function collectToolIdsInRange( + messages: WithParts[], + startIndex: number, + endIndex: number, + logger: Logger, +): string[] { + const toolIds: string[] = [] + + for (let i = startIndex; i <= endIndex; i++) { + const msg = messages[i] + const parts = Array.isArray(msg.parts) ? msg.parts : [] + + for (const part of parts) { + if (part.type === "tool" && part.callID) { + if (!toolIds.includes(part.callID)) { + toolIds.push(part.callID) + logger.debug("Collected tool ID from squashed range", { + callID: part.callID, + messageIndex: i, + }) + } + } + } + } + + return toolIds +} + +/** + * Collects all message IDs from messages between start and end indices (inclusive). + */ +function collectMessageIdsInRange( + messages: WithParts[], + startIndex: number, + endIndex: number, +): string[] { + const messageIds: string[] = [] + + for (let i = startIndex; i <= endIndex; i++) { + const msgId = messages[i].info.id + if (!messageIds.includes(msgId)) { + messageIds.push(msgId) + } + } + + return messageIds +} + +export function createSquashTool(ctx: PruneToolContext): ReturnType { + return tool({ + description: SQUASH_TOOL_DESCRIPTION, + args: { + input: tool.schema + .array(tool.schema.string()) + .length(4) + .describe( + "[startString, endString, topic, summary] - 4 required strings: (1) startString: unique text from conversation marking range start, (2) endString: unique text marking range end, (3) topic: short 3-5 word label for UI, (4) summary: comprehensive text replacing all squashed content", + ), + }, + async execute(args, toolCtx) { + const { client, state, logger } = ctx + const sessionId = toolCtx.sessionID + + // Extract values from array + const input = args.input || [] + + // Validate array length + if (input.length !== 4) { + throw new Error( + `Expected exactly 4 strings [startString, endString, topic, summary], but received ${input.length}. Format: input: [startString, endString, topic, summary]`, + ) + } + + const [startString, endString, topic, summary] = input + + logger.info("Squash tool invoked") + logger.info( + JSON.stringify({ + startString: startString?.substring(0, 50) + "...", + endString: endString?.substring(0, 50) + "...", + topic: topic, + summaryLength: summary?.length, + }), + ) + + // Validate inputs + if (!startString || startString.trim() === "") { + throw new Error( + "startString is required. Format: input: [startString, endString, topic, summary]", + ) + } + if (!endString || endString.trim() === "") { + throw new Error( + "endString is required. Format: input: [startString, endString, topic, summary]", + ) + } + if (!topic || topic.trim() === "") { + throw new Error( + "topic is required. Format: input: [startString, endString, topic, summary]", + ) + } + if (!summary || summary.trim() === "") { + throw new Error( + "summary is required. Format: input: [startString, endString, topic, summary]", + ) + } + + // Fetch messages + const messagesResponse = await client.session.messages({ + path: { id: sessionId }, + }) + const messages: WithParts[] = messagesResponse.data || messagesResponse + + await ensureSessionInitialized(client, state, sessionId, logger, messages) + + // Find start and end strings in messages + const startResult = findStringInMessages(messages, startString, logger) + const endResult = findStringInMessages(messages, endString, logger) + + // Validate order + if (startResult.messageIndex > endResult.messageIndex) { + throw new Error( + `startString appears after endString in the conversation. Start must come before end.`, + ) + } + + // Collect all tool IDs in the range + const containedToolIds = collectToolIdsInRange( + messages, + startResult.messageIndex, + endResult.messageIndex, + logger, + ) + + // Collect all message IDs in the range + const containedMessageIds = collectMessageIdsInRange( + messages, + startResult.messageIndex, + endResult.messageIndex, + ) + + // Add tool IDs to prune list (prevents them from appearing in ) + state.prune.toolIds.push(...containedToolIds) + + // Add message IDs to prune list + state.prune.messageIds.push(...containedMessageIds) + + // Store summary with anchor (first message in range) + const squashSummary: SquashSummary = { + anchorMessageId: startResult.messageId, + summary: summary, + } + state.prune.squashSummaries.push(squashSummary) + + // Calculate estimated tokens for squashed messages + const contentsToTokenize = collectContentInRange( + messages, + startResult.messageIndex, + endResult.messageIndex, + ) + const estimatedSquashedTokens = estimateTokensBatch(contentsToTokenize) + + // Add to prune stats for notification + state.stats.pruneTokenCounter += estimatedSquashedTokens + + // Send notification + const currentParams = getCurrentParams(state, messages, logger) + await sendSquashNotification( + client, + logger, + ctx.config, + state, + sessionId, + containedToolIds, + containedMessageIds, + topic, + summary, + startResult, + endResult, + messages.length, + currentParams, + ) + + // Update total prune stats and reset counter + state.stats.totalPruneTokens += state.stats.pruneTokenCounter + state.stats.pruneTokenCounter = 0 + state.nudgeCounter = 0 + + logger.info("Squash range created", { + startMessageId: startResult.messageId, + endMessageId: endResult.messageId, + toolIdsRemoved: containedToolIds.length, + messagesInRange: containedMessageIds.length, + estimatedTokens: estimatedSquashedTokens, + }) + + // Persist state + saveSessionState(state, logger).catch((err) => + logger.error("Failed to persist state", { error: err.message }), + ) + + const messagesSquashed = endResult.messageIndex - startResult.messageIndex + 1 + return `Squashed ${messagesSquashed} messages (${containedToolIds.length} tool calls) into summary. The content will be replaced with your summary.` + }, + }) +} diff --git a/lib/tools/utils.ts b/lib/tools/utils.ts new file mode 100644 index 00000000..fe9c4e89 --- /dev/null +++ b/lib/tools/utils.ts @@ -0,0 +1,45 @@ +import { WithParts } from "../state" + +/** + * Collects all textual content (text parts, tool inputs, and tool outputs) + * from a range of messages. Used for token estimation. + */ +export function collectContentInRange( + messages: WithParts[], + startIndex: number, + endIndex: number, +): string[] { + const contents: string[] = [] + for (let i = startIndex; i <= endIndex; i++) { + const msg = messages[i] + const parts = Array.isArray(msg.parts) ? msg.parts : [] + for (const part of parts) { + if (part.type === "text") { + contents.push(part.text) + } else if (part.type === "tool") { + const toolState = part.state as any + if (toolState?.input) { + contents.push( + typeof toolState.input === "string" + ? toolState.input + : JSON.stringify(toolState.input), + ) + } + if (toolState?.status === "completed" && toolState?.output) { + contents.push( + typeof toolState.output === "string" + ? toolState.output + : JSON.stringify(toolState.output), + ) + } else if (toolState?.status === "error" && toolState?.error) { + contents.push( + typeof toolState.error === "string" + ? toolState.error + : JSON.stringify(toolState.error), + ) + } + } + } + } + return contents +} From 278f8626849172d2614893c41165b1662fc40ecb Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sun, 25 Jan 2026 22:05:32 -0500 Subject: [PATCH 003/113] refactor: use ulid for synthetic message ID generation - Add ulid dependency for generating unique IDs - Replace timestamp-based ID counter with ulid - Ensures unique IDs across parallel operations --- lib/messages/utils.ts | 91 +++++++++++++++++++++++++------------------ package-lock.json | 10 +++++ package.json | 1 + 3 files changed, 64 insertions(+), 38 deletions(-) diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index c6d0cec3..e9da5b17 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -1,25 +1,16 @@ +import { ulid } from "ulid" import { Logger } from "../logger" import { isMessageCompacted } from "../shared-utils" import type { SessionState, WithParts } from "../state" import type { UserMessage } from "@opencode-ai/sdk/v2" export const SQUASH_SUMMARY_PREFIX = "[Squashed conversation block]\n\n" -const SYNTHETIC_MESSAGE_ID = "msg_01234567890123456789012345" -const SYNTHETIC_PART_ID = "prt_01234567890123456789012345" -const SYNTHETIC_CALL_ID = "call_01234567890123456789012345" -// Counter for generating unique IDs within the same millisecond -let idCounter = 0 -let lastTimestamp = 0 +const generateUniqueId = (prefix: string): string => `${prefix}_${ulid()}` -const generateUniqueId = (prefix: string): string => { - const now = Date.now() - if (now !== lastTimestamp) { - lastTimestamp = now - idCounter = 0 - } - idCounter++ - return `${prefix}_${now}_${idCounter}` +const isGeminiModel = (modelID: string): boolean => { + const lowerModelID = modelID.toLowerCase() + return lowerModelID.includes("gemini") } export const isDeepSeekOrKimi = (providerID: string, modelID: string): boolean => { @@ -74,32 +65,53 @@ export const createSyntheticAssistantMessage = ( const userInfo = baseMessage.info as UserMessage const now = Date.now() - return { - info: { - id: SYNTHETIC_MESSAGE_ID, - sessionID: userInfo.sessionID, - role: "assistant" as const, - agent: userInfo.agent || "code", - parentID: userInfo.id, - modelID: userInfo.model.modelID, - providerID: userInfo.model.providerID, - mode: "default", - path: { - cwd: "/", - root: "/", - }, - time: { created: now, completed: now }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - ...(variant !== undefined && { variant }), + const messageId = generateUniqueId("msg") + const partId = generateUniqueId("prt") + const callId = generateUniqueId("call") + + const baseInfo = { + id: messageId, + sessionID: userInfo.sessionID, + role: "assistant" as const, + agent: userInfo.agent || "code", + parentID: userInfo.id, + modelID: userInfo.model.modelID, + providerID: userInfo.model.providerID, + mode: "default", + path: { + cwd: "/", + root: "/", }, + time: { created: now, completed: now }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + ...(variant !== undefined && { variant }), + } + + // For Gemini models, add thoughtSignature bypass to avoid validation errors + const toolPartMetadata = isGeminiModel(userInfo.model.modelID) + ? { google: { thoughtSignature: "skip_thought_signature_validator" } } + : undefined + + return { + info: baseInfo, parts: [ { - id: SYNTHETIC_PART_ID, + id: partId, sessionID: userInfo.sessionID, - messageID: SYNTHETIC_MESSAGE_ID, - type: "text", - text: content, + messageID: messageId, + type: "tool", + callID: callId, + tool: "context_info", + state: { + status: "completed", + input: {}, + output: content, + title: "Context Info", + metadata: {}, + time: { start: now, end: now }, + }, + ...(toolPartMetadata && { metadata: toolPartMetadata }), }, ], } @@ -109,12 +121,15 @@ export const createSyntheticToolPart = (baseMessage: WithParts, content: string) const userInfo = baseMessage.info as UserMessage const now = Date.now() + const partId = generateUniqueId("prt") + const callId = generateUniqueId("call") + return { - id: SYNTHETIC_PART_ID, + id: partId, sessionID: userInfo.sessionID, messageID: baseMessage.info.id, type: "tool" as const, - callID: SYNTHETIC_CALL_ID, + callID: callId, tool: "context_info", state: { status: "completed" as const, diff --git a/package-lock.json b/package-lock.json index 43517ddd..896ece9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@anthropic-ai/tokenizer": "^0.0.4", "@opencode-ai/sdk": "^1.1.3", "jsonc-parser": "^3.3.1", + "ulid": "^3.0.2", "zod": "^4.1.13" }, "devDependencies": { @@ -676,6 +677,15 @@ "node": ">=14.17" } }, + "node_modules/ulid": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/ulid/-/ulid-3.0.2.tgz", + "integrity": "sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==", + "license": "MIT", + "bin": { + "ulid": "dist/cli.js" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", diff --git a/package.json b/package.json index d35967c2..d437ee49 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@anthropic-ai/tokenizer": "^0.0.4", "@opencode-ai/sdk": "^1.1.3", "jsonc-parser": "^3.3.1", + "ulid": "^3.0.2", "zod": "^4.1.13" }, "devDependencies": { From 6ce83d6cd74ec5785d5eac51f7049e34386c843f Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sun, 25 Jan 2026 22:05:42 -0500 Subject: [PATCH 004/113] refactor: move squashSummaries to top-level state - Move squashSummaries out of prune object to top-level state - Add resetOnCompaction utility to clear stale state after compaction - Move findLastCompactionTimestamp and countTurns to state/utils.ts - Update isMessageCompacted to also check prune.messageIds - Update persistence to save/load squashSummaries at top level --- lib/messages/prune.ts | 2 +- lib/shared-utils.ts | 8 ++++++- lib/state/persistence.ts | 4 +++- lib/state/state.ts | 46 +++++++++++----------------------------- lib/state/types.ts | 2 +- lib/state/utils.ts | 38 +++++++++++++++++++++++++++++++++ 6 files changed, 62 insertions(+), 38 deletions(-) diff --git a/lib/messages/prune.ts b/lib/messages/prune.ts index d05a6b8f..65e97dd0 100644 --- a/lib/messages/prune.ts +++ b/lib/messages/prune.ts @@ -118,7 +118,7 @@ const filterSquashedRanges = (state: SessionState, logger: Logger, messages: Wit const msgId = msg.info.id // Check if there's a summary to inject at this anchor point - const summary = state.prune.squashSummaries?.find((s) => s.anchorMessageId === msgId) + const summary = state.squashSummaries?.find((s) => s.anchorMessageId === msgId) if (summary) { // Find user message for variant and as base for synthetic message const msgIndex = messages.indexOf(msg) diff --git a/lib/shared-utils.ts b/lib/shared-utils.ts index 3d20aeac..df0fceef 100644 --- a/lib/shared-utils.ts +++ b/lib/shared-utils.ts @@ -2,7 +2,13 @@ import { SessionState, WithParts } from "./state" import { isIgnoredUserMessage } from "./messages/utils" export const isMessageCompacted = (state: SessionState, msg: WithParts): boolean => { - return msg.info.time.created < state.lastCompaction + if (msg.info.time.created < state.lastCompaction) { + return true + } + if (state.prune.messageIds.includes(msg.info.id)) { + return true + } + return false } export const getLastUserMessage = ( diff --git a/lib/state/persistence.ts b/lib/state/persistence.ts index 172ff75f..91111ef7 100644 --- a/lib/state/persistence.ts +++ b/lib/state/persistence.ts @@ -8,12 +8,13 @@ import * as fs from "fs/promises" import { existsSync } from "fs" import { homedir } from "os" import { join } from "path" -import type { SessionState, SessionStats, Prune } from "./types" +import type { SessionState, SessionStats, Prune, SquashSummary } from "./types" import type { Logger } from "../logger" export interface PersistedSessionState { sessionName?: string prune: Prune + squashSummaries: SquashSummary[] stats: SessionStats lastUpdated: string } @@ -45,6 +46,7 @@ export async function saveSessionState( const state: PersistedSessionState = { sessionName: sessionName, prune: sessionState.prune, + squashSummaries: sessionState.squashSummaries, stats: sessionState.stats, lastUpdated: new Date().toISOString(), } diff --git a/lib/state/state.ts b/lib/state/state.ts index 04ce39ea..98a99693 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -1,8 +1,13 @@ import type { SessionState, ToolParameterEntry, WithParts } from "./types" import type { Logger } from "../logger" import { loadSessionState } from "./persistence" -import { isSubAgentSession } from "./utils" -import { getLastUserMessage, isMessageCompacted } from "../shared-utils" +import { + isSubAgentSession, + findLastCompactionTimestamp, + countTurns, + resetOnCompaction, +} from "./utils" +import { getLastUserMessage } from "../shared-utils" export const checkSession = async ( client: any, @@ -29,9 +34,8 @@ export const checkSession = async ( const lastCompactionTimestamp = findLastCompactionTimestamp(messages) if (lastCompactionTimestamp > state.lastCompaction) { state.lastCompaction = lastCompactionTimestamp - state.toolParameters.clear() - state.prune.toolIds = [] - logger.info("Detected compaction from messages - cleared tool cache", { + resetOnCompaction(state) + logger.info("Detected compaction - reset stale state", { timestamp: lastCompactionTimestamp, }) } @@ -46,8 +50,8 @@ export function createSessionState(): SessionState { prune: { toolIds: [], messageIds: [], - squashSummaries: [], }, + squashSummaries: [], stats: { pruneTokenCounter: 0, totalPruneTokens: 0, @@ -67,8 +71,8 @@ export function resetSessionState(state: SessionState): void { state.prune = { toolIds: [], messageIds: [], - squashSummaries: [], } + state.squashSummaries = [] state.stats = { pruneTokenCounter: 0, totalPruneTokens: 0, @@ -113,36 +117,10 @@ export async function ensureSessionInitialized( state.prune = { toolIds: persisted.prune.toolIds || [], messageIds: persisted.prune.messageIds || [], - squashSummaries: persisted.prune.squashSummaries || [], } + state.squashSummaries = persisted.squashSummaries || [] state.stats = { pruneTokenCounter: persisted.stats?.pruneTokenCounter || 0, totalPruneTokens: persisted.stats?.totalPruneTokens || 0, } } - -function findLastCompactionTimestamp(messages: WithParts[]): number { - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i] - if (msg.info.role === "assistant" && msg.info.summary === true) { - return msg.info.time.created - } - } - return 0 -} - -export function countTurns(state: SessionState, messages: WithParts[]): number { - let turnCount = 0 - for (const msg of messages) { - if (isMessageCompacted(state, msg)) { - continue - } - const parts = Array.isArray(msg.parts) ? msg.parts : [] - for (const part of parts) { - if (part.type === "step-start") { - turnCount++ - } - } - } - return turnCount -} diff --git a/lib/state/types.ts b/lib/state/types.ts index 1892b210..330f8c89 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -28,13 +28,13 @@ export interface SquashSummary { export interface Prune { toolIds: string[] messageIds: string[] - squashSummaries: SquashSummary[] } export interface SessionState { sessionId: string | null isSubAgent: boolean prune: Prune + squashSummaries: SquashSummary[] stats: SessionStats toolParameters: Map nudgeCounter: number diff --git a/lib/state/utils.ts b/lib/state/utils.ts index 4cc10ce1..be8a08fe 100644 --- a/lib/state/utils.ts +++ b/lib/state/utils.ts @@ -1,3 +1,6 @@ +import type { SessionState, WithParts } from "./types" +import { isMessageCompacted } from "../shared-utils" + export async function isSubAgentSession(client: any, sessionID: string): Promise { try { const result = await client.session.get({ path: { id: sessionID } }) @@ -6,3 +9,38 @@ export async function isSubAgentSession(client: any, sessionID: string): Promise return false } } + +export function findLastCompactionTimestamp(messages: WithParts[]): number { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.info.role === "assistant" && msg.info.summary === true) { + return msg.info.time.created + } + } + return 0 +} + +export function countTurns(state: SessionState, messages: WithParts[]): number { + let turnCount = 0 + for (const msg of messages) { + if (isMessageCompacted(state, msg)) { + continue + } + const parts = Array.isArray(msg.parts) ? msg.parts : [] + for (const part of parts) { + if (part.type === "step-start") { + turnCount++ + } + } + } + return turnCount +} + +export function resetOnCompaction(state: SessionState): void { + state.toolParameters.clear() + state.prune.toolIds = [] + state.prune.messageIds = [] + state.squashSummaries = [] + state.nudgeCounter = 0 + state.lastToolPrune = false +} From 41a7c08b340736eca073fa14537a2106f002335e Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sun, 25 Jan 2026 22:05:48 -0500 Subject: [PATCH 005/113] refactor: move squash utility functions to tools/utils.ts - Move findStringInMessages, collectToolIdsInRange, collectMessageIdsInRange from squash.ts to tools/utils.ts for reusability - Keep squash.ts focused on tool definition and execution logic - Follows existing pattern where collectContentInRange lives in utils.ts --- lib/tools/squash.ts | 240 ++++++++++---------------------------------- lib/tools/utils.ts | 121 +++++++++++++++++++++- 2 files changed, 172 insertions(+), 189 deletions(-) diff --git a/lib/tools/squash.ts b/lib/tools/squash.ts index f4db488c..eab6f2fd 100644 --- a/lib/tools/squash.ts +++ b/lib/tools/squash.ts @@ -1,134 +1,20 @@ import { tool } from "@opencode-ai/plugin" -import type { SessionState, WithParts, SquashSummary } from "../state" +import type { WithParts, SquashSummary } from "../state" import type { PruneToolContext } from "./types" import { ensureSessionInitialized } from "../state" import { saveSessionState } from "../state/persistence" -import type { Logger } from "../logger" import { loadPrompt } from "../prompts" -import { countTokens, estimateTokensBatch, getCurrentParams } from "../strategies/utils" -import { collectContentInRange } from "./utils" +import { estimateTokensBatch, getCurrentParams } from "../strategies/utils" +import { + collectContentInRange, + findStringInMessages, + collectToolIdsInRange, + collectMessageIdsInRange, +} from "./utils" import { sendSquashNotification } from "../ui/notification" -import { ToolParameterEntry } from "../state" const SQUASH_TOOL_DESCRIPTION = loadPrompt("squash-tool-spec") -/** - * Searches messages for a string and returns the message ID where it's found. - * Searches in text parts, tool outputs, tool inputs, and other textual content. - * Throws an error if the string is not found or found more than once. - */ -function findStringInMessages( - messages: WithParts[], - searchString: string, - logger: Logger, -): { messageId: string; messageIndex: number } { - const matches: { messageId: string; messageIndex: number }[] = [] - - for (let i = 0; i < messages.length; i++) { - const msg = messages[i] - const parts = Array.isArray(msg.parts) ? msg.parts : [] - - for (const part of parts) { - let content = "" - - // Check different part types for text content - if (part.type === "text" && typeof part.text === "string") { - content = part.text - } else if (part.type === "tool" && part.state?.status === "completed") { - // Search in tool output - if (typeof part.state.output === "string") { - content = part.state.output - } - // Also search in tool input - if (part.state.input) { - const inputStr = - typeof part.state.input === "string" - ? part.state.input - : JSON.stringify(part.state.input) - content += " " + inputStr - } - } - - if (content.includes(searchString)) { - logger.debug("Found search string in message", { - messageId: msg.info.id, - messageIndex: i, - partType: part.type, - }) - // Only add if this message isn't already in matches - if (!matches.some((m) => m.messageId === msg.info.id)) { - matches.push({ messageId: msg.info.id, messageIndex: i }) - } - } - } - } - - if (matches.length === 0) { - throw new Error( - `String not found in conversation. Make sure the string exists in the conversation.`, - ) - } - - if (matches.length > 1) { - throw new Error( - `String found in ${matches.length} messages. Please use a more unique string to identify the range boundary.`, - ) - } - - return matches[0] -} - -/** - * Collects all tool callIDs from messages between start and end indices (inclusive). - */ -function collectToolIdsInRange( - messages: WithParts[], - startIndex: number, - endIndex: number, - logger: Logger, -): string[] { - const toolIds: string[] = [] - - for (let i = startIndex; i <= endIndex; i++) { - const msg = messages[i] - const parts = Array.isArray(msg.parts) ? msg.parts : [] - - for (const part of parts) { - if (part.type === "tool" && part.callID) { - if (!toolIds.includes(part.callID)) { - toolIds.push(part.callID) - logger.debug("Collected tool ID from squashed range", { - callID: part.callID, - messageIndex: i, - }) - } - } - } - } - - return toolIds -} - -/** - * Collects all message IDs from messages between start and end indices (inclusive). - */ -function collectMessageIdsInRange( - messages: WithParts[], - startIndex: number, - endIndex: number, -): string[] { - const messageIds: string[] = [] - - for (let i = startIndex; i <= endIndex; i++) { - const msgId = messages[i].info.id - if (!messageIds.includes(msgId)) { - messageIds.push(msgId) - } - } - - return messageIds -} - export function createSquashTool(ctx: PruneToolContext): ReturnType { return tool({ description: SQUASH_TOOL_DESCRIPTION, @@ -144,51 +30,18 @@ export function createSquashTool(ctx: PruneToolContext): ReturnType const { client, state, logger } = ctx const sessionId = toolCtx.sessionID - // Extract values from array - const input = args.input || [] - - // Validate array length - if (input.length !== 4) { - throw new Error( - `Expected exactly 4 strings [startString, endString, topic, summary], but received ${input.length}. Format: input: [startString, endString, topic, summary]`, - ) - } - - const [startString, endString, topic, summary] = input + const [startString, endString, topic, summary] = args.input logger.info("Squash tool invoked") - logger.info( - JSON.stringify({ - startString: startString?.substring(0, 50) + "...", - endString: endString?.substring(0, 50) + "...", - topic: topic, - summaryLength: summary?.length, - }), - ) + // logger.info( + // JSON.stringify({ + // startString: startString?.substring(0, 50) + "...", + // endString: endString?.substring(0, 50) + "...", + // topic: topic, + // summaryLength: summary?.length, + // }), + // ) - // Validate inputs - if (!startString || startString.trim() === "") { - throw new Error( - "startString is required. Format: input: [startString, endString, topic, summary]", - ) - } - if (!endString || endString.trim() === "") { - throw new Error( - "endString is required. Format: input: [startString, endString, topic, summary]", - ) - } - if (!topic || topic.trim() === "") { - throw new Error( - "topic is required. Format: input: [startString, endString, topic, summary]", - ) - } - if (!summary || summary.trim() === "") { - throw new Error( - "summary is required. Format: input: [startString, endString, topic, summary]", - ) - } - - // Fetch messages const messagesResponse = await client.session.messages({ path: { id: sessionId }, }) @@ -196,46 +49,61 @@ export function createSquashTool(ctx: PruneToolContext): ReturnType await ensureSessionInitialized(client, state, sessionId, logger, messages) - // Find start and end strings in messages - const startResult = findStringInMessages(messages, startString, logger) - const endResult = findStringInMessages(messages, endString, logger) + const startResult = findStringInMessages( + messages, + startString, + logger, + state.squashSummaries, + ) + const endResult = findStringInMessages( + messages, + endString, + logger, + state.squashSummaries, + ) - // Validate order if (startResult.messageIndex > endResult.messageIndex) { throw new Error( `startString appears after endString in the conversation. Start must come before end.`, ) } - // Collect all tool IDs in the range const containedToolIds = collectToolIdsInRange( messages, startResult.messageIndex, endResult.messageIndex, - logger, ) - // Collect all message IDs in the range const containedMessageIds = collectMessageIdsInRange( messages, startResult.messageIndex, endResult.messageIndex, ) - // Add tool IDs to prune list (prevents them from appearing in ) state.prune.toolIds.push(...containedToolIds) - - // Add message IDs to prune list state.prune.messageIds.push(...containedMessageIds) - // Store summary with anchor (first message in range) + // Remove any existing summaries whose anchors are now inside this range + // This prevents duplicate injections when a larger squash subsumes a smaller one + const removedSummaries = state.squashSummaries.filter((s) => + containedMessageIds.includes(s.anchorMessageId), + ) + if (removedSummaries.length > 0) { + // logger.info("Removing subsumed squash summaries", { + // count: removedSummaries.length, + // anchorIds: removedSummaries.map((s) => s.anchorMessageId), + // }) + state.squashSummaries = state.squashSummaries.filter( + (s) => !containedMessageIds.includes(s.anchorMessageId), + ) + } + const squashSummary: SquashSummary = { anchorMessageId: startResult.messageId, summary: summary, } - state.prune.squashSummaries.push(squashSummary) + state.squashSummaries.push(squashSummary) - // Calculate estimated tokens for squashed messages const contentsToTokenize = collectContentInRange( messages, startResult.messageIndex, @@ -243,10 +111,8 @@ export function createSquashTool(ctx: PruneToolContext): ReturnType ) const estimatedSquashedTokens = estimateTokensBatch(contentsToTokenize) - // Add to prune stats for notification state.stats.pruneTokenCounter += estimatedSquashedTokens - // Send notification const currentParams = getCurrentParams(state, messages, logger) await sendSquashNotification( client, @@ -264,20 +130,18 @@ export function createSquashTool(ctx: PruneToolContext): ReturnType currentParams, ) - // Update total prune stats and reset counter state.stats.totalPruneTokens += state.stats.pruneTokenCounter state.stats.pruneTokenCounter = 0 state.nudgeCounter = 0 - logger.info("Squash range created", { - startMessageId: startResult.messageId, - endMessageId: endResult.messageId, - toolIdsRemoved: containedToolIds.length, - messagesInRange: containedMessageIds.length, - estimatedTokens: estimatedSquashedTokens, - }) + // logger.info("Squash range created", { + // startMessageId: startResult.messageId, + // endMessageId: endResult.messageId, + // toolIdsRemoved: containedToolIds.length, + // messagesInRange: containedMessageIds.length, + // estimatedTokens: estimatedSquashedTokens, + // }) - // Persist state saveSessionState(state, logger).catch((err) => logger.error("Failed to persist state", { error: err.message }), ) diff --git a/lib/tools/utils.ts b/lib/tools/utils.ts index fe9c4e89..b7b11fd0 100644 --- a/lib/tools/utils.ts +++ b/lib/tools/utils.ts @@ -1,4 +1,123 @@ -import { WithParts } from "../state" +import type { WithParts, SquashSummary } from "../state" +import type { Logger } from "../logger" + +/** + * Searches messages for a string and returns the message ID where it's found. + * Searches in text parts, tool outputs, tool inputs, and other textual content. + * Also searches through existing squash summaries to enable chained squashing. + * Throws an error if the string is not found or found more than once. + */ +export function findStringInMessages( + messages: WithParts[], + searchString: string, + logger: Logger, + squashSummaries: SquashSummary[] = [], +): { messageId: string; messageIndex: number } { + const matches: { messageId: string; messageIndex: number }[] = [] + + // First, search through existing squash summaries + // This allows referencing text from previous squash operations + for (const summary of squashSummaries) { + if (summary.summary.includes(searchString)) { + const anchorIndex = messages.findIndex((m) => m.info.id === summary.anchorMessageId) + if (anchorIndex !== -1) { + matches.push({ + messageId: summary.anchorMessageId, + messageIndex: anchorIndex, + }) + } + } + } + + // Then search through raw messages + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + const parts = Array.isArray(msg.parts) ? msg.parts : [] + + for (const part of parts) { + let content = "" + + if (part.type === "text" && typeof part.text === "string") { + content = part.text + } else if (part.type === "tool" && part.state?.status === "completed") { + if (typeof part.state.output === "string") { + content = part.state.output + } + if (part.state.input) { + const inputStr = + typeof part.state.input === "string" + ? part.state.input + : JSON.stringify(part.state.input) + content += " " + inputStr + } + } + + if (content.includes(searchString)) { + matches.push({ messageId: msg.info.id, messageIndex: i }) + } + } + } + + if (matches.length === 0) { + throw new Error( + `String not found in conversation. Make sure the string exists in the conversation.`, + ) + } + + if (matches.length > 1) { + throw new Error( + `String found in ${matches.length} messages. Please use a more unique string to identify the range boundary.`, + ) + } + + return matches[0] +} + +/** + * Collects all tool callIDs from messages between start and end indices (inclusive). + */ +export function collectToolIdsInRange( + messages: WithParts[], + startIndex: number, + endIndex: number, +): string[] { + const toolIds: string[] = [] + + for (let i = startIndex; i <= endIndex; i++) { + const msg = messages[i] + const parts = Array.isArray(msg.parts) ? msg.parts : [] + + for (const part of parts) { + if (part.type === "tool" && part.callID) { + if (!toolIds.includes(part.callID)) { + toolIds.push(part.callID) + } + } + } + } + + return toolIds +} + +/** + * Collects all message IDs from messages between start and end indices (inclusive). + */ +export function collectMessageIdsInRange( + messages: WithParts[], + startIndex: number, + endIndex: number, +): string[] { + const messageIds: string[] = [] + + for (let i = startIndex; i <= endIndex; i++) { + const msgId = messages[i].info.id + if (!messageIds.includes(msgId)) { + messageIds.push(msgId) + } + } + + return messageIds +} /** * Collects all textual content (text parts, tool inputs, and tool outputs) From adfdd715bfb40c6fa3c0645cc5e420347eacedc0 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 27 Jan 2026 15:19:01 -0500 Subject: [PATCH 006/113] fix: add secure mode authentication support When opencode runs with OPENCODE_SERVER_PASSWORD set, the server requires HTTP Basic Auth. This adds auth utilities to detect secure mode and configure the SDK client with an interceptor that injects the Authorization header on all requests. Fixes #304 --- index.ts | 6 ++++++ lib/auth.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 lib/auth.ts diff --git a/index.ts b/index.ts index 4cd77603..fc8ab62a 100644 --- a/index.ts +++ b/index.ts @@ -8,6 +8,7 @@ import { createCommandExecuteHandler, createSystemPromptHandler, } from "./lib/hooks" +import { configureClientAuth, isSecureMode } from "./lib/auth" const plugin: Plugin = (async (ctx) => { const config = getConfig(ctx) @@ -19,6 +20,11 @@ const plugin: Plugin = (async (ctx) => { const logger = new Logger(config.debug) const state = createSessionState() + if (isSecureMode()) { + configureClientAuth(ctx.client) + // logger.info("Secure mode detected, configured client authentication") + } + logger.info("DCP initialized", { strategies: config.strategies, }) diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 00000000..8b7aa418 --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,37 @@ +export function isSecureMode(): boolean { + return !!process.env.OPENCODE_SERVER_PASSWORD +} + +export function getAuthorizationHeader(): string | undefined { + const password = process.env.OPENCODE_SERVER_PASSWORD + if (!password) return undefined + + const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" + // Use Buffer for Node.js base64 encoding (btoa may not be available in all Node versions) + const credentials = Buffer.from(`${username}:${password}`).toString("base64") + return `Basic ${credentials}` +} + +export function configureClientAuth(client: any): any { + const authHeader = getAuthorizationHeader() + + if (!authHeader) { + return client + } + + // The SDK client has an internal client with request interceptors + // Access the underlying client to add the interceptor + const innerClient = client._client || client.client + + if (innerClient?.interceptors?.request) { + innerClient.interceptors.request.use((request: Request) => { + // Only add auth header if not already present + if (!request.headers.has("Authorization")) { + request.headers.set("Authorization", authHeader) + } + return request + }) + } + + return client +} From ca972e08048b7aa48d9476d43f155f03b1fdd4b3 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 27 Jan 2026 20:15:02 -0500 Subject: [PATCH 007/113] refactor: append context info to existing assistant messages instead of creating new ones --- lib/messages/inject.ts | 16 ++++------- lib/messages/utils.ts | 65 ++++++++++++------------------------------ tsconfig.json | 2 +- 3 files changed, 25 insertions(+), 58 deletions(-) diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 8f092512..6d7bae0c 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -6,9 +6,9 @@ import { loadPrompt } from "../prompts" import { extractParameterKey, buildToolIdList, - createSyntheticAssistantMessage, - createSyntheticUserMessage, createSyntheticToolPart, + createSyntheticUserMessage, + createSyntheticAssistantMessage, isDeepSeekOrKimi, isIgnoredUserMessage, } from "./utils" @@ -188,14 +188,10 @@ export const insertPruneToolContext = ( const userInfo = lastUserMessage.info as UserMessage const variant = state.variant ?? userInfo.variant - let lastNonIgnoredMessage: WithParts | undefined - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i] - if (!(msg.info.role === "user" && isIgnoredUserMessage(msg))) { - lastNonIgnoredMessage = msg - break - } - } + // Find the last message that isn't an ignored user message + const lastNonIgnoredMessage = messages.findLast( + (msg) => !(msg.info.role === "user" && isIgnoredUserMessage(msg)), + ) if (!lastNonIgnoredMessage || lastNonIgnoredMessage.info.role === "user") { messages.push(createSyntheticUserMessage(lastUserMessage, combinedContent, variant)) diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index e9da5b17..55b4cbc1 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -57,63 +57,34 @@ export const createSyntheticUserMessage = ( } } -export const createSyntheticAssistantMessage = ( - baseMessage: WithParts, - content: string, - variant?: string, -): WithParts => { - const userInfo = baseMessage.info as UserMessage +export const createSyntheticToolPart = (assistantMessage: WithParts, content: string): any => { const now = Date.now() - - const messageId = generateUniqueId("msg") const partId = generateUniqueId("prt") const callId = generateUniqueId("call") - const baseInfo = { - id: messageId, - sessionID: userInfo.sessionID, - role: "assistant" as const, - agent: userInfo.agent || "code", - parentID: userInfo.id, - modelID: userInfo.model.modelID, - providerID: userInfo.model.providerID, - mode: "default", - path: { - cwd: "/", - root: "/", - }, - time: { created: now, completed: now }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - ...(variant !== undefined && { variant }), - } + const modelID = (assistantMessage.info as any).modelID || "" // For Gemini models, add thoughtSignature bypass to avoid validation errors - const toolPartMetadata = isGeminiModel(userInfo.model.modelID) + const toolPartMetadata = isGeminiModel(modelID) ? { google: { thoughtSignature: "skip_thought_signature_validator" } } : undefined return { - info: baseInfo, - parts: [ - { - id: partId, - sessionID: userInfo.sessionID, - messageID: messageId, - type: "tool", - callID: callId, - tool: "context_info", - state: { - status: "completed", - input: {}, - output: content, - title: "Context Info", - metadata: {}, - time: { start: now, end: now }, - }, - ...(toolPartMetadata && { metadata: toolPartMetadata }), - }, - ], + id: partId, + sessionID: assistantMessage.info.sessionID, + messageID: assistantMessage.info.id, + type: "tool", + callID: callId, + tool: "context_info", + state: { + status: "completed", + input: {}, + output: content, + title: "Context Info", + metadata: {}, + time: { start: now, end: now }, + }, + ...(toolPartMetadata && { metadata: toolPartMetadata }), } } diff --git a/tsconfig.json b/tsconfig.json index b30286cf..c20d8a54 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "target": "ES2022", "module": "ESNext", - "lib": ["ES2022"], + "lib": ["ES2023"], "moduleResolution": "bundler", "resolveJsonModule": true, "allowJs": true, From 8e57d9df13e59fe6228c9e85c34a73cb69f0f7b3 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 27 Jan 2026 21:39:47 -0500 Subject: [PATCH 008/113] fix: improve discard/extract robustness and fix missing cache sync (DCP-305) --- lib/tools/prune-shared.ts | 58 +++++++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/lib/tools/prune-shared.ts b/lib/tools/prune-shared.ts index 20ac8896..ba37fb78 100644 --- a/lib/tools/prune-shared.ts +++ b/lib/tools/prune-shared.ts @@ -3,6 +3,7 @@ import type { PluginConfig } from "../config" import type { Logger } from "../logger" import type { PruneToolContext } from "./types" import { buildToolIdList } from "../messages/utils" +import { syncToolCache } from "../state/tool-cache" import { PruneReason, sendUnifiedNotification } from "../ui/notification" import { formatPruningResultForTool } from "../ui/utils" import { ensureSessionInitialized } from "../state" @@ -48,32 +49,37 @@ export async function executePruneOperation( const messages: WithParts[] = messagesResponse.data || messagesResponse await ensureSessionInitialized(ctx.client, state, sessionId, logger, messages) + await syncToolCache(state, config, logger, messages) const currentParams = getCurrentParams(state, messages, logger) const toolIdList: string[] = buildToolIdList(state, messages, logger) - // Validate that all numeric IDs are within bounds - if (numericToolIds.some((id) => id < 0 || id >= toolIdList.length)) { - logger.debug("Invalid tool IDs provided: " + numericToolIds.join(", ")) - throw new Error( - "Invalid IDs provided. Only use numeric IDs from the list.", - ) - } + const validNumericIds: number[] = [] + const skippedIds: string[] = [] - // Validate that all IDs exist in cache and aren't protected - // (rejects hallucinated IDs and turn-protected tools not shown in ) + // Validate and filter IDs for (const index of numericToolIds) { + // Validate that all numeric IDs are within bounds + if (index < 0 || index >= toolIdList.length) { + logger.debug(`Rejecting prune request - index out of bounds: ${index}`) + skippedIds.push(index.toString()) + continue + } + const id = toolIdList[index] const metadata = state.toolParameters.get(id) + + // Validate that all IDs exist in cache and aren't protected + // (rejects hallucinated IDs and turn-protected tools not shown in ) if (!metadata) { logger.debug( "Rejecting prune request - ID not in cache (turn-protected or hallucinated)", { index, id }, ) - throw new Error( - "Invalid IDs provided. Only use numeric IDs from the list.", - ) + skippedIds.push(index.toString()) + continue } + const allProtectedTools = config.tools.settings.protectedTools if (allProtectedTools.includes(metadata.tool)) { logger.debug("Rejecting prune request - protected tool", { @@ -81,9 +87,8 @@ export async function executePruneOperation( id, tool: metadata.tool, }) - throw new Error( - "Invalid IDs provided. Only use numeric IDs from the list.", - ) + skippedIds.push(index.toString()) + continue } const filePath = getFilePathFromParameters(metadata.parameters) @@ -94,13 +99,22 @@ export async function executePruneOperation( tool: metadata.tool, filePath, }) - throw new Error( - "Invalid IDs provided. Only use numeric IDs from the list.", - ) + skippedIds.push(index.toString()) + continue } + + validNumericIds.push(index) } - const pruneToolIds: string[] = numericToolIds.map((index) => toolIdList[index]) + if (validNumericIds.length === 0) { + const errorMsg = + skippedIds.length > 0 + ? `Invalid IDs provided: [${skippedIds.join(", ")}]. Only use numeric IDs from the list.` + : `No valid IDs provided to ${toolName.toLowerCase()}.` + throw new Error(errorMsg) + } + + const pruneToolIds: string[] = validNumericIds.map((index) => toolIdList[index]) state.prune.toolIds.push(...pruneToolIds) const toolMetadata = new Map() @@ -137,5 +151,9 @@ export async function executePruneOperation( logger.error("Failed to persist state", { error: err.message }), ) - return formatPruningResultForTool(pruneToolIds, toolMetadata, workingDirectory) + 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 } From db08fc37aa501b8d45ee0a7c1c0cda5e08277b58 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 27 Jan 2026 22:16:41 -0500 Subject: [PATCH 009/113] v1.3.0-beta.1 - Bump beta version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 896ece9b..92b9a7f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tarquinen/opencode-dcp", - "version": "1.2.8", + "version": "1.3.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "1.2.8", + "version": "1.3.0-beta.1", "license": "MIT", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", diff --git a/package.json b/package.json index d437ee49..f1e486dd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@tarquinen/opencode-dcp", - "version": "1.2.8", + "version": "1.3.0-beta.1", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js", From 0e803eabbdf63125a5719ed32325ec0efd08c50d Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 27 Jan 2026 23:35:16 -0500 Subject: [PATCH 010/113] docs: sync schema and README with implementation of protected tools --- README.md | 2 +- dcp.schema.json | 36 +++--------------------------------- 2 files changed, 4 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index d9f85a30..03770df9 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ When enabled, turn protection prevents tool outputs from being pruned for a conf ### Protected Tools By default, these tools are always protected from pruning across all strategies: -`task`, `todowrite`, `todoread`, `discard`, `extract`, `squash`, `batch`, `write`, `edit` +`task`, `todowrite`, `todoread`, `discard`, `extract`, `squash`, `batch`, `write`, `edit`, `plan_enter`, `plan_exit` The `protectedTools` arrays in each section add to this default list. diff --git a/dcp.schema.json b/dcp.schema.json index 8e4ff104..bd458ac3 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -100,17 +100,7 @@ "items": { "type": "string" }, - "default": [ - "task", - "todowrite", - "todoread", - "discard", - "extract", - "squash", - "batch", - "write", - "edit" - ], + "default": [], "description": "Tool names that should be protected from automatic pruning" } } @@ -183,17 +173,7 @@ "items": { "type": "string" }, - "default": [ - "task", - "todowrite", - "todoread", - "discard", - "extract", - "squash", - "batch", - "write", - "edit" - ], + "default": [], "description": "Tool names excluded from deduplication" } } @@ -230,17 +210,7 @@ "items": { "type": "string" }, - "default": [ - "task", - "todowrite", - "todoread", - "discard", - "extract", - "squash", - "batch", - "write", - "edit" - ], + "default": [], "description": "Tool names excluded from error purging" } } From 80f1a7ee4385279e0275908ae74c9c920894ee70 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 27 Jan 2026 23:54:11 -0500 Subject: [PATCH 011/113] docs: add ko-fi badge to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 03770df9..41ebcc9e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Dynamic Context Pruning Plugin [![npm version](https://img.shields.io/npm/v/@tarquinen/opencode-dcp.svg)](https://www.npmjs.com/package/@tarquinen/opencode-dcp) +[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/dansmolsky) Automatically reduces token usage in OpenCode by removing obsolete tools from conversation history. From a39c7666dd501043f048567fe330ffd4a8ac5d95 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 28 Jan 2026 00:00:57 -0500 Subject: [PATCH 012/113] swap readme buttons --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 41ebcc9e..d83e8ed9 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Dynamic Context Pruning Plugin -[![npm version](https://img.shields.io/npm/v/@tarquinen/opencode-dcp.svg)](https://www.npmjs.com/package/@tarquinen/opencode-dcp) [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/dansmolsky) +[![npm version](https://img.shields.io/npm/v/@tarquinen/opencode-dcp.svg)](https://www.npmjs.com/package/@tarquinen/opencode-dcp) Automatically reduces token usage in OpenCode by removing obsolete tools from conversation history. From ff132f11fd84e5f96f4f7d29dfd6e88844772d22 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 28 Jan 2026 00:36:04 -0500 Subject: [PATCH 013/113] validate extraction is array --- lib/tools/extract.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/tools/extract.ts b/lib/tools/extract.ts index 2be9a180..15e5d7c8 100644 --- a/lib/tools/extract.ts +++ b/lib/tools/extract.ts @@ -31,6 +31,16 @@ export function createExtractTool(ctx: PruneToolContext): ReturnType Date: Wed, 28 Jan 2026 13:47:01 -0500 Subject: [PATCH 014/113] improve squash tool error messages to specify which boundary string failed --- lib/prompts/squash-tool-spec.ts | 3 ++- lib/tools/squash.ts | 2 ++ lib/tools/utils.ts | 5 +++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/prompts/squash-tool-spec.ts b/lib/prompts/squash-tool-spec.ts index 8d45a5ee..6beba0bd 100644 --- a/lib/prompts/squash-tool-spec.ts +++ b/lib/prompts/squash-tool-spec.ts @@ -14,8 +14,9 @@ export const SQUASH_TOOL_SPEC = `**Purpose:** Collapse a contiguous range of con 3. \`topic\` — short label (3-5 words) 4. \`summary\` — replacement text 5. Everything between (inclusive) removed, summary inserted +- The squash will FAIL if \`startString\` or \`endString\` is not found in the conversation with an error "startString/endString not found in conversation". +- The squash will FAIL if \`startString\` or \`endString\` is found multiple times with an error "Found multiple matches for startString/endString". Provide a larger string with more surrounding context to uniquely identify the intended match. **Best Practices:** -- Choose unique strings appearing only once - Write concise topics: "Auth System Exploration", "Token Logic Refactor" - Write comprehensive summaries with key information - Best after finishing work phase, not during active exploration diff --git a/lib/tools/squash.ts b/lib/tools/squash.ts index eab6f2fd..9ab9425a 100644 --- a/lib/tools/squash.ts +++ b/lib/tools/squash.ts @@ -54,12 +54,14 @@ export function createSquashTool(ctx: PruneToolContext): ReturnType startString, logger, state.squashSummaries, + "startString", ) const endResult = findStringInMessages( messages, endString, logger, state.squashSummaries, + "endString", ) if (startResult.messageIndex > endResult.messageIndex) { diff --git a/lib/tools/utils.ts b/lib/tools/utils.ts index b7b11fd0..d5e4e180 100644 --- a/lib/tools/utils.ts +++ b/lib/tools/utils.ts @@ -12,6 +12,7 @@ export function findStringInMessages( searchString: string, logger: Logger, squashSummaries: SquashSummary[] = [], + stringType: "startString" | "endString", ): { messageId: string; messageIndex: number } { const matches: { messageId: string; messageIndex: number }[] = [] @@ -60,13 +61,13 @@ export function findStringInMessages( if (matches.length === 0) { throw new Error( - `String not found in conversation. Make sure the string exists in the conversation.`, + `${stringType} not found in conversation. Make sure the string exists and is spelled exactly as it appears.`, ) } if (matches.length > 1) { throw new Error( - `String found in ${matches.length} messages. Please use a more unique string to identify the range boundary.`, + `Found multiple matches for ${stringType}. Provide more surrounding context to uniquely identify the intended match.`, ) } From d3cbf5da8d2b78104e63a1946aca9be7d86f5470 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 28 Jan 2026 15:03:21 -0500 Subject: [PATCH 015/113] revert prompt style to dev branch format with semantic improvements - revert terse PR 313 style back to verbose prose format from dev branch - clarify tool triggers: discard/extract for individual outputs, squash for phases - remove 'task complete' trigger from discard/extract (exclusive to squash) - add instruction to not use discard/extract without prunable-tools list - replace 'task/sub-task' terminology with 'phase' to avoid conflict with Task tool --- lib/prompts/discard-tool-spec.ts | 56 ++++++++++++----- lib/prompts/extract-tool-spec.ts | 69 +++++++++++++------- lib/prompts/nudge/all.ts | 14 +++-- lib/prompts/nudge/discard-extract.ts | 13 ++-- lib/prompts/nudge/discard-squash.ts | 12 ++-- lib/prompts/nudge/discard.ts | 12 ++-- lib/prompts/nudge/extract-squash.ts | 12 ++-- lib/prompts/nudge/extract.ts | 12 ++-- lib/prompts/nudge/squash.ts | 12 ++-- lib/prompts/squash-tool-spec.ts | 90 +++++++++++++++++---------- lib/prompts/system/all.ts | 79 +++++++++++++++++------ lib/prompts/system/discard-extract.ts | 76 ++++++++++++++++------ lib/prompts/system/discard-squash.ts | 74 ++++++++++++++++------ lib/prompts/system/discard.ts | 63 +++++++++++++------ lib/prompts/system/extract-squash.ts | 74 ++++++++++++++++------ lib/prompts/system/extract.ts | 62 ++++++++++++------ lib/prompts/system/squash.ts | 60 ++++++++++++------ 17 files changed, 550 insertions(+), 240 deletions(-) diff --git a/lib/prompts/discard-tool-spec.ts b/lib/prompts/discard-tool-spec.ts index 54f2bef1..1c1eea74 100644 --- a/lib/prompts/discard-tool-spec.ts +++ b/lib/prompts/discard-tool-spec.ts @@ -1,17 +1,39 @@ -export const DISCARD_TOOL_SPEC = `**Purpose:** Discard tool outputs from context to manage size and reduce noise. -**IDs:** Use numeric IDs from \`\` (format: \`ID: tool, parameter\`). -**Use When:** -- Noise → irrelevant, unhelpful, or superseded outputs -**Do NOT Use When:** -- Output contains useful information -- Output needed later (files to edit, implementation context) -**Best Practices:** -- Batch multiple items; avoid single small outputs (unless pure noise) -- Criterion: "Needed for upcoming task?" → keep it -**Format:** -- \`ids\`: string[] — numeric IDs from prunable list -**Example:** -Noise removal: - ids: ["5"] - Context: Read wrong_file.ts — not relevant to auth system -` +export const DISCARD_TOOL_SPEC = `Discards tool outputs from context to manage conversation size and reduce noise. + +## IMPORTANT: The Prunable List +A \`\` list is provided to you showing available tool outputs you can discard when there are tools available for pruning. Each line has the format \`ID: tool, parameter\` (e.g., \`20: read, /path/to/file.ts\`). You MUST only use numeric IDs that appear in this list to select which tools to discard. + +## When to Use This Tool + +Use \`discard\` for removing individual tool outputs that are no longer needed: + +- **Noise:** Irrelevant, unhelpful, or superseded outputs that provide no value. +- **Wrong Files:** You read or accessed something that turned out to be irrelevant. +- **Outdated Info:** Outputs that have been superseded by newer information. + +## When NOT to Use This Tool + +- **If the output contains useful information:** Keep it in context rather than discarding. +- **If you'll need the output later:** Don't discard files you plan to edit or context you'll need for implementation. + +## Best Practices +- **Strategic Batching:** Don't discard single small tool outputs (like short bash commands) unless they are pure noise. Wait until you have several items to perform high-impact discards. +- **Think ahead:** Before discarding, ask: "Will I need this output for upcoming work?" If yes, keep it. + +## Format + +- \`ids\`: Array of numeric IDs as strings from the \`\` list + +## Example + + +Assistant: [Reads 'wrong_file.ts'] +This file isn't relevant to the auth system. I'll remove it to clear the context. +[Uses discard with ids: ["5"]] + + + +Assistant: [Reads config.ts, then reads updated config.ts after changes] +The first read is now outdated. I'll discard it and keep the updated version. +[Uses discard with ids: ["20"]] +` diff --git a/lib/prompts/extract-tool-spec.ts b/lib/prompts/extract-tool-spec.ts index 20d94107..f680ea9e 100644 --- a/lib/prompts/extract-tool-spec.ts +++ b/lib/prompts/extract-tool-spec.ts @@ -1,22 +1,47 @@ -export const EXTRACT_TOOL_SPEC = `**Purpose:** Extract key findings from tool outputs into distilled knowledge; remove raw outputs from context. -**IDs:** Use numeric IDs from \`\` (format: \`ID: tool, parameter\`). -**Use When:** -- Task complete → preserve findings -- Distill context → keep specifics, drop noise -**Do NOT Use When:** -- Need exact syntax (edits/grep) → keep raw output -- Planning modifications → keep read output -**Best Practices:** -- Batch multiple items; avoid frequent small extractions -- Preserve raw output if editing/modifying later -**Format:** -- \`ids\`: string[] — numeric IDs from prunable list -- \`distillation\`: string[] — positional mapping (distillation[i] for ids[i]) -- Detail level: signatures, logic, constraints, values -**Example:** - \`ids\`: ["10", "11"] - \`distillation\`: [ - "auth.ts: validateToken(token: string)→User|null. Cache 5min TTL then OIDC. bcrypt 12 rounds. Tokens ≥128 chars.", - "user.ts: interface User {id: string; email: string; permissions: ('read'|'write'|'admin')[]; status: 'active'|'suspended'}" - ] -` +export const EXTRACT_TOOL_SPEC = `Extracts key findings from tool outputs into distilled knowledge, then removes the raw outputs from context. + +## IMPORTANT: The Prunable List +A \`\` list is provided to you showing available tool outputs you can extract from when there are tools available for pruning. Each line has the format \`ID: tool, parameter\` (e.g., \`20: read, /path/to/file.ts\`). You MUST only use numeric IDs that appear in this list to select which tools to extract. + +## When to Use This Tool + +Use \`extract\` when you have individual tool outputs with valuable information you want to **preserve in distilled form** before removing the raw content: + +- **Large Outputs:** The raw output is too large but contains valuable technical details worth keeping. +- **Knowledge Preservation:** You have context that contains valuable information (signatures, logic, constraints) but also a lot of unnecessary detail. + +## When NOT to Use This Tool + +- **If you need precise syntax:** If you'll edit a file or grep for exact strings, keep the raw output. +- **If uncertain:** Prefer keeping over re-fetching. + + +## Best Practices +- **Strategic Batching:** Wait until you have several items or a few large outputs to extract, rather than doing tiny, frequent extractions. Aim for high-impact extractions that significantly reduce context size. +- **Think ahead:** Before extracting, ask: "Will I need the raw output for upcoming work?" If you researched a file you'll later edit, do NOT extract it. + +## Format + +- \`ids\`: Array of numeric IDs as strings from the \`\` list +- \`distillation\`: Array of strings, one per ID (positional: distillation[0] is for ids[0], etc.) + +Each distillation string should capture the essential information you need to preserve - function signatures, logic, constraints, values, etc. Be as detailed as needed. + +## Example + + +Assistant: [Reads auth service and user types] +I'll preserve the key details before extracting. +[Uses extract with: + ids: ["10", "11"], + distillation: [ + "auth.ts: validateToken(token: string) -> User|null checks cache first (5min TTL) then OIDC. hashPassword uses bcrypt 12 rounds. Tokens must be 128+ chars.", + "user.ts: interface User { id: string; email: string; permissions: ('read'|'write'|'admin')[]; status: 'active'|'suspended' }" + ] +] + + + +Assistant: [Reads 'auth.ts' to understand the login flow] +I've understood the auth flow. I'll need to modify this file to add the new validation, so I'm keeping this read in context rather than extracting. +` diff --git a/lib/prompts/nudge/all.ts b/lib/prompts/nudge/all.ts index 70951fa5..08e86e8f 100644 --- a/lib/prompts/nudge/all.ts +++ b/lib/prompts/nudge/all.ts @@ -1,8 +1,10 @@ export const NUDGE_ALL = ` -**CONTEXT WARNING:** Context filling with tool outputs. Context hygiene required. -**Actions:** -1. Task done → use \`squash\` to condense entire sequence into summary -2. Noise → files/commands with no value, use \`discard\` -3. Knowledge → valuable raw data to reference later, use \`extract\` to distill insights -**Protocol:** Prioritize cleanup. Don't interrupt atomic ops. After immediate step, perform context management. +**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. + +**Immediate Actions Required:** +1. **Phase Completion:** If a phase is complete, use the \`squash\` tool to condense the entire sequence into a summary. +2. **Noise Removal:** If you read files or ran commands that yielded no value, use \`discard\` to remove them. +3. **Knowledge Preservation:** If you are holding valuable raw data you'll need to reference later, use \`extract\` to distill the insights and remove the raw entry. + +**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must perform context management. ` diff --git a/lib/prompts/nudge/discard-extract.ts b/lib/prompts/nudge/discard-extract.ts index e40a97e6..2e1b8615 100644 --- a/lib/prompts/nudge/discard-extract.ts +++ b/lib/prompts/nudge/discard-extract.ts @@ -1,7 +1,10 @@ export const NUDGE_DISCARD_EXTRACT = ` -**CONTEXT WARNING:** Context filling with tool outputs. Context hygiene required. -**Actions:** -1. Noise → files/commands with no value, use \`discard\` -2. Knowledge → valuable raw data to reference later, use \`extract\` to distill insights -**Protocol:** Prioritize cleanup. Don't interrupt atomic ops. After immediate step, perform context management. +**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. + +**Immediate Actions Required:** +1. **Noise Removal:** If you read files or ran commands that yielded no value, use \`discard\` to remove them. +2. **Superseded Info:** If older outputs have been replaced by newer ones, use \`discard\` on the outdated versions. +3. **Knowledge Preservation:** If you have large outputs with valuable technical details, use \`extract\` to distill the insights and remove the raw entry. + +**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must perform context management. ` diff --git a/lib/prompts/nudge/discard-squash.ts b/lib/prompts/nudge/discard-squash.ts index 614b038b..699a716f 100644 --- a/lib/prompts/nudge/discard-squash.ts +++ b/lib/prompts/nudge/discard-squash.ts @@ -1,7 +1,9 @@ export const NUDGE_DISCARD_SQUASH = ` -**CONTEXT WARNING:** Context filling with tool outputs. Context hygiene required. -**Actions:** -1. Task done → sub-task/phase complete, use \`squash\` to condense into summary -2. Noise → files/commands with no value, use \`discard\` -**Protocol:** Prioritize cleanup. Don't interrupt atomic ops. After immediate step, perform context management. +**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. + +**Immediate Actions Required:** +1. **Phase Completion:** If a phase is complete, use the \`squash\` tool to condense the entire sequence into a summary. +2. **Noise Removal:** If you read files or ran commands that yielded no value, use the \`discard\` tool to remove them. + +**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must perform context management. ` diff --git a/lib/prompts/nudge/discard.ts b/lib/prompts/nudge/discard.ts index c274c1a1..13e6314b 100644 --- a/lib/prompts/nudge/discard.ts +++ b/lib/prompts/nudge/discard.ts @@ -1,7 +1,9 @@ export const NUDGE_DISCARD = ` -**CONTEXT WARNING:** Context filling with tool outputs. Context hygiene required. -**Actions:** -1. Noise → files/commands with no value, use \`discard\` -2. Outdated → outputs no longer relevant, discard -**Protocol:** Prioritize cleanup. Don't interrupt atomic ops. After immediate step, discard unneeded outputs. +**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. + +**Immediate Actions Required:** +1. **Noise Removal:** If you read files or ran commands that yielded no value, use the \`discard\` tool to remove them. +2. **Superseded Info:** If older outputs have been replaced by newer ones, discard the outdated versions. + +**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must discard unneeded tool outputs. ` diff --git a/lib/prompts/nudge/extract-squash.ts b/lib/prompts/nudge/extract-squash.ts index 3eb79ffd..88053e80 100644 --- a/lib/prompts/nudge/extract-squash.ts +++ b/lib/prompts/nudge/extract-squash.ts @@ -1,7 +1,9 @@ export const NUDGE_EXTRACT_SQUASH = ` -**CONTEXT WARNING:** Context filling with tool outputs. Context hygiene required. -**Actions:** -1. Task done → sub-task/phase complete, use \`squash\` to condense into summary -2. Knowledge → valuable raw data to reference later, use \`extract\` to distill insights -**Protocol:** Prioritize cleanup. Don't interrupt atomic ops. After immediate step, perform context management. +**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. + +**Immediate Actions Required:** +1. **Phase Completion:** If a phase is complete, use the \`squash\` tool to condense the entire sequence into a summary. +2. **Knowledge Preservation:** If you are holding valuable raw data you'll need to reference later, use \`extract\` to distill the insights and remove the raw entry. + +**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must perform context management. ` diff --git a/lib/prompts/nudge/extract.ts b/lib/prompts/nudge/extract.ts index 95258891..16ea5b78 100644 --- a/lib/prompts/nudge/extract.ts +++ b/lib/prompts/nudge/extract.ts @@ -1,7 +1,9 @@ export const NUDGE_EXTRACT = ` -**CONTEXT WARNING:** Context filling with tool outputs. Context hygiene required. -**Actions:** -1. Knowledge → valuable raw data to reference later, use \`extract\` with high-fidelity distillation -2. Phase done → extract key findings to keep context focused -**Protocol:** Prioritize cleanup. Don't interrupt atomic ops. After immediate step, extract valuable findings. +**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. + +**Immediate Actions Required:** +1. **Large Outputs:** If you have large tool outputs with valuable technical details, use the \`extract\` tool to distill and preserve key information. +2. **Knowledge Preservation:** If you are holding valuable raw data you'll need to reference later, use the \`extract\` tool with high-fidelity distillation to preserve the insights and remove the raw entry. + +**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must extract valuable findings from tool outputs. ` diff --git a/lib/prompts/nudge/squash.ts b/lib/prompts/nudge/squash.ts index e773346b..ba4c9097 100644 --- a/lib/prompts/nudge/squash.ts +++ b/lib/prompts/nudge/squash.ts @@ -1,7 +1,9 @@ export const NUDGE_SQUASH = ` -**CONTEXT WARNING:** Context filling with tool outputs. Context hygiene required. -**Actions:** -1. Task done → sub-task/phase complete, use \`squash\` to condense sequence into summary -2. Exploration done → squash results to focus on next task -**Protocol:** Prioritize cleanup. Don't interrupt atomic ops. After immediate step, squash unneeded ranges. +**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. + +**Immediate Actions Required:** +1. **Phase Completion:** If a phase is complete, use the \`squash\` tool to condense the entire sequence into a summary. +2. **Exploration Done:** If you explored multiple files or ran multiple commands, squash the results to focus on the next phase. + +**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must squash completed conversation ranges. ` diff --git a/lib/prompts/squash-tool-spec.ts b/lib/prompts/squash-tool-spec.ts index 6beba0bd..ab3644b7 100644 --- a/lib/prompts/squash-tool-spec.ts +++ b/lib/prompts/squash-tool-spec.ts @@ -1,33 +1,57 @@ -export const SQUASH_TOOL_SPEC = `**Purpose:** Collapse a contiguous range of conversation into a single summary. -**Use When:** -- Task complete → squash entire sequence (research, tool calls, implementation) into summary -- Exploration done → multiple files/commands explored, only need summary -- Failed attempts → condense unsuccessful approaches into brief note -- Verbose output → section grown large but can be summarized -**Do NOT Use When:** -- Need specific details (exact code, file contents, error messages from range) -- Individual tool outputs → squash targets conversation ranges, not single outputs -- Recent content → may still need for current task -**How It Works:** -1. \`startString\` — unique text marking range start -2. \`endString\` — unique text marking range end -3. \`topic\` — short label (3-5 words) -4. \`summary\` — replacement text -5. Everything between (inclusive) removed, summary inserted -- The squash will FAIL if \`startString\` or \`endString\` is not found in the conversation with an error "startString/endString not found in conversation". -- The squash will FAIL if \`startString\` or \`endString\` is found multiple times with an error "Found multiple matches for startString/endString". Provide a larger string with more surrounding context to uniquely identify the intended match. -**Best Practices:** -- Write concise topics: "Auth System Exploration", "Token Logic Refactor" -- Write comprehensive summaries with key information -- Best after finishing work phase, not during active exploration -**Format:** -- \`input\`: [startString, endString, topic, summary] -**Example:** - Conversation: [Asked about auth] → [Read 5 files] → [Analyzed patterns] → [Found "JWT tokens with 24h expiry"] - input: [ - "Asked about authentication", - "JWT tokens with 24h expiry", - "Auth System Exploration", - "Auth: JWT 24h expiry, bcrypt passwords, refresh rotation. Files: auth.ts, tokens.ts, middleware/auth.ts" - ] -` +export const SQUASH_TOOL_SPEC = `Collapses a contiguous range of conversation into a single summary. + +## When to Use This Tool + +Use \`squash\` when you want to condense an entire sequence of work into a brief summary: + +- **Phase Completion:** You completed a phase (research, tool calls, implementation) and want to collapse the entire sequence into a summary. +- **Exploration Done:** You explored multiple files or ran multiple commands and only need a summary of what you learned. +- **Failed Attempts:** You tried several unsuccessful approaches and want to condense them into a brief note. +- **Verbose Output:** A section of conversation has grown large but can be summarized without losing critical details. + +## When NOT to Use This Tool + +- **If you need specific details:** If you'll need exact code, file contents, or error messages from the range, keep them. +- **For individual tool outputs:** Use \`discard\` or \`extract\` for single tool outputs. Squash targets conversation ranges. +- **If it's recent content:** You may still need recent work for the current phase. + +## How It Works + +1. \`startString\` — A unique text string that marks the start of the range to squash +2. \`endString\` — A unique text string that marks the end of the range to squash +3. \`topic\` — A short label (3-5 words) describing the squashed content +4. \`summary\` — The replacement text that will be inserted + +Everything between startString and endString (inclusive) is removed and replaced with your summary. + +**Important:** The squash will FAIL if \`startString\` or \`endString\` is not found in the conversation. The squash will also FAIL if either string is found multiple times. Provide a larger string with more surrounding context to uniquely identify the intended match. + +## Best Practices +- **Choose unique strings:** Pick text that appears only once in the conversation. +- **Write concise topics:** Examples: "Auth System Exploration", "Token Logic Refactor" +- **Write comprehensive summaries:** Include key information like file names, function signatures, and important findings. +- **Timing:** Best used after finishing a work phase, not during active exploration. + +## Format + +- \`input\`: Array with four elements: [startString, endString, topic, summary] + +## Example + + +Conversation: [Asked about auth] -> [Read 5 files] -> [Analyzed patterns] -> [Found "JWT tokens with 24h expiry"] + +[Uses squash with: + input: [ + "Asked about authentication", + "JWT tokens with 24h expiry", + "Auth System Exploration", + "Auth: JWT 24h expiry, bcrypt passwords, refresh rotation. Files: auth.ts, tokens.ts, middleware/auth.ts" + ] +] + + + +Assistant: [Just finished reading auth.ts] +I've read the auth file and now need to make edits based on it. I'm keeping this in context rather than squashing. +` diff --git a/lib/prompts/system/all.ts b/lib/prompts/system/all.ts index 62a30828..65cfb338 100644 --- a/lib/prompts/system/all.ts +++ b/lib/prompts/system/all.ts @@ -1,25 +1,64 @@ export const SYSTEM_PROMPT_ALL = ` -Context-constrained. Manage via \`discard\`/\`extract\`/\`squash\`. Environment auto-calls \`context_info\` (you can't call) to inject list each turn. -TOOLS -- \`discard\`: remove unneeded outputs (noise, outdated). No preservation. -- \`extract\`: distill key findings before removal. Use when preserving info. -- \`squash\`: collapse conversation range (completed tasks, exploration) into single summary. -CHOOSING TOOL -Scope+preservation? Task done (large scope)→\`squash\` | Insights to keep→\`extract\` | Noise/superseded→\`discard\` -BATCH PRUNES -Every tool call=context debt. Pay down regularly. Batch prunes; rarely prune single tiny output unless pure noise. -Evaluate pruning when: task/sub-task done | starting new phase | write/edit ops done -MUST NOT prune when: output needed for upcoming work | contains files/context for edits -Pruning forcing re-call=net loss. Only prune if confident won't need again. -When in doubt, keep. Aim high-impact prunes. FAILURE TO PRUNE=DEGRADED PERFORMANCE. + +ENVIRONMENT +You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\`, \`extract\`, and \`squash\` tools. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to prune. + +IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. + +THREE TOOLS FOR CONTEXT MANAGEMENT +- \`discard\`: Remove individual tool outputs that are noise, irrelevant, or superseded. No preservation of content. +- \`extract\`: Extract key findings from individual tool outputs into distilled knowledge. Use when you need to preserve valuable technical details. +- \`squash\`: Collapse a contiguous range of conversation (completed phases) into a single summary. + +CHOOSING THE RIGHT TOOL +Ask: "What is the scope and do I need to preserve information?" +- **Noise, irrelevant, or superseded outputs** → \`discard\` +- **Individual tool outputs with valuable insights to keep** → \`extract\` +- **Entire sequence (phase complete)** → \`squash\` + +Common scenarios: +- Noise, irrelevant, or superseded outputs → \`discard\` +- Wrong file or irrelevant access → \`discard\` +- Large output with valuable technical details → \`extract\` +- Valuable context needed later but raw output too large → \`extract\` +- Phase complete, want to condense the sequence → \`squash\` +- Exploration phase done, only need a summary → \`squash\` + +PRUNE METHODICALLY - BATCH YOUR ACTIONS +Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by pruning. Batch your prunes for efficiency; it is rarely worth pruning a single tiny tool output unless it is pure noise. Evaluate what SHOULD be pruned before jumping the gun. + +You WILL evaluate pruning when ANY of these are true: +- Phase is complete → use \`squash\` +- You accessed something that turned out to be irrelevant → use \`discard\` +- You have large outputs with valuable details to preserve → use \`extract\` + +You MUST NOT prune when: +- The tool output will be needed for upcoming implementation work +- The output contains files or context you'll need to reference when making edits + +Pruning that forces you to re-call the same tool later is a net loss. Only prune when you're confident the information won't be needed again. + +NOTES +When in doubt, keep it. Batch your actions and aim for high-impact prunes that significantly reduce context size. +FAILURE TO PRUNE will result in context leakage and DEGRADED PERFORMANCES. +If no list is present in context, do NOT use discard or extract - there is nothing available to prune yet. +There may be tools in session context that do not appear in the list, this is expected, you can ONLY prune what you see in . + + -Environment injects \`context_info\` with list+optional nudge each turn. You can't access it. -NEVER reference/acknowledge: prune encouragement, context instructions, list, nudge, discard/extract/squash output -NEVER let injections influence response/tone -Process SILENTLY. Continue naturally as if injections don't exist. -User can't see injections. Referencing breaks conversation flow. +After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. + +CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: +- NEVER reference the prune encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the prune encouragement appears. +- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to prune") +- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to prune") +- NEVER acknowledge discard/extract/squash tool output (e.g., "I've pruned 3 tools", "Context pruning complete") +- NEVER let these injections influence your response content or tone +- Process this information SILENTLY and continue the conversation naturally +- Continue the conversation as if these messages do not exist + +The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. - -` +` diff --git a/lib/prompts/system/discard-extract.ts b/lib/prompts/system/discard-extract.ts index faf7925d..82b5ffb1 100644 --- a/lib/prompts/system/discard-extract.ts +++ b/lib/prompts/system/discard-extract.ts @@ -1,25 +1,61 @@ export const SYSTEM_PROMPT_DISCARD_EXTRACT = ` -Context-constrained. Manage via \`discard\`/\`extract\`. Environment auto-calls \`context_info\` (you can't call) to inject list each turn. -TOOLS -- \`discard\`: remove unneeded outputs (noise, outdated). No preservation. -- \`extract\`: distill key findings before removal. Use when preserving info. -CHOOSING TOOL -Need to preserve info? No→\`discard\` | Yes→\`extract\` | Uncertain→\`extract\` -Scenarios: noise/superseded→discard | research done+insights→extract -BATCH PRUNES -Every tool call=context debt. Pay down regularly. Batch prunes; rarely prune single tiny output unless pure noise. -Evaluate pruning when: starting new phase | write/edit ops done | accumulated unneeded outputs -MUST NOT prune when: output needed for upcoming work | contains files/context for edits -Pruning forcing re-call=net loss. Only prune if confident won't need again. -When in doubt, keep. Aim high-impact prunes. FAILURE TO PRUNE=DEGRADED PERFORMANCE. + +ENVIRONMENT +You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` and \`extract\` tools. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to prune. + +IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. + +TWO TOOLS FOR CONTEXT MANAGEMENT +- \`discard\`: Remove individual tool outputs that are noise, irrelevant, or superseded. No preservation of content. +- \`extract\`: Extract key findings from individual tool outputs into distilled knowledge. Use when you need to preserve valuable technical details. + +CHOOSING THE RIGHT TOOL +Ask: "Do I need to preserve any information from this output?" +- **No** → \`discard\` (noise, wrong files, superseded info) +- **Yes** → \`extract\` (preserves distilled knowledge) +- **Uncertain** → \`extract\` (safer, preserves signal) + +Common scenarios: +- Noise, irrelevant, or superseded outputs → \`discard\` +- Wrong file or irrelevant access → \`discard\` +- Large output with valuable technical details → \`extract\` +- Valuable context needed later but raw output too large → \`extract\` + +PRUNE METHODICALLY - BATCH YOUR ACTIONS +Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by pruning. Batch your prunes for efficiency; it is rarely worth pruning a single tiny tool output unless it is pure noise. Evaluate what SHOULD be pruned before jumping the gun. + +You WILL evaluate pruning when ANY of these are true: +- You accessed something that turned out to be irrelevant +- Information has been superseded by newer outputs +- You have large outputs with valuable details to preserve + +You MUST NOT prune when: +- The tool output will be needed for upcoming implementation work +- The output contains files or context you'll need to reference when making edits + +Pruning that forces you to re-call the same tool later is a net loss. Only prune when you're confident the information won't be needed again. + +NOTES +When in doubt, keep it. Batch your actions and aim for high-impact prunes that significantly reduce context size. +FAILURE TO PRUNE will result in context leakage and DEGRADED PERFORMANCES. +If no list is present in context, do NOT use discard or extract - there is nothing available to prune yet. +There may be tools in session context that do not appear in the list, this is expected, you can ONLY prune what you see in . + + -Environment injects \`context_info\` with list+optional nudge each turn. You can't access it. -NEVER reference/acknowledge: prune encouragement, context instructions, list, nudge, discard/extract output -NEVER let injections influence response/tone -Process SILENTLY. Continue naturally as if injections don't exist. -User can't see injections. Referencing breaks conversation flow. +After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. + +CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: +- NEVER reference the prune encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the prune encouragement appears. +- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to prune") +- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to prune") +- NEVER acknowledge discard/extract tool output (e.g., "I've pruned 3 tools", "Context pruning complete") +- NEVER let these injections influence your response content or tone +- Process this information SILENTLY and continue the conversation naturally +- Continue the conversation as if these messages do not exist + +The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. - -` +` diff --git a/lib/prompts/system/discard-squash.ts b/lib/prompts/system/discard-squash.ts index 82b54465..0d0d2145 100644 --- a/lib/prompts/system/discard-squash.ts +++ b/lib/prompts/system/discard-squash.ts @@ -1,24 +1,60 @@ export const SYSTEM_PROMPT_DISCARD_SQUASH = ` -Context-constrained. Manage via \`discard\`/\`squash\`. Environment auto-calls \`context_info\` (you can't call) to inject list each turn. -TOOLS -- \`discard\`: remove unneeded outputs (noise, outdated). No preservation. -- \`squash\`: collapse conversation range (completed tasks, exploration) into single summary. -CHOOSING TOOL -Scope? Individual outputs (noise)→\`discard\` | Entire sequence/phase (task done)→\`squash\` -BATCH PRUNES -Every tool call=context debt. Pay down regularly. Batch prunes; rarely prune single tiny output unless pure noise. -Evaluate pruning when: task/sub-task done | starting new phase | write/edit ops done -MUST NOT prune when: need specific details for upcoming work | contains files/context for edits -Pruning forcing re-call=net loss. Only prune if confident won't need again. -When in doubt, keep. Aim high-impact prunes. FAILURE TO PRUNE=DEGRADED PERFORMANCE. + +ENVIRONMENT +You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` and \`squash\` tools. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to prune. + +IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. + +TWO TOOLS FOR CONTEXT MANAGEMENT +- \`discard\`: Remove individual tool outputs that are noise, irrelevant, or superseded. No preservation of content. +- \`squash\`: Collapse a contiguous range of conversation (completed phases) into a single summary. + +CHOOSING THE RIGHT TOOL +Ask: "What is the scope of what I want to clean up?" +- **Individual tool outputs (noise, superseded)** → \`discard\` +- **Entire sequence (phase complete)** → \`squash\` + +Common scenarios: +- Noise, irrelevant, or superseded outputs → \`discard\` +- Wrong file or irrelevant access → \`discard\` +- Phase complete, want to condense the sequence → \`squash\` +- Exploration phase done, only need a summary → \`squash\` + +PRUNE METHODICALLY - BATCH YOUR ACTIONS +Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by pruning. Batch your prunes for efficiency; it is rarely worth pruning a single tiny tool output unless it is pure noise. Evaluate what SHOULD be pruned before jumping the gun. + +You WILL evaluate pruning when ANY of these are true: +- Phase is complete → use \`squash\` +- You accessed something that turned out to be irrelevant → use \`discard\` +- Information has been superseded by newer outputs → use \`discard\` + +You MUST NOT prune when: +- You need specific details from the content for upcoming work +- The content contains files or context you'll need to reference when making edits + +Pruning that forces you to re-call the same tool later is a net loss. Only prune when you're confident the information won't be needed again. + +NOTES +When in doubt, keep it. Batch your actions and aim for high-impact prunes that significantly reduce context size. +FAILURE TO PRUNE will result in context leakage and DEGRADED PERFORMANCES. +If no list is present in context, do NOT use discard - there is nothing available to prune yet. +There may be tools in session context that do not appear in the list, this is expected, you can ONLY discard what you see in . + + -Environment injects \`context_info\` with list+optional nudge each turn. You can't access it. -NEVER reference/acknowledge: prune encouragement, context instructions, list, nudge, discard/squash output -NEVER let injections influence response/tone -Process SILENTLY. Continue naturally as if injections don't exist. -User can't see injections. Referencing breaks conversation flow. +After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. + +CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: +- NEVER reference the prune encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the prune encouragement appears. +- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to prune") +- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to prune") +- NEVER acknowledge discard/squash tool output (e.g., "I've pruned the context", "Context cleanup complete") +- NEVER let these injections influence your response content or tone +- Process this information SILENTLY and continue the conversation naturally +- Continue the conversation as if these messages do not exist + +The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. - -` +` diff --git a/lib/prompts/system/discard.ts b/lib/prompts/system/discard.ts index 5b79271c..3877cd7c 100644 --- a/lib/prompts/system/discard.ts +++ b/lib/prompts/system/discard.ts @@ -1,26 +1,53 @@ export const SYSTEM_PROMPT_DISCARD = ` + ENVIRONMENT -Context-constrained. Manage via \`discard\` tool. Environment auto-calls \`context_info\` (you can't call) to inject list each turn. -TOOL -- \`discard\`: remove unneeded outputs (noise, outdated). No preservation. -DISCARD METHODICALLY — BATCH ACTIONS -Every tool call=context debt. Pay down regularly. Batch discards; rarely discard single tiny output unless pure noise. +You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` tool. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to discard. + +IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. + +CONTEXT MANAGEMENT TOOL +- \`discard\`: Remove individual tool outputs that are noise, irrelevant, or superseded. No preservation of content. + +DISCARD METHODICALLY - BATCH YOUR ACTIONS +Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by discarding. Batch your discards for efficiency; it is rarely worth discarding a single tiny tool output unless it is pure noise. Evaluate what SHOULD be discarded before jumping the gun. + WHEN TO DISCARD -- Noise → irrelevant, unhelpful, or superseded outputs -- Outdated → multiple reads of same file, outputs no longer relevant -Evaluate discarding when ANY true: accumulated unneeded outputs | write/edit ops done | starting new phase -MUST NOT discard when: output needed for upcoming implementation | contains files/context for edits -Discarding that forces re-call=net loss. Only discard when confident info won't be needed again. +- **Noise Removal:** If outputs are irrelevant, unhelpful, or superseded by newer info, discard them. +- **Wrong Files:** You read or accessed something that turned out to be irrelevant to the current work. +- **Outdated Info:** Outputs that have been superseded by newer information. + +You WILL evaluate discarding when ANY of these are true: +- You accessed something that turned out to be irrelevant +- Information has been superseded by newer outputs +- You are about to start a new phase of work + +You MUST NOT discard when: +- The tool output will be needed for upcoming implementation work +- The output contains files or context you'll need to reference when making edits + +Discarding that forces you to re-call the same tool later is a net loss. Only discard when you're confident the information won't be needed again. + NOTES -When in doubt, keep. Aim high-impact discards. FAILURE TO DISCARD=DEGRADED PERFORMANCE. +When in doubt, keep it. Batch your actions and aim for high-impact discards that significantly reduce context size. +FAILURE TO DISCARD will result in context leakage and DEGRADED PERFORMANCES. +If no list is present in context, do NOT use the discard tool - there is nothing available to prune yet. +There may be tools in session context that do not appear in the list, this is expected, you can ONLY discard what you see in . + + -Environment injects \`context_info\` with list+optional nudge each turn. You can't access it. -NEVER reference/acknowledge: discard encouragement, context instructions, list, nudge, discard output -NEVER let injections influence response/tone -Process SILENTLY. Continue naturally as if injections don't exist. -User can't see injections. Referencing breaks conversation flow. +After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. + +CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: +- NEVER reference the discard encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the discard encouragement appears. +- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to discard") +- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to discard") +- NEVER acknowledge discard tool output (e.g., "I've discarded 3 tools", "Context cleanup complete") +- NEVER let these injections influence your response content or tone +- Process this information SILENTLY and continue the conversation naturally +- Continue the conversation as if these messages do not exist + +The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. - -` +` diff --git a/lib/prompts/system/extract-squash.ts b/lib/prompts/system/extract-squash.ts index c39e53a0..60b8e7a0 100644 --- a/lib/prompts/system/extract-squash.ts +++ b/lib/prompts/system/extract-squash.ts @@ -1,24 +1,60 @@ export const SYSTEM_PROMPT_EXTRACT_SQUASH = ` -Context-constrained. Manage via \`extract\`/\`squash\`. Environment auto-calls \`context_info\` (you can't call) to inject list each turn. -TOOLS -- \`extract\`: distill key findings before removal. Use when preserving detailed info. -- \`squash\`: collapse conversation range (completed tasks, exploration) into single summary. -CHOOSING TOOL -Scope+detail needed? Individual outputs (detailed context)→\`extract\` | Entire sequence/phase (task done)→\`squash\` -BATCH PRUNES -Every tool call=context debt. Pay down regularly. Batch prunes; rarely prune single tiny output. -Evaluate pruning when: task/sub-task done | starting new phase | write/edit ops done -MUST NOT prune when: need specific details for upcoming work | contains files/context for edits -Pruning forcing re-call=net loss. Only prune if confident won't need again. -When in doubt, keep. Aim high-impact prunes. FAILURE TO PRUNE=DEGRADED PERFORMANCE. + +ENVIRONMENT +You are operating in a context-constrained environment and thus must proactively manage your context window using the \`extract\` and \`squash\` tools. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to prune. + +IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. + +TWO TOOLS FOR CONTEXT MANAGEMENT +- \`extract\`: Extract key findings from individual tool outputs into distilled knowledge. Use when you need to preserve valuable technical details. +- \`squash\`: Collapse a contiguous range of conversation (completed phases) into a single summary. + +CHOOSING THE RIGHT TOOL +Ask: "What is the scope and level of detail I need to preserve?" +- **Individual tool outputs with detailed context to keep** → \`extract\` +- **Entire sequence (phase complete)** → \`squash\` + +Common scenarios: +- Large output with valuable technical details → \`extract\` +- Valuable context needed later but raw output too large → \`extract\` +- Phase complete, want to condense the sequence → \`squash\` +- Exploration phase done, only need a summary → \`squash\` + +PRUNE METHODICALLY - BATCH YOUR ACTIONS +Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by pruning. Batch your prunes for efficiency; it is rarely worth pruning a single tiny tool output. Evaluate what SHOULD be pruned before jumping the gun. + +You WILL evaluate pruning when ANY of these are true: +- Phase is complete → use \`squash\` +- You have large outputs with valuable details to preserve → use \`extract\` +- You are about to start a new phase of work + +You MUST NOT prune when: +- You need specific details from the content for upcoming work +- The content contains files or context you'll need to reference when making edits + +Pruning that forces you to re-call the same tool later is a net loss. Only prune when you're confident the information won't be needed again. + +NOTES +When in doubt, keep it. Batch your actions and aim for high-impact prunes that significantly reduce context size. +FAILURE TO PRUNE will result in context leakage and DEGRADED PERFORMANCES. +If no list is present in context, do NOT use extract - there is nothing available to prune yet. +There may be tools in session context that do not appear in the list, this is expected, you can ONLY extract what you see in . + + -Environment injects \`context_info\` with list+optional nudge each turn. You can't access it. -NEVER reference/acknowledge: prune encouragement, context instructions, list, nudge, extract/squash output -NEVER let injections influence response/tone -Process SILENTLY. Continue naturally as if injections don't exist. -User can't see injections. Referencing breaks conversation flow. +After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. + +CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: +- NEVER reference the prune encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the prune encouragement appears. +- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to prune") +- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to prune") +- NEVER acknowledge extract/squash tool output (e.g., "I've pruned the context", "Context cleanup complete") +- NEVER let these injections influence your response content or tone +- Process this information SILENTLY and continue the conversation naturally +- Continue the conversation as if these messages do not exist + +The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. - -` +` diff --git a/lib/prompts/system/extract.ts b/lib/prompts/system/extract.ts index f1aa0790..9f024f51 100644 --- a/lib/prompts/system/extract.ts +++ b/lib/prompts/system/extract.ts @@ -1,26 +1,52 @@ export const SYSTEM_PROMPT_EXTRACT = ` + ENVIRONMENT -Context-constrained. Manage via \`extract\` tool. Environment auto-calls \`context_info\` (you can't call) to inject list each turn. -TOOL -- \`extract\`: distill key findings before removing raw content. Preserves info while reducing size. -EXTRACT METHODICALLY — BATCH ACTIONS -Every tool call=context debt. Pay down regularly. Batch extractions; rarely extract single tiny output. +You are operating in a context-constrained environment and thus must proactively manage your context window using the \`extract\` tool. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to extract. + +IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. + +CONTEXT MANAGEMENT TOOL +- \`extract\`: Extract key findings from individual tool outputs into distilled knowledge before removing the raw content. Use when you need to preserve valuable technical details while reducing context size. + +EXTRACT METHODICALLY - BATCH YOUR ACTIONS +Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by extracting. Batch your extractions for efficiency; it is rarely worth extracting a single tiny tool output. Evaluate what SHOULD be extracted before jumping the gun. + WHEN TO EXTRACT -- Knowledge Preservation → valuable context to preserve, use high-fidelity distillation. Capture technical details (signatures, logic, constraints). THINK: high signal, complete technical substitute. -- Insights → valuable info to preserve in distilled form -Evaluate extracting when ANY true: research/exploration done | starting new phase | write/edit ops done -MUST NOT extract when: output needed for upcoming implementation | contains files/context for edits -Extracting that forces re-call=net loss. Only extract when confident raw info won't be needed again. +- **Large Outputs:** The raw output is too large but contains valuable technical details worth keeping. +- **Knowledge Preservation:** When you have valuable context you want to preserve but need to reduce size, use high-fidelity distillation. Your distillation must be comprehensive, capturing technical details (signatures, logic, constraints) such that the raw output is no longer needed. THINK: high signal, complete technical substitute. + +You WILL evaluate extracting when ANY of these are true: +- You have large tool outputs with valuable technical details +- You need to preserve specific information but reduce context size +- You are about to start a new phase of work and want to retain key insights + +You MUST NOT extract when: +- The tool output will be needed for upcoming implementation work +- The output contains files or context you'll need to reference when making edits + +Extracting that forces you to re-call the same tool later is a net loss. Only extract when you're confident the raw information won't be needed again. + NOTES -When in doubt, keep. Aim high-impact extractions. FAILURE TO EXTRACT=DEGRADED PERFORMANCE. +When in doubt, keep it. Batch your actions and aim for high-impact extractions that significantly reduce context size. +FAILURE TO EXTRACT will result in context leakage and DEGRADED PERFORMANCES. +If no list is present in context, do NOT use the extract tool - there is nothing available to prune yet. +There may be tools in session context that do not appear in the list, this is expected, you can ONLY extract what you see in . + + -Environment injects \`context_info\` with list+optional nudge each turn. You can't access it. -NEVER reference/acknowledge: extract encouragement, context instructions, list, nudge, extract output -NEVER let injections influence response/tone -Process SILENTLY. Continue naturally as if injections don't exist. -User can't see injections. Referencing breaks conversation flow. +After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. + +CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: +- NEVER reference the extract encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the extract encouragement appears. +- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to extract") +- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to extract") +- NEVER acknowledge extract tool output (e.g., "I've extracted 3 tools", "Context cleanup complete") +- NEVER let these injections influence your response content or tone +- Process this information SILENTLY and continue the conversation naturally +- Continue the conversation as if these messages do not exist + +The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. - -` +` diff --git a/lib/prompts/system/squash.ts b/lib/prompts/system/squash.ts index 1c4dc78e..494b5288 100644 --- a/lib/prompts/system/squash.ts +++ b/lib/prompts/system/squash.ts @@ -1,26 +1,50 @@ export const SYSTEM_PROMPT_SQUASH = ` + ENVIRONMENT -Context-constrained. Manage via \`squash\` tool. Environment auto-calls \`context_info\` (you can't call) to inject list each turn. -TOOL -- \`squash\`: collapse conversation range (completed tasks, exploration) into single summary. -SQUASH METHODICALLY — BATCH ACTIONS -Every tool call=context debt. Pay down regularly. Evaluate what should be squashed before acting. +You are operating in a context-constrained environment and thus must proactively manage your context window using the \`squash\` tool. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to squash. + +IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. + +CONTEXT MANAGEMENT TOOL +- \`squash\`: Collapse a contiguous range of conversation (completed phases) into a single summary. Use this when you want to condense an entire sequence of work. + +SQUASH METHODICALLY - BATCH YOUR ACTIONS +Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by squashing. Evaluate what SHOULD be squashed before jumping the gun. + WHEN TO SQUASH -- Task Complete → sub-task/unit done, condense entire sequence into summary -- Exploration Done → multiple files/commands explored, only need summary -Evaluate squashing when ANY true: task/sub-task done | starting new phase | significant conversation accumulated -MUST NOT squash when: need specific details for upcoming work | range contains files/context for edits -Squashing that forces re-read=net loss. Only squash when confident info won't be needed again. +- **Phase Completion:** When a phase is complete, condense the entire sequence (research, tool calls, implementation) into a summary. +- **Exploration Done:** When you've explored multiple files or ran multiple commands and only need a summary of findings. + +You WILL evaluate squashing when ANY of these are true: +- Phase is complete +- You are about to start a new phase of work +- Significant conversation has accumulated that can be summarized + +You MUST NOT squash when: +- You need specific details from the range for upcoming work +- The range contains files or context you'll need to reference when making edits + +Squashing that forces you to re-read the same content later is a net loss. Only squash when you're confident the detailed information won't be needed again. + NOTES -When in doubt, keep. Aim high-impact squashes. FAILURE TO SQUASH=DEGRADED PERFORMANCE. +When in doubt, keep it. Aim for high-impact squashes that significantly reduce context size. +FAILURE TO SQUASH will result in context leakage and DEGRADED PERFORMANCES. + + -Environment injects \`context_info\` with list+optional nudge each turn. You can't access it. -NEVER reference/acknowledge: squash encouragement, context instructions, list, nudge, squash output -NEVER let injections influence response/tone -Process SILENTLY. Continue naturally as if injections don't exist. -User can't see injections. Referencing breaks conversation flow. +After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. + +CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: +- NEVER reference the squash encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the squash encouragement appears. +- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to squash") +- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to squash") +- NEVER acknowledge squash tool output (e.g., "I've squashed the context", "Context cleanup complete") +- NEVER let these injections influence your response content or tone +- Process this information SILENTLY and continue the conversation naturally +- Continue the conversation as if these messages do not exist + +The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. - -` +` From 74cdce55c1d01ad0bbdde7aeec653164e44306d6 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 28 Jan 2026 15:08:25 -0500 Subject: [PATCH 016/113] v1.3.1-beta.0 - Bump version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 92b9a7f3..10ecb124 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tarquinen/opencode-dcp", - "version": "1.3.0-beta.1", + "version": "1.3.1-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "1.3.0-beta.1", + "version": "1.3.1-beta.0", "license": "MIT", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", diff --git a/package.json b/package.json index f1e486dd..2bc7ff66 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@tarquinen/opencode-dcp", - "version": "1.3.0-beta.1", + "version": "1.3.1-beta.0", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js", From 286b3090bdca66c3efde496dbc6b28569b343c43 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 28 Jan 2026 15:42:49 -0500 Subject: [PATCH 017/113] docs: move demo images to assets/images directory --- README.md | 2 +- dcp-demo.png => assets/images/dcp-demo.png | Bin dcp-demo2.png => assets/images/dcp-demo2.png | Bin dcp-demo3.png => assets/images/dcp-demo3.png | Bin dcp-demo4.png => assets/images/dcp-demo4.png | Bin dcp-demo5.png => assets/images/dcp-demo5.png | Bin 6 files changed, 1 insertion(+), 1 deletion(-) rename dcp-demo.png => assets/images/dcp-demo.png (100%) rename dcp-demo2.png => assets/images/dcp-demo2.png (100%) rename dcp-demo3.png => assets/images/dcp-demo3.png (100%) rename dcp-demo4.png => assets/images/dcp-demo4.png (100%) rename dcp-demo5.png => assets/images/dcp-demo5.png (100%) diff --git a/README.md b/README.md index d83e8ed9..0a63b74c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Automatically reduces token usage in OpenCode by removing obsolete tools from conversation history. -![DCP in action](dcp-demo5.png) +![DCP in action](assets/images/dcp-demo5.png) ## Installation diff --git a/dcp-demo.png b/assets/images/dcp-demo.png similarity index 100% rename from dcp-demo.png rename to assets/images/dcp-demo.png diff --git a/dcp-demo2.png b/assets/images/dcp-demo2.png similarity index 100% rename from dcp-demo2.png rename to assets/images/dcp-demo2.png diff --git a/dcp-demo3.png b/assets/images/dcp-demo3.png similarity index 100% rename from dcp-demo3.png rename to assets/images/dcp-demo3.png diff --git a/dcp-demo4.png b/assets/images/dcp-demo4.png similarity index 100% rename from dcp-demo4.png rename to assets/images/dcp-demo4.png diff --git a/dcp-demo5.png b/assets/images/dcp-demo5.png similarity index 100% rename from dcp-demo5.png rename to assets/images/dcp-demo5.png From ce866821f4d97528586a6232e97c82002b7b3445 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 28 Jan 2026 21:08:11 -0500 Subject: [PATCH 018/113] fix: ensure tool count accuracy in context breakdown using unique callIDs --- lib/commands/context.ts | 63 +++++++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/lib/commands/context.ts b/lib/commands/context.ts index bd2e8661..9cb5c68c 100644 --- a/lib/commands/context.ts +++ b/lib/commands/context.ts @@ -112,43 +112,58 @@ function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdo const toolOutputParts: string[] = [] let firstUserText = "" let foundFirstUser = false + const foundToolIds = new Set() for (const msg of messages) { - if (isMessageCompacted(state, msg)) continue - if (msg.info.role === "user" && isIgnoredUserMessage(msg)) continue - const parts = Array.isArray(msg.parts) ? msg.parts : [] + const isCompacted = isMessageCompacted(state, msg) + const isIgnoredUser = msg.info.role === "user" && isIgnoredUserMessage(msg) + + // Single pass through parts: always count tools, conditionally collect tokens for (const part of parts) { - if (part.type === "text" && msg.info.role === "user") { + if (part.type === "tool") { + // Count unique tool calls from ALL messages (including compacted ones) + // prunedCount already includes tools from squashed messages + const toolPart = part as ToolPart + if (toolPart.callID && !foundToolIds.has(toolPart.callID)) { + breakdown.toolCount++ + foundToolIds.add(toolPart.callID) + } + + // Only collect tool input/output for token counting from non-compacted messages + if (!isCompacted) { + if (toolPart.state?.input) { + const inputStr = + typeof toolPart.state.input === "string" + ? toolPart.state.input + : JSON.stringify(toolPart.state.input) + toolInputParts.push(inputStr) + } + + if (toolPart.state?.status === "completed" && toolPart.state?.output) { + const outputStr = + typeof toolPart.state.output === "string" + ? toolPart.state.output + : JSON.stringify(toolPart.state.output) + toolOutputParts.push(outputStr) + } + } + } else if ( + part.type === "text" && + msg.info.role === "user" && + !isCompacted && + !isIgnoredUser + ) { const textPart = part as TextPart const text = textPart.text || "" userTextParts.push(text) if (!foundFirstUser) { firstUserText += text } - } else if (part.type === "tool") { - const toolPart = part as ToolPart - breakdown.toolCount++ - - if (toolPart.state?.input) { - const inputStr = - typeof toolPart.state.input === "string" - ? toolPart.state.input - : JSON.stringify(toolPart.state.input) - toolInputParts.push(inputStr) - } - - if (toolPart.state?.status === "completed" && toolPart.state?.output) { - const outputStr = - typeof toolPart.state.output === "string" - ? toolPart.state.output - : JSON.stringify(toolPart.state.output) - toolOutputParts.push(outputStr) - } } } - if (msg.info.role === "user" && !isIgnoredUserMessage(msg) && !foundFirstUser) { + if (msg.info.role === "user" && !isIgnoredUser && !foundFirstUser) { foundFirstUser = true } } From 2255691705957e30f666b12cb22d9824c8340673 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 28 Jan 2026 21:41:57 -0500 Subject: [PATCH 019/113] cleanup --- lib/commands/context.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/commands/context.ts b/lib/commands/context.ts index 9cb5c68c..1fe83fb7 100644 --- a/lib/commands/context.ts +++ b/lib/commands/context.ts @@ -119,18 +119,14 @@ function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdo const isCompacted = isMessageCompacted(state, msg) const isIgnoredUser = msg.info.role === "user" && isIgnoredUserMessage(msg) - // Single pass through parts: always count tools, conditionally collect tokens for (const part of parts) { if (part.type === "tool") { - // Count unique tool calls from ALL messages (including compacted ones) - // prunedCount already includes tools from squashed messages const toolPart = part as ToolPart if (toolPart.callID && !foundToolIds.has(toolPart.callID)) { breakdown.toolCount++ foundToolIds.add(toolPart.callID) } - // Only collect tool input/output for token counting from non-compacted messages if (!isCompacted) { if (toolPart.state?.input) { const inputStr = From 0a381d83bdbefe4a5f7890ef6cb94f47bc4b0fa3 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 28 Jan 2026 23:02:15 -0500 Subject: [PATCH 020/113] refactor: inject assistant text parts instead of tool parts --- lib/messages/inject.ts | 3 +- lib/messages/utils.ts | 62 +++++++++++++++++++++++------------------- 2 files changed, 36 insertions(+), 29 deletions(-) diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 6d7bae0c..4c859219 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -6,9 +6,9 @@ import { loadPrompt } from "../prompts" import { extractParameterKey, buildToolIdList, - createSyntheticToolPart, createSyntheticUserMessage, createSyntheticAssistantMessage, + createSyntheticToolPart, isDeepSeekOrKimi, isIgnoredUserMessage, } from "./utils" @@ -203,6 +203,7 @@ export const insertPruneToolContext = ( const toolPart = createSyntheticToolPart(lastNonIgnoredMessage, combinedContent) lastNonIgnoredMessage.parts.push(toolPart) } else { + // Create a new assistant message with just a text part messages.push( createSyntheticAssistantMessage(lastUserMessage, combinedContent, variant), ) diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index 55b4cbc1..9d30b03f 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -8,11 +8,6 @@ export const SQUASH_SUMMARY_PREFIX = "[Squashed conversation block]\n\n" const generateUniqueId = (prefix: string): string => `${prefix}_${ulid()}` -const isGeminiModel = (modelID: string): boolean => { - const lowerModelID = modelID.toLowerCase() - return lowerModelID.includes("gemini") -} - export const isDeepSeekOrKimi = (providerID: string, modelID: string): boolean => { const lowerProviderID = providerID.toLowerCase() const lowerModelID = modelID.toLowerCase() @@ -57,34 +52,45 @@ export const createSyntheticUserMessage = ( } } -export const createSyntheticToolPart = (assistantMessage: WithParts, content: string): any => { +export const createSyntheticAssistantMessage = ( + baseMessage: WithParts, + content: string, + variant?: string, +): WithParts => { + const userInfo = baseMessage.info as UserMessage const now = Date.now() - const partId = generateUniqueId("prt") - const callId = generateUniqueId("call") - - const modelID = (assistantMessage.info as any).modelID || "" - // For Gemini models, add thoughtSignature bypass to avoid validation errors - const toolPartMetadata = isGeminiModel(modelID) - ? { google: { thoughtSignature: "skip_thought_signature_validator" } } - : undefined + const messageId = generateUniqueId("msg") + const partId = generateUniqueId("prt") return { - id: partId, - sessionID: assistantMessage.info.sessionID, - messageID: assistantMessage.info.id, - type: "tool", - callID: callId, - tool: "context_info", - state: { - status: "completed", - input: {}, - output: content, - title: "Context Info", - metadata: {}, - time: { start: now, end: now }, + info: { + id: messageId, + sessionID: userInfo.sessionID, + role: "assistant" as const, + agent: userInfo.agent || "code", + parentID: userInfo.id, + modelID: userInfo.model.modelID, + providerID: userInfo.model.providerID, + mode: "default", + path: { + cwd: "/", + root: "/", + }, + time: { created: now, completed: now }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + ...(variant !== undefined && { variant }), }, - ...(toolPartMetadata && { metadata: toolPartMetadata }), + parts: [ + { + id: partId, + sessionID: userInfo.sessionID, + messageID: messageId, + type: "text", + text: content, + }, + ], } } From f963d8af0740ece5d9b2d9e7413011d1816c57a4 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 28 Jan 2026 23:54:18 -0500 Subject: [PATCH 021/113] refactor: hybrid injection strategy for DeepSeek/Kimi models - Use tool part injection for DeepSeek/Kimi (requires reasoning_content in assistant messages) - Use text part injection for other models (cleaner approach) - Add prunedMessageCount to context breakdown display --- lib/commands/context.ts | 6 +++++- lib/messages/inject.ts | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/commands/context.ts b/lib/commands/context.ts index 1fe83fb7..7ecd91c8 100644 --- a/lib/commands/context.ts +++ b/lib/commands/context.ts @@ -62,6 +62,7 @@ interface TokenBreakdown { toolCount: number prunedTokens: number prunedCount: number + prunedMessageCount: number total: number } @@ -74,6 +75,7 @@ function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdo toolCount: 0, prunedTokens: state.stats.totalPruneTokens, prunedCount: state.prune.toolIds.length, + prunedMessageCount: state.prune.messageIds.length, total: 0, } @@ -232,8 +234,10 @@ function formatContextMessage(breakdown: TokenBreakdown): string { if (breakdown.prunedTokens > 0) { const withoutPruning = breakdown.total + breakdown.prunedTokens + const messagePrunePart = + breakdown.prunedMessageCount > 0 ? `, ${breakdown.prunedMessageCount} messages` : "" lines.push( - ` Pruned: ${breakdown.prunedCount} tools (~${formatTokenCount(breakdown.prunedTokens)})`, + ` Pruned: ${breakdown.prunedCount} tools${messagePrunePart} (~${formatTokenCount(breakdown.prunedTokens)})`, ) lines.push(` Current context: ~${formatTokenCount(breakdown.total)}`) lines.push(` Without DCP: ~${formatTokenCount(withoutPruning)}`) diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 4c859219..6b8466e7 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -193,9 +193,14 @@ export const insertPruneToolContext = ( (msg) => !(msg.info.role === "user" && isIgnoredUserMessage(msg)), ) + // It's not safe to inject assistant role messages following a user message, as models such + // as Claude expect the assistant "turn" to start with reasoning parts. Reasoning parts in many + // cases also cannot be faked as they may be encrypted by the model. if (!lastNonIgnoredMessage || lastNonIgnoredMessage.info.role === "user") { messages.push(createSyntheticUserMessage(lastUserMessage, combinedContent, variant)) } else { + // For DeepSeek and Kimi, append tool part to existing message, it seems they only allow assistant + // messages to not have reasoning parts if they only have tool parts. const providerID = userInfo.model?.providerID || "" const modelID = userInfo.model?.modelID || "" From e1f5312527aaa00c49082ccc289a96af8f365cb7 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 29 Jan 2026 00:25:55 -0500 Subject: [PATCH 022/113] injection guide comments --- lib/messages/inject.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 6b8466e7..91761393 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -193,14 +193,18 @@ export const insertPruneToolContext = ( (msg) => !(msg.info.role === "user" && isIgnoredUserMessage(msg)), ) - // It's not safe to inject assistant role messages following a user message, as models such + // It's not safe to inject assistant role messages following a user message as models such // as Claude expect the assistant "turn" to start with reasoning parts. Reasoning parts in many // cases also cannot be faked as they may be encrypted by the model. + // Gemini only accepts synth reasoning text if it is "skip_thought_signature_validator" if (!lastNonIgnoredMessage || lastNonIgnoredMessage.info.role === "user") { messages.push(createSyntheticUserMessage(lastUserMessage, combinedContent, variant)) } else { - // For DeepSeek and Kimi, append tool part to existing message, it seems they only allow assistant - // messages to not have reasoning parts if they only have tool parts. + // For DeepSeek and Kimi, append tool part to existing message, for some reason they don't + // output reasoning parts following an assistant injection containing either just text part, + // or text part with synth reasoning, and there's no docs on how their reasoning encryption + // works as far as I can find. IDK what's going on here, seems like the only possible ways + // to inject for them is a user role message, or a tool part apeended to last assistant message. const providerID = userInfo.model?.providerID || "" const modelID = userInfo.model?.modelID || "" From a74cb8b33e562298a7a2d124c25102cbca74fb0d Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Thu, 29 Jan 2026 03:50:34 +0100 Subject: [PATCH 023/113] refactor: new prompt structure and dx cli --- .repomixignore | 9 ++ cli/README.md | 41 +++++++++ cli/print.ts | 117 ++++++++++++++++++++++++++ lib/hooks.ts | 32 ++----- lib/messages/inject.ts | 78 ++++++++--------- lib/prompts/index.ts | 68 ++++++++------- lib/prompts/nudge.md | 16 ++++ lib/prompts/nudge/all.ts | 10 --- lib/prompts/nudge/discard-extract.ts | 10 --- lib/prompts/nudge/discard-squash.ts | 9 -- lib/prompts/nudge/discard.ts | 9 -- lib/prompts/nudge/extract-squash.ts | 9 -- lib/prompts/nudge/extract.ts | 9 -- lib/prompts/nudge/squash.ts | 9 -- lib/prompts/system.md | 91 ++++++++++++++++++++ lib/prompts/system/all.ts | 64 -------------- lib/prompts/system/discard-extract.ts | 61 -------------- lib/prompts/system/discard-squash.ts | 60 ------------- lib/prompts/system/discard.ts | 53 ------------ lib/prompts/system/extract-squash.ts | 60 ------------- lib/prompts/system/extract.ts | 52 ------------ lib/prompts/system/squash.ts | 50 ----------- package.json | 5 +- 23 files changed, 362 insertions(+), 560 deletions(-) create mode 100644 .repomixignore create mode 100644 cli/README.md create mode 100644 cli/print.ts create mode 100644 lib/prompts/nudge.md delete mode 100644 lib/prompts/nudge/all.ts delete mode 100644 lib/prompts/nudge/discard-extract.ts delete mode 100644 lib/prompts/nudge/discard-squash.ts delete mode 100644 lib/prompts/nudge/discard.ts delete mode 100644 lib/prompts/nudge/extract-squash.ts delete mode 100644 lib/prompts/nudge/extract.ts delete mode 100644 lib/prompts/nudge/squash.ts create mode 100644 lib/prompts/system.md delete mode 100644 lib/prompts/system/all.ts delete mode 100644 lib/prompts/system/discard-extract.ts delete mode 100644 lib/prompts/system/discard-squash.ts delete mode 100644 lib/prompts/system/discard.ts delete mode 100644 lib/prompts/system/extract-squash.ts delete mode 100644 lib/prompts/system/extract.ts delete mode 100644 lib/prompts/system/squash.ts diff --git a/.repomixignore b/.repomixignore new file mode 100644 index 00000000..6bc6e2ee --- /dev/null +++ b/.repomixignore @@ -0,0 +1,9 @@ +.github/ +.logs/ +.opencode/ +dist/ +.repomixignore +repomix-output.xml +bun.lock +package-lock.jsonc +LICENCE diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 00000000..3c1774d2 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,41 @@ +# DCP CLI + +Dev tool for previewing prompt outputs. Verify parsing works correctly and quickly check specific tool combinations. + +## Usage + +```bash +bun run dcp [TYPE] [-d] [-e] [-s] +``` + +## Types + +| Flag | Description | +| ------------------ | --------------------------- | +| `--system` | System prompt | +| `--nudge` | Nudge prompt | +| `--prune-list` | Example prunable tools list | +| `--squash-context` | Example squash context | + +## Tool Flags + +| Flag | Description | +| --------------- | ------------------- | +| `-d, --discard` | Enable discard tool | +| `-e, --extract` | Enable extract tool | +| `-s, --squash` | Enable squash tool | + +If no tool flags specified, all are enabled. + +## Examples + +```bash +bun run dcp --system -d -e -s # System prompt with all tools +bun run dcp --system -d # System prompt with discard only +bun run dcp --nudge -e -s # Nudge with extract and squash +bun run dcp --prune-list # Example prunable tools list +``` + +## Purpose + +This CLI does NOT ship with the plugin. It's purely for DX - iterate on prompt templates and verify the `` conditional parsing produces the expected output. diff --git a/cli/print.ts b/cli/print.ts new file mode 100644 index 00000000..94ae2ad5 --- /dev/null +++ b/cli/print.ts @@ -0,0 +1,117 @@ +#!/usr/bin/env npx tsx + +import { renderSystemPrompt, renderNudge, type ToolFlags } from "../lib/prompts/index.js" +import { + wrapPrunableTools, + wrapSquashContext, + wrapCooldownMessage, +} from "../lib/messages/inject.js" + +const args = process.argv.slice(2) + +const flags: ToolFlags = { + discard: args.includes("-d") || args.includes("--discard"), + extract: args.includes("-e") || args.includes("--extract"), + squash: args.includes("-s") || args.includes("--squash"), +} + +// Default to all enabled if none specified +if (!flags.discard && !flags.extract && !flags.squash) { + flags.discard = true + flags.extract = true + flags.squash = true +} + +const showSystem = args.includes("--system") +const showNudge = args.includes("--nudge") +const showPruneList = args.includes("--prune-list") +const showSquashContext = args.includes("--squash-context") +const showCooldown = args.includes("--cooldown") +const showHelp = args.includes("--help") || args.includes("-h") + +if ( + showHelp || + (!showSystem && !showNudge && !showPruneList && !showSquashContext && !showCooldown) +) { + console.log(` +Usage: bun run dcp [TYPE] [-d] [-e] [-s] + +Types: + --system System prompt + --nudge Nudge prompt + --prune-list Example prunable tools list + --squash-context Example squash context + --cooldown Cooldown message after pruning + +Tool flags (for --system and --nudge): + -d, --discard Enable discard tool + -e, --extract Enable extract tool + -s, --squash Enable squash tool + +If no tool flags specified, all are enabled. + +Examples: + bun run dcp --system -d -e -s # System prompt with all tools + bun run dcp --system -d # System prompt with discard only + bun run dcp --nudge -e -s # Nudge with extract and squash + bun run dcp --prune-list # Example prunable tools list +`) + process.exit(0) +} + +const header = (title: string) => { + console.log() + console.log("─".repeat(60)) + console.log(title) + console.log("─".repeat(60)) +} + +if (showSystem) { + const enabled = [ + flags.discard && "discard", + flags.extract && "extract", + flags.squash && "squash", + ] + .filter(Boolean) + .join(", ") + header(`SYSTEM PROMPT (tools: ${enabled})`) + console.log(renderSystemPrompt(flags)) +} + +if (showNudge) { + const enabled = [ + flags.discard && "discard", + flags.extract && "extract", + flags.squash && "squash", + ] + .filter(Boolean) + .join(", ") + header(`NUDGE (tools: ${enabled})`) + console.log(renderNudge(flags)) +} + +if (showPruneList) { + header("PRUNABLE TOOLS LIST (mock example)") + const mockList = `5: read, /path/to/file.ts +8: bash, npm run build +12: glob, src/**/*.ts +15: read, /path/to/another-file.ts` + console.log(wrapPrunableTools(mockList)) +} + +if (showSquashContext) { + header("SQUASH CONTEXT (mock example)") + console.log(wrapSquashContext(45)) +} + +if (showCooldown) { + const enabled = [ + flags.discard && "discard", + flags.extract && "extract", + flags.squash && "squash", + ] + .filter(Boolean) + .join(", ") + header(`COOLDOWN MESSAGE (tools: ${enabled})`) + console.log(wrapCooldownMessage(flags)) +} diff --git a/lib/hooks.ts b/lib/hooks.ts index eee5801b..126fec34 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -5,7 +5,7 @@ import { syncToolCache } from "./state/tool-cache" import { deduplicate, supersedeWrites, purgeErrors } from "./strategies" import { prune, insertPruneToolContext } from "./messages" import { checkSession } from "./state" -import { loadPrompt } from "./prompts" +import { renderSystemPrompt } from "./prompts" import { handleStatsCommand } from "./commands/stats" import { handleContextCommand } from "./commands/context" import { handleHelpCommand } from "./commands/help" @@ -33,31 +33,17 @@ export function createSystemPromptHandler( return } - const discardEnabled = config.tools.discard.enabled - const extractEnabled = config.tools.extract.enabled - const squashEnabled = config.tools.squash.enabled - - let promptName: string - if (discardEnabled && extractEnabled && squashEnabled) { - promptName = "system/system-prompt-all" - } else if (discardEnabled && extractEnabled) { - promptName = "system/system-prompt-discard-extract" - } else if (discardEnabled && squashEnabled) { - promptName = "system/system-prompt-discard-squash" - } else if (extractEnabled && squashEnabled) { - promptName = "system/system-prompt-extract-squash" - } else if (discardEnabled) { - promptName = "system/system-prompt-discard" - } else if (extractEnabled) { - promptName = "system/system-prompt-extract" - } else if (squashEnabled) { - promptName = "system/system-prompt-squash" - } else { + const flags = { + discard: config.tools.discard.enabled, + extract: config.tools.extract.enabled, + squash: config.tools.squash.enabled, + } + + if (!flags.discard && !flags.extract && !flags.squash) { return } - const syntheticPrompt = loadPrompt(promptName) - output.system.push(syntheticPrompt) + output.system.push(renderSystemPrompt(flags)) } } diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 91761393..cc7ec771 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -2,7 +2,7 @@ import type { SessionState, WithParts } from "../state" import type { Logger } from "../logger" import type { PluginConfig } from "../config" import type { UserMessage } from "@opencode-ai/sdk/v2" -import { loadPrompt } from "../prompts" +import { renderNudge } from "../prompts" import { extractParameterKey, buildToolIdList, @@ -15,43 +15,26 @@ import { import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" import { getLastUserMessage, isMessageCompacted } from "../shared-utils" -const getNudgeString = (config: PluginConfig): string => { - const discardEnabled = config.tools.discard.enabled - const extractEnabled = config.tools.extract.enabled - const squashEnabled = config.tools.squash.enabled - - if (discardEnabled && extractEnabled && squashEnabled) { - return loadPrompt(`nudge/nudge-all`) - } else if (discardEnabled && extractEnabled) { - return loadPrompt(`nudge/nudge-discard-extract`) - } else if (discardEnabled && squashEnabled) { - return loadPrompt(`nudge/nudge-discard-squash`) - } else if (extractEnabled && squashEnabled) { - return loadPrompt(`nudge/nudge-extract-squash`) - } else if (discardEnabled) { - return loadPrompt(`nudge/nudge-discard`) - } else if (extractEnabled) { - return loadPrompt(`nudge/nudge-extract`) - } else if (squashEnabled) { - return loadPrompt(`nudge/nudge-squash`) - } - return "" -} - -const wrapPrunableTools = (content: string): string => ` +export const wrapPrunableTools = (content: string): string => ` The following tools have been invoked and are available for pruning. This list does not mandate immediate action. Consider your current goals and the resources you need before pruning valuable tool inputs or outputs. Consolidate your prunes for efficiency; it is rarely worth pruning a single tiny tool output. Keep the context free of noise. ${content} ` -const getCooldownMessage = (config: PluginConfig): string => { - const discardEnabled = config.tools.discard.enabled - const extractEnabled = config.tools.extract.enabled - const squashEnabled = config.tools.squash.enabled +export const wrapSquashContext = (messageCount: number): string => ` +Squash available. Conversation: ${messageCount} messages. +Squash collapses completed task sequences or exploration phases into summaries. +Uses text boundaries [startString, endString, topic, summary]. +` +export const wrapCooldownMessage = (flags: { + discard: boolean + extract: boolean + squash: boolean +}): string => { const enabledTools: string[] = [] - if (discardEnabled) enabledTools.push("discard") - if (extractEnabled) enabledTools.push("extract") - if (squashEnabled) enabledTools.push("squash") + if (flags.discard) enabledTools.push("discard") + if (flags.extract) enabledTools.push("extract") + if (flags.squash) enabledTools.push("squash") let toolName: string if (enabledTools.length === 0) { @@ -64,18 +47,35 @@ const getCooldownMessage = (config: PluginConfig): string => { } return ` -Context management was just performed. Do not use the ${toolName} again. A fresh list will be available after your next tool use. +Context management was just performed. Do NOT use the ${toolName} again. A fresh list will be available after your next tool use. ` } +const getNudgeString = (config: PluginConfig): string => { + const flags = { + discard: config.tools.discard.enabled, + extract: config.tools.extract.enabled, + squash: config.tools.squash.enabled, + } + + if (!flags.discard && !flags.extract && !flags.squash) { + return "" + } + + return renderNudge(flags) +} + +const getCooldownMessage = (config: PluginConfig): string => { + return wrapCooldownMessage({ + discard: config.tools.discard.enabled, + extract: config.tools.extract.enabled, + squash: config.tools.squash.enabled, + }) +} + const buildSquashContext = (state: SessionState, messages: WithParts[]): string => { const messageCount = messages.filter((msg) => !isMessageCompacted(state, msg)).length - - return ` -Squash available. Conversation: ${messageCount} messages. -Squash collapses completed task sequences or exploration phases into summaries. -Uses text boundaries [startString, endString, topic, summary]. -` + return wrapSquashContext(messageCount) } const buildPrunableToolsList = ( diff --git a/lib/prompts/index.ts b/lib/prompts/index.ts index 8a6bf745..a9d70b10 100644 --- a/lib/prompts/index.ts +++ b/lib/prompts/index.ts @@ -1,44 +1,50 @@ +import { readFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + // Tool specs import { DISCARD_TOOL_SPEC } from "./discard-tool-spec" import { EXTRACT_TOOL_SPEC } from "./extract-tool-spec" import { SQUASH_TOOL_SPEC } from "./squash-tool-spec" -// System prompts -import { SYSTEM_PROMPT_DISCARD } from "./system/discard" -import { SYSTEM_PROMPT_EXTRACT } from "./system/extract" -import { SYSTEM_PROMPT_SQUASH } from "./system/squash" -import { SYSTEM_PROMPT_DISCARD_EXTRACT } from "./system/discard-extract" -import { SYSTEM_PROMPT_DISCARD_SQUASH } from "./system/discard-squash" -import { SYSTEM_PROMPT_EXTRACT_SQUASH } from "./system/extract-squash" -import { SYSTEM_PROMPT_ALL } from "./system/all" - -// Nudge prompts -import { NUDGE_DISCARD } from "./nudge/discard" -import { NUDGE_EXTRACT } from "./nudge/extract" -import { NUDGE_SQUASH } from "./nudge/squash" -import { NUDGE_DISCARD_EXTRACT } from "./nudge/discard-extract" -import { NUDGE_DISCARD_SQUASH } from "./nudge/discard-squash" -import { NUDGE_EXTRACT_SQUASH } from "./nudge/extract-squash" -import { NUDGE_ALL } from "./nudge/all" +const __dirname = dirname(fileURLToPath(import.meta.url)) + +// Load markdown prompts at module init +const SYSTEM_PROMPT = readFileSync(join(__dirname, "system.md"), "utf-8") +const NUDGE = readFileSync(join(__dirname, "nudge.md"), "utf-8") + +export interface ToolFlags { + discard: boolean + extract: boolean + squash: boolean +} + +function processConditionals(template: string, flags: ToolFlags): string { + const tools = ["discard", "extract", "squash"] as const + let result = template + // Strip comments: // ... // + result = result.replace(/\/\/.*?\/\//g, "") + // Process tool conditionals + for (const tool of tools) { + const regex = new RegExp(`<${tool}>([\\s\\S]*?)`, "g") + result = result.replace(regex, (_, content) => (flags[tool] ? content : "")) + } + // Collapse multiple blank/whitespace-only lines to single blank line + return result.replace(/\n([ \t]*\n)+/g, "\n\n").trim() +} + +export function renderSystemPrompt(flags: ToolFlags): string { + return processConditionals(SYSTEM_PROMPT, flags) +} + +export function renderNudge(flags: ToolFlags): string { + return processConditionals(NUDGE, flags) +} const PROMPTS: Record = { "discard-tool-spec": DISCARD_TOOL_SPEC, "extract-tool-spec": EXTRACT_TOOL_SPEC, "squash-tool-spec": SQUASH_TOOL_SPEC, - "system/system-prompt-discard": SYSTEM_PROMPT_DISCARD, - "system/system-prompt-extract": SYSTEM_PROMPT_EXTRACT, - "system/system-prompt-squash": SYSTEM_PROMPT_SQUASH, - "system/system-prompt-discard-extract": SYSTEM_PROMPT_DISCARD_EXTRACT, - "system/system-prompt-discard-squash": SYSTEM_PROMPT_DISCARD_SQUASH, - "system/system-prompt-extract-squash": SYSTEM_PROMPT_EXTRACT_SQUASH, - "system/system-prompt-all": SYSTEM_PROMPT_ALL, - "nudge/nudge-discard": NUDGE_DISCARD, - "nudge/nudge-extract": NUDGE_EXTRACT, - "nudge/nudge-squash": NUDGE_SQUASH, - "nudge/nudge-discard-extract": NUDGE_DISCARD_EXTRACT, - "nudge/nudge-discard-squash": NUDGE_DISCARD_SQUASH, - "nudge/nudge-extract-squash": NUDGE_EXTRACT_SQUASH, - "nudge/nudge-all": NUDGE_ALL, } export function loadPrompt(name: string, vars?: Record): string { diff --git a/lib/prompts/nudge.md b/lib/prompts/nudge.md new file mode 100644 index 00000000..c7e835fc --- /dev/null +++ b/lib/prompts/nudge.md @@ -0,0 +1,16 @@ + +**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. + +**Immediate Actions Required:** + +**Phase Completion:** If a phase is complete, use the `squash` tool to condense the entire sequence into a summary. + + +**Noise Removal:** If you read files or ran commands that yielded no value, use the `discard` tool to remove them. If older outputs have been replaced by newer ones, discard the outdated versions. + + +**Knowledge Preservation:** If you are holding valuable raw data you'll need to reference later, use the `extract` tool with high-fidelity distillation to preserve the insights and remove the raw entry. + + +**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must perform context management. + diff --git a/lib/prompts/nudge/all.ts b/lib/prompts/nudge/all.ts deleted file mode 100644 index 08e86e8f..00000000 --- a/lib/prompts/nudge/all.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const NUDGE_ALL = ` -**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. - -**Immediate Actions Required:** -1. **Phase Completion:** If a phase is complete, use the \`squash\` tool to condense the entire sequence into a summary. -2. **Noise Removal:** If you read files or ran commands that yielded no value, use \`discard\` to remove them. -3. **Knowledge Preservation:** If you are holding valuable raw data you'll need to reference later, use \`extract\` to distill the insights and remove the raw entry. - -**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must perform context management. -` diff --git a/lib/prompts/nudge/discard-extract.ts b/lib/prompts/nudge/discard-extract.ts deleted file mode 100644 index 2e1b8615..00000000 --- a/lib/prompts/nudge/discard-extract.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const NUDGE_DISCARD_EXTRACT = ` -**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. - -**Immediate Actions Required:** -1. **Noise Removal:** If you read files or ran commands that yielded no value, use \`discard\` to remove them. -2. **Superseded Info:** If older outputs have been replaced by newer ones, use \`discard\` on the outdated versions. -3. **Knowledge Preservation:** If you have large outputs with valuable technical details, use \`extract\` to distill the insights and remove the raw entry. - -**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must perform context management. -` diff --git a/lib/prompts/nudge/discard-squash.ts b/lib/prompts/nudge/discard-squash.ts deleted file mode 100644 index 699a716f..00000000 --- a/lib/prompts/nudge/discard-squash.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const NUDGE_DISCARD_SQUASH = ` -**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. - -**Immediate Actions Required:** -1. **Phase Completion:** If a phase is complete, use the \`squash\` tool to condense the entire sequence into a summary. -2. **Noise Removal:** If you read files or ran commands that yielded no value, use the \`discard\` tool to remove them. - -**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must perform context management. -` diff --git a/lib/prompts/nudge/discard.ts b/lib/prompts/nudge/discard.ts deleted file mode 100644 index 13e6314b..00000000 --- a/lib/prompts/nudge/discard.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const NUDGE_DISCARD = ` -**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. - -**Immediate Actions Required:** -1. **Noise Removal:** If you read files or ran commands that yielded no value, use the \`discard\` tool to remove them. -2. **Superseded Info:** If older outputs have been replaced by newer ones, discard the outdated versions. - -**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must discard unneeded tool outputs. -` diff --git a/lib/prompts/nudge/extract-squash.ts b/lib/prompts/nudge/extract-squash.ts deleted file mode 100644 index 88053e80..00000000 --- a/lib/prompts/nudge/extract-squash.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const NUDGE_EXTRACT_SQUASH = ` -**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. - -**Immediate Actions Required:** -1. **Phase Completion:** If a phase is complete, use the \`squash\` tool to condense the entire sequence into a summary. -2. **Knowledge Preservation:** If you are holding valuable raw data you'll need to reference later, use \`extract\` to distill the insights and remove the raw entry. - -**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must perform context management. -` diff --git a/lib/prompts/nudge/extract.ts b/lib/prompts/nudge/extract.ts deleted file mode 100644 index 16ea5b78..00000000 --- a/lib/prompts/nudge/extract.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const NUDGE_EXTRACT = ` -**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. - -**Immediate Actions Required:** -1. **Large Outputs:** If you have large tool outputs with valuable technical details, use the \`extract\` tool to distill and preserve key information. -2. **Knowledge Preservation:** If you are holding valuable raw data you'll need to reference later, use the \`extract\` tool with high-fidelity distillation to preserve the insights and remove the raw entry. - -**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must extract valuable findings from tool outputs. -` diff --git a/lib/prompts/nudge/squash.ts b/lib/prompts/nudge/squash.ts deleted file mode 100644 index ba4c9097..00000000 --- a/lib/prompts/nudge/squash.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const NUDGE_SQUASH = ` -**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. - -**Immediate Actions Required:** -1. **Phase Completion:** If a phase is complete, use the \`squash\` tool to condense the entire sequence into a summary. -2. **Exploration Done:** If you explored multiple files or ran multiple commands, squash the results to focus on the next phase. - -**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must squash completed conversation ranges. -` diff --git a/lib/prompts/system.md b/lib/prompts/system.md new file mode 100644 index 00000000..b4671022 --- /dev/null +++ b/lib/prompts/system.md @@ -0,0 +1,91 @@ + + + +ENVIRONMENT +You are operating in a context-constrained environment and must proactively manage your context window. The environment calls the `context_info` tool to provide an up-to-date list after each turn. Use this information when deciding what to prune. + +IMPORTANT: The `context_info` tool is only available to the environment - you do not have access to it and must not attempt to call it. + +AVAILABLE TOOLS + +`discard`: Remove individual tool outputs that are noise, irrelevant, or superseded. No preservation of content. + + +`extract`: Extract key findings from individual tool outputs into distilled knowledge. Use when you need to preserve valuable technical details. + + +`squash`: Collapse a contiguous range of conversation (completed phases) into a single summary. + + +PRUNE METHODICALLY - BATCH YOUR ACTIONS +Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by pruning. Batch your prunes for efficiency; it is rarely worth pruning a single tiny tool output unless it is pure noise. Evaluate what SHOULD be pruned before jumping the gun. + +You MUST NOT prune when: + +- The tool output will be needed for upcoming implementation work +- The output contains files or context you'll need to reference when making edits + +Pruning that forces you to re-call the same tool later is a net loss. Only prune when you're confident the information won't be needed again. + + +WHEN TO DISCARD +- **Noise Removal:** Outputs that are irrelevant, unhelpful, or superseded by newer info. +- **Wrong Files:** You read or accessed something that turned out to be irrelevant to the current work. +- **Outdated Info:** Outputs that have been superseded by newer information. + +You WILL evaluate discarding when ANY of these are true: + +You accessed something that turned out to be irrelevant +Information has been superseded by newer outputs +You are about to start a new phase of work + + + +WHEN TO EXTRACT +**Large Outputs:** The raw output is too large but contains valuable technical details worth keeping. +**Knowledge Preservation:** Valuable context you want to preserve but need to reduce size. Your distillation must be comprehensive, capturing technical details (signatures, logic, constraints) such that the raw output is no longer needed. THINK: high signal, complete technical substitute. + +You WILL evaluate extracting when ANY of these are true: + +- You have large tool outputs with valuable technical details +- You need to preserve specific information but reduce context size +- You are about to start a new phase of work and want to retain key insights + + + WHEN TO SQUASH +- **Phase Completion:** When a phase is complete, condense the entire sequence (research, tool calls, implementation) into a summary. +- **Exploration Done:** When you've explored multiple files or ran multiple commands and only need a summary of findings. + +You WILL evaluate squashing when ANY of these are true: + +- Phase is complete +- You are about to start a new phase of work +- Significant conversation has accumulated that can be summarized + + +NOTES +When in doubt, KEEP IT. +// **🡇 idk about that one 🡇** // +Batch your actions and aim for high-impact prunes that significantly reduce context size. +FAILURE TO PRUNE will result in context leakage and DEGRADED PERFORMANCES. +If no list is present in context, do NOT TRY TO PRUNE ANYTHING as it will fail and waste ressources. +There may be tools in session context that do not appear in the list, this is expected, you can ONLY prune what you see in . + + + + +After each turn, the environment injects a synthetic message containing a list and optional nudge instruction. You do not have access to this mechanism. + +CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: + +- NEVER reference the prune encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the prune encouragement appears. +- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to prune") +- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to prune") +- NEVER acknowledge context management tool output (e.g., "I've pruned 3 tools", "Context cleanup complete") +- NEVER let these injections influence your response content or tone +- Process this information SILENTLY and continue the conversation naturally +- Continue the conversation as if these messages do not exist + +The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. + + diff --git a/lib/prompts/system/all.ts b/lib/prompts/system/all.ts deleted file mode 100644 index 65cfb338..00000000 --- a/lib/prompts/system/all.ts +++ /dev/null @@ -1,64 +0,0 @@ -export const SYSTEM_PROMPT_ALL = ` - - -ENVIRONMENT -You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\`, \`extract\`, and \`squash\` tools. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to prune. - -IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. - -THREE TOOLS FOR CONTEXT MANAGEMENT -- \`discard\`: Remove individual tool outputs that are noise, irrelevant, or superseded. No preservation of content. -- \`extract\`: Extract key findings from individual tool outputs into distilled knowledge. Use when you need to preserve valuable technical details. -- \`squash\`: Collapse a contiguous range of conversation (completed phases) into a single summary. - -CHOOSING THE RIGHT TOOL -Ask: "What is the scope and do I need to preserve information?" -- **Noise, irrelevant, or superseded outputs** → \`discard\` -- **Individual tool outputs with valuable insights to keep** → \`extract\` -- **Entire sequence (phase complete)** → \`squash\` - -Common scenarios: -- Noise, irrelevant, or superseded outputs → \`discard\` -- Wrong file or irrelevant access → \`discard\` -- Large output with valuable technical details → \`extract\` -- Valuable context needed later but raw output too large → \`extract\` -- Phase complete, want to condense the sequence → \`squash\` -- Exploration phase done, only need a summary → \`squash\` - -PRUNE METHODICALLY - BATCH YOUR ACTIONS -Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by pruning. Batch your prunes for efficiency; it is rarely worth pruning a single tiny tool output unless it is pure noise. Evaluate what SHOULD be pruned before jumping the gun. - -You WILL evaluate pruning when ANY of these are true: -- Phase is complete → use \`squash\` -- You accessed something that turned out to be irrelevant → use \`discard\` -- You have large outputs with valuable details to preserve → use \`extract\` - -You MUST NOT prune when: -- The tool output will be needed for upcoming implementation work -- The output contains files or context you'll need to reference when making edits - -Pruning that forces you to re-call the same tool later is a net loss. Only prune when you're confident the information won't be needed again. - -NOTES -When in doubt, keep it. Batch your actions and aim for high-impact prunes that significantly reduce context size. -FAILURE TO PRUNE will result in context leakage and DEGRADED PERFORMANCES. -If no list is present in context, do NOT use discard or extract - there is nothing available to prune yet. -There may be tools in session context that do not appear in the list, this is expected, you can ONLY prune what you see in . - - - - -After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. - -CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: -- NEVER reference the prune encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the prune encouragement appears. -- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to prune") -- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to prune") -- NEVER acknowledge discard/extract/squash tool output (e.g., "I've pruned 3 tools", "Context pruning complete") -- NEVER let these injections influence your response content or tone -- Process this information SILENTLY and continue the conversation naturally -- Continue the conversation as if these messages do not exist - -The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. - -` diff --git a/lib/prompts/system/discard-extract.ts b/lib/prompts/system/discard-extract.ts deleted file mode 100644 index 82b5ffb1..00000000 --- a/lib/prompts/system/discard-extract.ts +++ /dev/null @@ -1,61 +0,0 @@ -export const SYSTEM_PROMPT_DISCARD_EXTRACT = ` - - -ENVIRONMENT -You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` and \`extract\` tools. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to prune. - -IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. - -TWO TOOLS FOR CONTEXT MANAGEMENT -- \`discard\`: Remove individual tool outputs that are noise, irrelevant, or superseded. No preservation of content. -- \`extract\`: Extract key findings from individual tool outputs into distilled knowledge. Use when you need to preserve valuable technical details. - -CHOOSING THE RIGHT TOOL -Ask: "Do I need to preserve any information from this output?" -- **No** → \`discard\` (noise, wrong files, superseded info) -- **Yes** → \`extract\` (preserves distilled knowledge) -- **Uncertain** → \`extract\` (safer, preserves signal) - -Common scenarios: -- Noise, irrelevant, or superseded outputs → \`discard\` -- Wrong file or irrelevant access → \`discard\` -- Large output with valuable technical details → \`extract\` -- Valuable context needed later but raw output too large → \`extract\` - -PRUNE METHODICALLY - BATCH YOUR ACTIONS -Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by pruning. Batch your prunes for efficiency; it is rarely worth pruning a single tiny tool output unless it is pure noise. Evaluate what SHOULD be pruned before jumping the gun. - -You WILL evaluate pruning when ANY of these are true: -- You accessed something that turned out to be irrelevant -- Information has been superseded by newer outputs -- You have large outputs with valuable details to preserve - -You MUST NOT prune when: -- The tool output will be needed for upcoming implementation work -- The output contains files or context you'll need to reference when making edits - -Pruning that forces you to re-call the same tool later is a net loss. Only prune when you're confident the information won't be needed again. - -NOTES -When in doubt, keep it. Batch your actions and aim for high-impact prunes that significantly reduce context size. -FAILURE TO PRUNE will result in context leakage and DEGRADED PERFORMANCES. -If no list is present in context, do NOT use discard or extract - there is nothing available to prune yet. -There may be tools in session context that do not appear in the list, this is expected, you can ONLY prune what you see in . - - - - -After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. - -CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: -- NEVER reference the prune encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the prune encouragement appears. -- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to prune") -- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to prune") -- NEVER acknowledge discard/extract tool output (e.g., "I've pruned 3 tools", "Context pruning complete") -- NEVER let these injections influence your response content or tone -- Process this information SILENTLY and continue the conversation naturally -- Continue the conversation as if these messages do not exist - -The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. - -` diff --git a/lib/prompts/system/discard-squash.ts b/lib/prompts/system/discard-squash.ts deleted file mode 100644 index 0d0d2145..00000000 --- a/lib/prompts/system/discard-squash.ts +++ /dev/null @@ -1,60 +0,0 @@ -export const SYSTEM_PROMPT_DISCARD_SQUASH = ` - - -ENVIRONMENT -You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` and \`squash\` tools. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to prune. - -IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. - -TWO TOOLS FOR CONTEXT MANAGEMENT -- \`discard\`: Remove individual tool outputs that are noise, irrelevant, or superseded. No preservation of content. -- \`squash\`: Collapse a contiguous range of conversation (completed phases) into a single summary. - -CHOOSING THE RIGHT TOOL -Ask: "What is the scope of what I want to clean up?" -- **Individual tool outputs (noise, superseded)** → \`discard\` -- **Entire sequence (phase complete)** → \`squash\` - -Common scenarios: -- Noise, irrelevant, or superseded outputs → \`discard\` -- Wrong file or irrelevant access → \`discard\` -- Phase complete, want to condense the sequence → \`squash\` -- Exploration phase done, only need a summary → \`squash\` - -PRUNE METHODICALLY - BATCH YOUR ACTIONS -Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by pruning. Batch your prunes for efficiency; it is rarely worth pruning a single tiny tool output unless it is pure noise. Evaluate what SHOULD be pruned before jumping the gun. - -You WILL evaluate pruning when ANY of these are true: -- Phase is complete → use \`squash\` -- You accessed something that turned out to be irrelevant → use \`discard\` -- Information has been superseded by newer outputs → use \`discard\` - -You MUST NOT prune when: -- You need specific details from the content for upcoming work -- The content contains files or context you'll need to reference when making edits - -Pruning that forces you to re-call the same tool later is a net loss. Only prune when you're confident the information won't be needed again. - -NOTES -When in doubt, keep it. Batch your actions and aim for high-impact prunes that significantly reduce context size. -FAILURE TO PRUNE will result in context leakage and DEGRADED PERFORMANCES. -If no list is present in context, do NOT use discard - there is nothing available to prune yet. -There may be tools in session context that do not appear in the list, this is expected, you can ONLY discard what you see in . - - - - -After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. - -CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: -- NEVER reference the prune encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the prune encouragement appears. -- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to prune") -- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to prune") -- NEVER acknowledge discard/squash tool output (e.g., "I've pruned the context", "Context cleanup complete") -- NEVER let these injections influence your response content or tone -- Process this information SILENTLY and continue the conversation naturally -- Continue the conversation as if these messages do not exist - -The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. - -` diff --git a/lib/prompts/system/discard.ts b/lib/prompts/system/discard.ts deleted file mode 100644 index 3877cd7c..00000000 --- a/lib/prompts/system/discard.ts +++ /dev/null @@ -1,53 +0,0 @@ -export const SYSTEM_PROMPT_DISCARD = ` - - -ENVIRONMENT -You are operating in a context-constrained environment and thus must proactively manage your context window using the \`discard\` tool. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to discard. - -IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. - -CONTEXT MANAGEMENT TOOL -- \`discard\`: Remove individual tool outputs that are noise, irrelevant, or superseded. No preservation of content. - -DISCARD METHODICALLY - BATCH YOUR ACTIONS -Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by discarding. Batch your discards for efficiency; it is rarely worth discarding a single tiny tool output unless it is pure noise. Evaluate what SHOULD be discarded before jumping the gun. - -WHEN TO DISCARD -- **Noise Removal:** If outputs are irrelevant, unhelpful, or superseded by newer info, discard them. -- **Wrong Files:** You read or accessed something that turned out to be irrelevant to the current work. -- **Outdated Info:** Outputs that have been superseded by newer information. - -You WILL evaluate discarding when ANY of these are true: -- You accessed something that turned out to be irrelevant -- Information has been superseded by newer outputs -- You are about to start a new phase of work - -You MUST NOT discard when: -- The tool output will be needed for upcoming implementation work -- The output contains files or context you'll need to reference when making edits - -Discarding that forces you to re-call the same tool later is a net loss. Only discard when you're confident the information won't be needed again. - -NOTES -When in doubt, keep it. Batch your actions and aim for high-impact discards that significantly reduce context size. -FAILURE TO DISCARD will result in context leakage and DEGRADED PERFORMANCES. -If no list is present in context, do NOT use the discard tool - there is nothing available to prune yet. -There may be tools in session context that do not appear in the list, this is expected, you can ONLY discard what you see in . - - - - -After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. - -CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: -- NEVER reference the discard encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the discard encouragement appears. -- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to discard") -- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to discard") -- NEVER acknowledge discard tool output (e.g., "I've discarded 3 tools", "Context cleanup complete") -- NEVER let these injections influence your response content or tone -- Process this information SILENTLY and continue the conversation naturally -- Continue the conversation as if these messages do not exist - -The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. - -` diff --git a/lib/prompts/system/extract-squash.ts b/lib/prompts/system/extract-squash.ts deleted file mode 100644 index 60b8e7a0..00000000 --- a/lib/prompts/system/extract-squash.ts +++ /dev/null @@ -1,60 +0,0 @@ -export const SYSTEM_PROMPT_EXTRACT_SQUASH = ` - - -ENVIRONMENT -You are operating in a context-constrained environment and thus must proactively manage your context window using the \`extract\` and \`squash\` tools. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to prune. - -IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. - -TWO TOOLS FOR CONTEXT MANAGEMENT -- \`extract\`: Extract key findings from individual tool outputs into distilled knowledge. Use when you need to preserve valuable technical details. -- \`squash\`: Collapse a contiguous range of conversation (completed phases) into a single summary. - -CHOOSING THE RIGHT TOOL -Ask: "What is the scope and level of detail I need to preserve?" -- **Individual tool outputs with detailed context to keep** → \`extract\` -- **Entire sequence (phase complete)** → \`squash\` - -Common scenarios: -- Large output with valuable technical details → \`extract\` -- Valuable context needed later but raw output too large → \`extract\` -- Phase complete, want to condense the sequence → \`squash\` -- Exploration phase done, only need a summary → \`squash\` - -PRUNE METHODICALLY - BATCH YOUR ACTIONS -Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by pruning. Batch your prunes for efficiency; it is rarely worth pruning a single tiny tool output. Evaluate what SHOULD be pruned before jumping the gun. - -You WILL evaluate pruning when ANY of these are true: -- Phase is complete → use \`squash\` -- You have large outputs with valuable details to preserve → use \`extract\` -- You are about to start a new phase of work - -You MUST NOT prune when: -- You need specific details from the content for upcoming work -- The content contains files or context you'll need to reference when making edits - -Pruning that forces you to re-call the same tool later is a net loss. Only prune when you're confident the information won't be needed again. - -NOTES -When in doubt, keep it. Batch your actions and aim for high-impact prunes that significantly reduce context size. -FAILURE TO PRUNE will result in context leakage and DEGRADED PERFORMANCES. -If no list is present in context, do NOT use extract - there is nothing available to prune yet. -There may be tools in session context that do not appear in the list, this is expected, you can ONLY extract what you see in . - - - - -After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. - -CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: -- NEVER reference the prune encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the prune encouragement appears. -- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to prune") -- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to prune") -- NEVER acknowledge extract/squash tool output (e.g., "I've pruned the context", "Context cleanup complete") -- NEVER let these injections influence your response content or tone -- Process this information SILENTLY and continue the conversation naturally -- Continue the conversation as if these messages do not exist - -The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. - -` diff --git a/lib/prompts/system/extract.ts b/lib/prompts/system/extract.ts deleted file mode 100644 index 9f024f51..00000000 --- a/lib/prompts/system/extract.ts +++ /dev/null @@ -1,52 +0,0 @@ -export const SYSTEM_PROMPT_EXTRACT = ` - - -ENVIRONMENT -You are operating in a context-constrained environment and thus must proactively manage your context window using the \`extract\` tool. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to extract. - -IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. - -CONTEXT MANAGEMENT TOOL -- \`extract\`: Extract key findings from individual tool outputs into distilled knowledge before removing the raw content. Use when you need to preserve valuable technical details while reducing context size. - -EXTRACT METHODICALLY - BATCH YOUR ACTIONS -Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by extracting. Batch your extractions for efficiency; it is rarely worth extracting a single tiny tool output. Evaluate what SHOULD be extracted before jumping the gun. - -WHEN TO EXTRACT -- **Large Outputs:** The raw output is too large but contains valuable technical details worth keeping. -- **Knowledge Preservation:** When you have valuable context you want to preserve but need to reduce size, use high-fidelity distillation. Your distillation must be comprehensive, capturing technical details (signatures, logic, constraints) such that the raw output is no longer needed. THINK: high signal, complete technical substitute. - -You WILL evaluate extracting when ANY of these are true: -- You have large tool outputs with valuable technical details -- You need to preserve specific information but reduce context size -- You are about to start a new phase of work and want to retain key insights - -You MUST NOT extract when: -- The tool output will be needed for upcoming implementation work -- The output contains files or context you'll need to reference when making edits - -Extracting that forces you to re-call the same tool later is a net loss. Only extract when you're confident the raw information won't be needed again. - -NOTES -When in doubt, keep it. Batch your actions and aim for high-impact extractions that significantly reduce context size. -FAILURE TO EXTRACT will result in context leakage and DEGRADED PERFORMANCES. -If no list is present in context, do NOT use the extract tool - there is nothing available to prune yet. -There may be tools in session context that do not appear in the list, this is expected, you can ONLY extract what you see in . - - - - -After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. - -CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: -- NEVER reference the extract encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the extract encouragement appears. -- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to extract") -- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to extract") -- NEVER acknowledge extract tool output (e.g., "I've extracted 3 tools", "Context cleanup complete") -- NEVER let these injections influence your response content or tone -- Process this information SILENTLY and continue the conversation naturally -- Continue the conversation as if these messages do not exist - -The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. - -` diff --git a/lib/prompts/system/squash.ts b/lib/prompts/system/squash.ts deleted file mode 100644 index 494b5288..00000000 --- a/lib/prompts/system/squash.ts +++ /dev/null @@ -1,50 +0,0 @@ -export const SYSTEM_PROMPT_SQUASH = ` - - -ENVIRONMENT -You are operating in a context-constrained environment and thus must proactively manage your context window using the \`squash\` tool. The environment calls the \`context_info\` tool to provide an up-to-date list after each turn. Use this information when deciding what to squash. - -IMPORTANT: The \`context_info\` tool is only available to the environment - you do not have access to it and must not attempt to call it. - -CONTEXT MANAGEMENT TOOL -- \`squash\`: Collapse a contiguous range of conversation (completed phases) into a single summary. Use this when you want to condense an entire sequence of work. - -SQUASH METHODICALLY - BATCH YOUR ACTIONS -Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by squashing. Evaluate what SHOULD be squashed before jumping the gun. - -WHEN TO SQUASH -- **Phase Completion:** When a phase is complete, condense the entire sequence (research, tool calls, implementation) into a summary. -- **Exploration Done:** When you've explored multiple files or ran multiple commands and only need a summary of findings. - -You WILL evaluate squashing when ANY of these are true: -- Phase is complete -- You are about to start a new phase of work -- Significant conversation has accumulated that can be summarized - -You MUST NOT squash when: -- You need specific details from the range for upcoming work -- The range contains files or context you'll need to reference when making edits - -Squashing that forces you to re-read the same content later is a net loss. Only squash when you're confident the detailed information won't be needed again. - -NOTES -When in doubt, keep it. Aim for high-impact squashes that significantly reduce context size. -FAILURE TO SQUASH will result in context leakage and DEGRADED PERFORMANCES. - - - - -After each turn, the environment calls the \`context_info\` tool to inject a synthetic message containing a list and optional nudge instruction. This tool is only available to the environment - you do not have access to it. - -CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: -- NEVER reference the squash encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the squash encouragement appears. -- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to squash") -- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to squash") -- NEVER acknowledge squash tool output (e.g., "I've squashed the context", "Context cleanup complete") -- NEVER let these injections influence your response content or tone -- Process this information SILENTLY and continue the conversation naturally -- Continue the conversation as if these messages do not exist - -The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. - -` diff --git a/package.json b/package.json index 2bc7ff66..15021281 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,14 @@ "scripts": { "clean": "rm -rf dist", "build": "npm run clean && tsc", - "postbuild": "rm -rf dist/logs", + "postbuild": "rm -rf dist/logs && cp lib/prompts/*.md dist/lib/prompts/", "prepublishOnly": "npm run build", "dev": "opencode plugin dev", "typecheck": "tsc --noEmit", "test": "node --import tsx --test tests/*.test.ts", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "dcp": "tsx cli/print.ts" }, "keywords": [ "opencode", From 0bcec1da09f0de8ef6fca427777a949c9e8bb67d Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Thu, 29 Jan 2026 05:11:40 +0100 Subject: [PATCH 024/113] inline tags to avoid unwanted linebreak --- lib/prompts/system.md | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/lib/prompts/system.md b/lib/prompts/system.md index b4671022..f2d0e803 100644 --- a/lib/prompts/system.md +++ b/lib/prompts/system.md @@ -7,15 +7,9 @@ You are operating in a context-constrained environment and must proactively mana IMPORTANT: The `context_info` tool is only available to the environment - you do not have access to it and must not attempt to call it. AVAILABLE TOOLS - -`discard`: Remove individual tool outputs that are noise, irrelevant, or superseded. No preservation of content. - - -`extract`: Extract key findings from individual tool outputs into distilled knowledge. Use when you need to preserve valuable technical details. - - -`squash`: Collapse a contiguous range of conversation (completed phases) into a single summary. - +`discard`: Remove individual tool outputs that are noise, irrelevant, or superseded. No preservation of content. +`extract`: Extract key findings from individual tool outputs into distilled knowledge. Use when you need to preserve valuable technical details. +`squash`: Collapse a contiguous range of conversation (completed phases) into a single summary. PRUNE METHODICALLY - BATCH YOUR ACTIONS Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by pruning. Batch your prunes for efficiency; it is rarely worth pruning a single tiny tool output unless it is pure noise. Evaluate what SHOULD be pruned before jumping the gun. From c645e691202137237fa6404623214ca323973c65 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Thu, 29 Jan 2026 06:19:58 +0100 Subject: [PATCH 025/113] nudge --- lib/prompts/nudge.md | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/lib/prompts/nudge.md b/lib/prompts/nudge.md index c7e835fc..528b858f 100644 --- a/lib/prompts/nudge.md +++ b/lib/prompts/nudge.md @@ -1,16 +1,12 @@ -**CRITICAL CONTEXT WARNING:** Your context window is filling with tool outputs. Strict adherence to context hygiene is required. +CRITICAL CONTEXT WARNING +Your context window is filling with tool. Strict adherence to context hygiene is required. -**Immediate Actions Required:** - -**Phase Completion:** If a phase is complete, use the `squash` tool to condense the entire sequence into a summary. - - -**Noise Removal:** If you read files or ran commands that yielded no value, use the `discard` tool to remove them. If older outputs have been replaced by newer ones, discard the outdated versions. - - -**Knowledge Preservation:** If you are holding valuable raw data you'll need to reference later, use the `extract` tool with high-fidelity distillation to preserve the insights and remove the raw entry. - +PROTOCOL +You should prioritize 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. -**Protocol:** You should prioritize this cleanup, but do not interrupt a critical atomic operation if one is in progress. Once the immediate step is done, you must perform context management. +IMMEDIATE ACTION REQUIRED +KNOWLEDGE PRESERVATION: If holding valuable raw data you POTENTIALLY will need in your task, use the `extract` tool. Produce a high-fidelity distillation to preserve insights - be thorough +NOISE REMOVAL: If you read files or ran commands that yielded no value, use the `discard` tool to remove them. If newer tools supersedes older ones, discard the old +PHASE COMPLETION: If a phase is complete, use the `squash` tool to condense the entire sequence into a detailed summary From 06ce131b4b3daf9f9724763fb73bb3bbc2bf6ed6 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Thu, 29 Jan 2026 06:20:04 +0100 Subject: [PATCH 026/113] gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 9f567cb5..177c17b2 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ test-update.ts # Documentation (local development only) docs/ SCHEMA_NOTES.md + +repomix-output.xml \ No newline at end of file From 53e2c1586c8f8a0d5b5706bac5d3924fe631f27e Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Thu, 29 Jan 2026 06:26:53 +0100 Subject: [PATCH 027/113] DCP: Distill Compress Prune --- README.md | 18 +-- cli/README.md | 30 ++--- cli/print.ts | 68 ++++++------ dcp.schema.json | 18 +-- index.ts | 20 ++-- lib/config.ts | 105 +++++++++--------- lib/hooks.ts | 8 +- lib/messages/inject.ts | 62 +++++------ lib/messages/prune.ts | 18 +-- lib/messages/utils.ts | 2 +- ...ash-tool-spec.ts => compress-tool-spec.ts} | 22 ++-- lib/prompts/discard-tool-spec.ts | 39 ------- ...ract-tool-spec.ts => distill-tool-spec.ts} | 20 ++-- lib/prompts/index.ts | 20 ++-- lib/prompts/nudge.md | 6 +- lib/prompts/prune-tool-spec.ts | 39 +++++++ lib/prompts/system.md | 30 ++--- lib/state/persistence.ts | 6 +- lib/state/state.ts | 6 +- lib/state/tool-cache.ts | 8 +- lib/state/types.ts | 4 +- lib/state/utils.ts | 2 +- lib/strategies/index.ts | 2 +- lib/tools/{squash.ts => compress.ts} | 44 ++++---- lib/tools/{extract.ts => distill.ts} | 14 +-- lib/tools/index.ts | 6 +- lib/tools/{discard.ts => prune.ts} | 10 +- lib/tools/utils.ts | 12 +- lib/ui/notification.ts | 12 +- 29 files changed, 329 insertions(+), 322 deletions(-) rename lib/prompts/{squash-tool-spec.ts => compress-tool-spec.ts} (71%) delete mode 100644 lib/prompts/discard-tool-spec.ts rename lib/prompts/{extract-tool-spec.ts => distill-tool-spec.ts} (75%) create mode 100644 lib/prompts/prune-tool-spec.ts rename lib/tools/{squash.ts => compress.ts} (76%) rename lib/tools/{extract.ts => distill.ts} (82%) rename lib/tools/{discard.ts => prune.ts} (72%) diff --git a/README.md b/README.md index 0a63b74c..afd338c3 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,11 @@ DCP uses multiple tools and strategies to reduce context size: ### Tools -**Discard** — Exposes a `discard` tool that the AI can call to remove completed or noisy tool content from context. +**Prune** — Exposes a `prune` tool that the AI can call to remove completed or noisy tool content from context. -**Extract** — Exposes an `extract` tool that the AI can call to distill valuable context into concise summaries before removing the tool content. +**Distill** — Exposes a `distill` tool that the AI can call to distill valuable context into concise summaries before removing the tool content. -**Squash** — Exposes a `squash` tool that the AI can call to collapse a large section of conversation (messages and tools) into a single summary. +**Compress** — Exposes a `compress` tool that the AI can call to collapse a large section of conversation (messages and tools) into a single summary. ### Strategies @@ -60,7 +60,7 @@ DCP uses its own config file: - Global: `~/.config/opencode/dcp.jsonc` (or `dcp.json`), created automatically on first run - Custom config directory: `$OPENCODE_CONFIG_DIR/dcp.jsonc` (or `dcp.json`), if `OPENCODE_CONFIG_DIR` is set -- Project: `.opencode/dcp.jsonc` (or `dcp.json`) in your project’s `.opencode` directory +- Project: `.opencode/dcp.jsonc` (or `dcp.json`) in your project's `.opencode` directory
Default Configuration (click to expand) @@ -99,17 +99,17 @@ DCP uses its own config file: "protectedTools": [], }, // Removes tool content from context without preservation (for completed tasks or noise) - "discard": { + "prune": { "enabled": true, }, // Distills key findings into preserved knowledge before removing raw content - "extract": { + "distill": { "enabled": true, // Show distillation content as an ignored message notification "showDistillation": false, }, // Collapses a range of conversation content into a single summary - "squash": { + "compress": { "enabled": true, // Show summary content as an ignored message notification "showSummary": true, @@ -152,12 +152,12 @@ DCP provides a `/dcp` slash command: ### Turn Protection -When enabled, turn protection prevents tool outputs from being pruned for a configurable number of message turns. This gives the AI time to reference recent tool outputs before they become prunable. Applies to both `discard` and `extract` tools, as well as automatic strategies. +When enabled, turn protection prevents tool outputs from being pruned for a configurable number of message turns. This gives the AI time to reference recent tool outputs before they become prunable. Applies to both `prune` and `distill` tools, as well as automatic strategies. ### Protected Tools By default, these tools are always protected from pruning across all strategies: -`task`, `todowrite`, `todoread`, `discard`, `extract`, `squash`, `batch`, `write`, `edit`, `plan_enter`, `plan_exit` +`task`, `todowrite`, `todoread`, `prune`, `distill`, `compress`, `batch`, `write`, `edit`, `plan_enter`, `plan_exit` The `protectedTools` arrays in each section add to this default list. diff --git a/cli/README.md b/cli/README.md index 3c1774d2..abbd3398 100644 --- a/cli/README.md +++ b/cli/README.md @@ -5,34 +5,34 @@ Dev tool for previewing prompt outputs. Verify parsing works correctly and quick ## Usage ```bash -bun run dcp [TYPE] [-d] [-e] [-s] +bun run dcp [TYPE] [-p] [-d] [-c] ``` ## Types -| Flag | Description | -| ------------------ | --------------------------- | -| `--system` | System prompt | -| `--nudge` | Nudge prompt | -| `--prune-list` | Example prunable tools list | -| `--squash-context` | Example squash context | +| Flag | Description | +| -------------------- | --------------------------- | +| `--system` | System prompt | +| `--nudge` | Nudge prompt | +| `--prune-list` | Example prunable tools list | +| `--compress-context` | Example compress context | ## Tool Flags -| Flag | Description | -| --------------- | ------------------- | -| `-d, --discard` | Enable discard tool | -| `-e, --extract` | Enable extract tool | -| `-s, --squash` | Enable squash tool | +| Flag | Description | +| ---------------- | -------------------- | +| `-p, --prune` | Enable prune tool | +| `-d, --distill` | Enable distill tool | +| `-c, --compress` | Enable compress tool | If no tool flags specified, all are enabled. ## Examples ```bash -bun run dcp --system -d -e -s # System prompt with all tools -bun run dcp --system -d # System prompt with discard only -bun run dcp --nudge -e -s # Nudge with extract and squash +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 ``` diff --git a/cli/print.ts b/cli/print.ts index 94ae2ad5..8d795cd3 100644 --- a/cli/print.ts +++ b/cli/print.ts @@ -3,57 +3,57 @@ import { renderSystemPrompt, renderNudge, type ToolFlags } from "../lib/prompts/index.js" import { wrapPrunableTools, - wrapSquashContext, + wrapCompressContext, wrapCooldownMessage, } from "../lib/messages/inject.js" const args = process.argv.slice(2) const flags: ToolFlags = { - discard: args.includes("-d") || args.includes("--discard"), - extract: args.includes("-e") || args.includes("--extract"), - squash: args.includes("-s") || args.includes("--squash"), + prune: args.includes("-p") || args.includes("--prune"), + distill: args.includes("-d") || args.includes("--distill"), + compress: args.includes("-c") || args.includes("--compress"), } // Default to all enabled if none specified -if (!flags.discard && !flags.extract && !flags.squash) { - flags.discard = true - flags.extract = true - flags.squash = true +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 showSquashContext = args.includes("--squash-context") +const showCompressContext = args.includes("--compress-context") const showCooldown = args.includes("--cooldown") const showHelp = args.includes("--help") || args.includes("-h") if ( showHelp || - (!showSystem && !showNudge && !showPruneList && !showSquashContext && !showCooldown) + (!showSystem && !showNudge && !showPruneList && !showCompressContext && !showCooldown) ) { console.log(` -Usage: bun run dcp [TYPE] [-d] [-e] [-s] +Usage: bun run dcp [TYPE] [-p] [-d] [-c] Types: - --system System prompt - --nudge Nudge prompt - --prune-list Example prunable tools list - --squash-context Example squash context - --cooldown Cooldown message after pruning + --system System prompt + --nudge Nudge prompt + --prune-list Example prunable tools list + --compress-context Example compress context + --cooldown Cooldown message after pruning Tool flags (for --system and --nudge): - -d, --discard Enable discard tool - -e, --extract Enable extract tool - -s, --squash Enable squash tool + -p, --prune Enable prune tool + -d, --distill Enable distill tool + -c, --compress Enable compress tool If no tool flags specified, all are enabled. Examples: - bun run dcp --system -d -e -s # System prompt with all tools - bun run dcp --system -d # System prompt with discard only - bun run dcp --nudge -e -s # Nudge with extract and squash + bun run dcp --system -p -d -c # System prompt with all tools + bun run dcp --system -p # System prompt with prune only + bun run dcp --nudge -d -c # Nudge with distill and compress bun run dcp --prune-list # Example prunable tools list `) process.exit(0) @@ -68,9 +68,9 @@ const header = (title: string) => { if (showSystem) { const enabled = [ - flags.discard && "discard", - flags.extract && "extract", - flags.squash && "squash", + flags.prune && "prune", + flags.distill && "distill", + flags.compress && "compress", ] .filter(Boolean) .join(", ") @@ -80,9 +80,9 @@ if (showSystem) { if (showNudge) { const enabled = [ - flags.discard && "discard", - flags.extract && "extract", - flags.squash && "squash", + flags.prune && "prune", + flags.distill && "distill", + flags.compress && "compress", ] .filter(Boolean) .join(", ") @@ -99,16 +99,16 @@ if (showPruneList) { console.log(wrapPrunableTools(mockList)) } -if (showSquashContext) { - header("SQUASH CONTEXT (mock example)") - console.log(wrapSquashContext(45)) +if (showCompressContext) { + header("COMPRESS CONTEXT (mock example)") + console.log(wrapCompressContext(45)) } if (showCooldown) { const enabled = [ - flags.discard && "discard", - flags.extract && "extract", - flags.squash && "squash", + flags.prune && "prune", + flags.distill && "distill", + flags.compress && "compress", ] .filter(Boolean) .join(", ") diff --git a/dcp.schema.json b/dcp.schema.json index bd458ac3..9d87cbac 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -105,27 +105,27 @@ } } }, - "discard": { + "prune": { "type": "object", - "description": "Configuration for the discard tool", + "description": "Configuration for the prune tool", "additionalProperties": false, "properties": { "enabled": { "type": "boolean", "default": true, - "description": "Enable the discard tool" + "description": "Enable the prune tool" } } }, - "extract": { + "distill": { "type": "object", - "description": "Configuration for the extract tool", + "description": "Configuration for the distill tool", "additionalProperties": false, "properties": { "enabled": { "type": "boolean", "default": true, - "description": "Enable the extract tool" + "description": "Enable the distill tool" }, "showDistillation": { "type": "boolean", @@ -134,15 +134,15 @@ } } }, - "squash": { + "compress": { "type": "object", - "description": "Configuration for the squash tool", + "description": "Configuration for the compress tool", "additionalProperties": false, "properties": { "enabled": { "type": "boolean", "default": true, - "description": "Enable the squash tool" + "description": "Enable the compress tool" }, "showSummary": { "type": "boolean", diff --git a/index.ts b/index.ts index fc8ab62a..1537a52b 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 { createDiscardTool, createExtractTool, createSquashTool } from "./lib/strategies" +import { createPruneTool, createDistillTool, createCompressTool } from "./lib/strategies" import { createChatMessageTransformHandler, createCommandExecuteHandler, @@ -61,8 +61,8 @@ const plugin: Plugin = (async (ctx) => { ctx.directory, ), tool: { - ...(config.tools.discard.enabled && { - discard: createDiscardTool({ + ...(config.tools.prune.enabled && { + prune: createPruneTool({ client: ctx.client, state, logger, @@ -70,8 +70,8 @@ const plugin: Plugin = (async (ctx) => { workingDirectory: ctx.directory, }), }), - ...(config.tools.extract.enabled && { - extract: createExtractTool({ + ...(config.tools.distill.enabled && { + distill: createDistillTool({ client: ctx.client, state, logger, @@ -79,8 +79,8 @@ const plugin: Plugin = (async (ctx) => { workingDirectory: ctx.directory, }), }), - ...(config.tools.squash.enabled && { - squash: createSquashTool({ + ...(config.tools.compress.enabled && { + compress: createCompressTool({ client: ctx.client, state, logger, @@ -99,9 +99,9 @@ const plugin: Plugin = (async (ctx) => { } const toolsToAdd: string[] = [] - if (config.tools.discard.enabled) toolsToAdd.push("discard") - if (config.tools.extract.enabled) toolsToAdd.push("extract") - if (config.tools.squash.enabled) toolsToAdd.push("squash") + if (config.tools.prune.enabled) toolsToAdd.push("prune") + if (config.tools.distill.enabled) toolsToAdd.push("distill") + if (config.tools.compress.enabled) toolsToAdd.push("compress") if (toolsToAdd.length > 0) { const existingPrimaryTools = opencodeConfig.experimental?.primary_tools ?? [] diff --git a/lib/config.ts b/lib/config.ts index e0b0b7f8..5f3b4e39 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -9,16 +9,16 @@ export interface Deduplication { protectedTools: string[] } -export interface DiscardTool { +export interface PruneTool { enabled: boolean } -export interface ExtractTool { +export interface DistillTool { enabled: boolean showDistillation: boolean } -export interface SquashTool { +export interface CompressTool { enabled: boolean showSummary: boolean } @@ -31,9 +31,9 @@ export interface ToolSettings { export interface Tools { settings: ToolSettings - discard: DiscardTool - extract: ExtractTool - squash: SquashTool + prune: PruneTool + distill: DistillTool + compress: CompressTool } export interface Commands { @@ -75,9 +75,9 @@ const DEFAULT_PROTECTED_TOOLS = [ "task", "todowrite", "todoread", - "discard", - "extract", - "squash", + "prune", + "distill", + "compress", "batch", "write", "edit", @@ -105,14 +105,14 @@ export const VALID_CONFIG_KEYS = new Set([ "tools.settings.nudgeEnabled", "tools.settings.nudgeFrequency", "tools.settings.protectedTools", - "tools.discard", - "tools.discard.enabled", - "tools.extract", - "tools.extract.enabled", - "tools.extract.showDistillation", - "tools.squash", - "tools.squash.enabled", - "tools.squash.showSummary", + "tools.prune", + "tools.prune.enabled", + "tools.distill", + "tools.distill.enabled", + "tools.distill.showDistillation", + "tools.compress", + "tools.compress.enabled", + "tools.compress.showSummary", "strategies", // strategies.deduplication "strategies.deduplication", @@ -277,50 +277,53 @@ function validateConfigTypes(config: Record): ValidationError[] { }) } } - if (tools.discard) { - if (tools.discard.enabled !== undefined && typeof tools.discard.enabled !== "boolean") { + if (tools.prune) { + if (tools.prune.enabled !== undefined && typeof tools.prune.enabled !== "boolean") { errors.push({ - key: "tools.discard.enabled", + key: "tools.prune.enabled", expected: "boolean", - actual: typeof tools.discard.enabled, + actual: typeof tools.prune.enabled, }) } } - if (tools.extract) { - if (tools.extract.enabled !== undefined && typeof tools.extract.enabled !== "boolean") { + if (tools.distill) { + if (tools.distill.enabled !== undefined && typeof tools.distill.enabled !== "boolean") { errors.push({ - key: "tools.extract.enabled", + key: "tools.distill.enabled", expected: "boolean", - actual: typeof tools.extract.enabled, + actual: typeof tools.distill.enabled, }) } if ( - tools.extract.showDistillation !== undefined && - typeof tools.extract.showDistillation !== "boolean" + tools.distill.showDistillation !== undefined && + typeof tools.distill.showDistillation !== "boolean" ) { errors.push({ - key: "tools.extract.showDistillation", + key: "tools.distill.showDistillation", expected: "boolean", - actual: typeof tools.extract.showDistillation, + actual: typeof tools.distill.showDistillation, }) } } - if (tools.squash) { - if (tools.squash.enabled !== undefined && typeof tools.squash.enabled !== "boolean") { + if (tools.compress) { + if ( + tools.compress.enabled !== undefined && + typeof tools.compress.enabled !== "boolean" + ) { errors.push({ - key: "tools.squash.enabled", + key: "tools.compress.enabled", expected: "boolean", - actual: typeof tools.squash.enabled, + actual: typeof tools.compress.enabled, }) } if ( - tools.squash.showSummary !== undefined && - typeof tools.squash.showSummary !== "boolean" + tools.compress.showSummary !== undefined && + typeof tools.compress.showSummary !== "boolean" ) { errors.push({ - key: "tools.squash.showSummary", + key: "tools.compress.showSummary", expected: "boolean", - actual: typeof tools.squash.showSummary, + actual: typeof tools.compress.showSummary, }) } } @@ -468,14 +471,14 @@ const defaultConfig: PluginConfig = { nudgeFrequency: 10, protectedTools: [...DEFAULT_PROTECTED_TOOLS], }, - discard: { + prune: { enabled: true, }, - extract: { + distill: { enabled: true, showDistillation: false, }, - squash: { + compress: { enabled: true, showSummary: true, }, @@ -644,16 +647,16 @@ function mergeTools( ]), ], }, - discard: { - enabled: override.discard?.enabled ?? base.discard.enabled, + prune: { + enabled: override.prune?.enabled ?? base.prune.enabled, }, - extract: { - enabled: override.extract?.enabled ?? base.extract.enabled, - showDistillation: override.extract?.showDistillation ?? base.extract.showDistillation, + distill: { + enabled: override.distill?.enabled ?? base.distill.enabled, + showDistillation: override.distill?.showDistillation ?? base.distill.showDistillation, }, - squash: { - enabled: override.squash?.enabled ?? base.squash.enabled, - showSummary: override.squash?.showSummary ?? base.squash.showSummary, + compress: { + enabled: override.compress?.enabled ?? base.compress.enabled, + showSummary: override.compress?.showSummary ?? base.compress.showSummary, }, } } @@ -684,9 +687,9 @@ function deepCloneConfig(config: PluginConfig): PluginConfig { ...config.tools.settings, protectedTools: [...config.tools.settings.protectedTools], }, - discard: { ...config.tools.discard }, - extract: { ...config.tools.extract }, - squash: { ...config.tools.squash }, + prune: { ...config.tools.prune }, + distill: { ...config.tools.distill }, + compress: { ...config.tools.compress }, }, strategies: { deduplication: { diff --git a/lib/hooks.ts b/lib/hooks.ts index 126fec34..c10b6f00 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -34,12 +34,12 @@ export function createSystemPromptHandler( } const flags = { - discard: config.tools.discard.enabled, - extract: config.tools.extract.enabled, - squash: config.tools.squash.enabled, + prune: config.tools.prune.enabled, + distill: config.tools.distill.enabled, + compress: config.tools.compress.enabled, } - if (!flags.discard && !flags.extract && !flags.squash) { + if (!flags.prune && !flags.distill && !flags.compress) { return } diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index cc7ec771..5ebe6552 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -20,21 +20,21 @@ The following tools have been invoked and are available for pruning. This list d ${content} ` -export const wrapSquashContext = (messageCount: number): string => ` -Squash available. Conversation: ${messageCount} messages. -Squash collapses completed task sequences or exploration phases into summaries. +export const wrapCompressContext = (messageCount: number): string => ` +Compress available. Conversation: ${messageCount} messages. +Compress collapses completed task sequences or exploration phases into summaries. Uses text boundaries [startString, endString, topic, summary]. -` +` export const wrapCooldownMessage = (flags: { - discard: boolean - extract: boolean - squash: boolean + prune: boolean + distill: boolean + compress: boolean }): string => { const enabledTools: string[] = [] - if (flags.discard) enabledTools.push("discard") - if (flags.extract) enabledTools.push("extract") - if (flags.squash) enabledTools.push("squash") + if (flags.prune) enabledTools.push("prune") + if (flags.distill) enabledTools.push("distill") + if (flags.compress) enabledTools.push("compress") let toolName: string if (enabledTools.length === 0) { @@ -53,12 +53,12 @@ Context management was just performed. Do NOT use the ${toolName} again. A fresh const getNudgeString = (config: PluginConfig): string => { const flags = { - discard: config.tools.discard.enabled, - extract: config.tools.extract.enabled, - squash: config.tools.squash.enabled, + prune: config.tools.prune.enabled, + distill: config.tools.distill.enabled, + compress: config.tools.compress.enabled, } - if (!flags.discard && !flags.extract && !flags.squash) { + if (!flags.prune && !flags.distill && !flags.compress) { return "" } @@ -67,15 +67,15 @@ const getNudgeString = (config: PluginConfig): string => { const getCooldownMessage = (config: PluginConfig): string => { return wrapCooldownMessage({ - discard: config.tools.discard.enabled, - extract: config.tools.extract.enabled, - squash: config.tools.squash.enabled, + prune: config.tools.prune.enabled, + distill: config.tools.distill.enabled, + compress: config.tools.compress.enabled, }) } -const buildSquashContext = (state: SessionState, messages: WithParts[]): string => { +const buildCompressContext = (state: SessionState, messages: WithParts[]): string => { const messageCount = messages.filter((msg) => !isMessageCompacted(state, msg)).length - return wrapSquashContext(messageCount) + return wrapCompressContext(messageCount) } const buildPrunableToolsList = ( @@ -133,23 +133,23 @@ export const insertPruneToolContext = ( logger: Logger, messages: WithParts[], ): void => { - const discardEnabled = config.tools.discard.enabled - const extractEnabled = config.tools.extract.enabled - const squashEnabled = config.tools.squash.enabled + const pruneEnabled = config.tools.prune.enabled + const distillEnabled = config.tools.distill.enabled + const compressEnabled = config.tools.compress.enabled - if (!discardEnabled && !extractEnabled && !squashEnabled) { + if (!pruneEnabled && !distillEnabled && !compressEnabled) { return } - const discardOrExtractEnabled = discardEnabled || extractEnabled + const pruneOrDistillEnabled = pruneEnabled || distillEnabled const contentParts: string[] = [] if (state.lastToolPrune) { logger.debug("Last tool was prune - injecting cooldown message") contentParts.push(getCooldownMessage(config)) } else { - // Inject only when discard or extract is enabled - if (discardOrExtractEnabled) { + // Inject only when prune or distill is enabled + if (pruneOrDistillEnabled) { const prunableToolsList = buildPrunableToolsList(state, config, logger, messages) if (prunableToolsList) { // logger.debug("prunable-tools: \n" + prunableToolsList) @@ -157,11 +157,11 @@ export const insertPruneToolContext = ( } } - // Inject always when squash is enabled (every turn) - if (squashEnabled) { - const squashContext = buildSquashContext(state, messages) - // logger.debug("squash-context: \n" + squashContext) - contentParts.push(squashContext) + // Inject always when compress is enabled (every turn) + if (compressEnabled) { + const compressContext = buildCompressContext(state, messages) + // logger.debug("compress-context: \n" + compressContext) + contentParts.push(compressContext) } // Add nudge if threshold reached diff --git a/lib/messages/prune.ts b/lib/messages/prune.ts index 65e97dd0..8d616270 100644 --- a/lib/messages/prune.ts +++ b/lib/messages/prune.ts @@ -2,7 +2,7 @@ import type { SessionState, WithParts } from "../state" import type { Logger } from "../logger" import type { PluginConfig } from "../config" import { isMessageCompacted, getLastUserMessage } from "../shared-utils" -import { createSyntheticUserMessage, SQUASH_SUMMARY_PREFIX } from "./utils" +import { createSyntheticUserMessage, COMPRESS_SUMMARY_PREFIX } from "./utils" import type { UserMessage } from "@opencode-ai/sdk/v2" const PRUNED_TOOL_OUTPUT_REPLACEMENT = @@ -16,7 +16,7 @@ export const prune = ( config: PluginConfig, messages: WithParts[], ): void => { - filterSquashedRanges(state, logger, messages) + filterCompressedRanges(state, logger, messages) pruneToolOutputs(state, logger, messages) pruneToolInputs(state, logger, messages) pruneToolErrors(state, logger, messages) @@ -107,7 +107,11 @@ const pruneToolErrors = (state: SessionState, logger: Logger, messages: WithPart } } -const filterSquashedRanges = (state: SessionState, logger: Logger, messages: WithParts[]): void => { +const filterCompressedRanges = ( + state: SessionState, + logger: Logger, + messages: WithParts[], +): void => { if (!state.prune.messageIds?.length) { return } @@ -118,7 +122,7 @@ const filterSquashedRanges = (state: SessionState, logger: Logger, messages: Wit const msgId = msg.info.id // Check if there's a summary to inject at this anchor point - const summary = state.squashSummaries?.find((s) => s.anchorMessageId === msgId) + const summary = state.compressSummaries?.find((s) => s.anchorMessageId === msgId) if (summary) { // Find user message for variant and as base for synthetic message const msgIndex = messages.indexOf(msg) @@ -126,17 +130,17 @@ const filterSquashedRanges = (state: SessionState, logger: Logger, messages: Wit if (userMessage) { const userInfo = userMessage.info as UserMessage - const summaryContent = SQUASH_SUMMARY_PREFIX + summary.summary + const summaryContent = COMPRESS_SUMMARY_PREFIX + summary.summary result.push( createSyntheticUserMessage(userMessage, summaryContent, userInfo.variant), ) - logger.info("Injected squash summary", { + logger.info("Injected compress summary", { anchorMessageId: msgId, summaryLength: summary.summary.length, }) } else { - logger.warn("No user message found for squash summary", { + logger.warn("No user message found for compress summary", { anchorMessageId: msgId, }) } diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index 9d30b03f..a0035727 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -4,7 +4,7 @@ import { isMessageCompacted } from "../shared-utils" import type { SessionState, WithParts } from "../state" import type { UserMessage } from "@opencode-ai/sdk/v2" -export const SQUASH_SUMMARY_PREFIX = "[Squashed conversation block]\n\n" +export const COMPRESS_SUMMARY_PREFIX = "[Compressed conversation block]\n\n" const generateUniqueId = (prefix: string): string => `${prefix}_${ulid()}` diff --git a/lib/prompts/squash-tool-spec.ts b/lib/prompts/compress-tool-spec.ts similarity index 71% rename from lib/prompts/squash-tool-spec.ts rename to lib/prompts/compress-tool-spec.ts index ab3644b7..0a08b08f 100644 --- a/lib/prompts/squash-tool-spec.ts +++ b/lib/prompts/compress-tool-spec.ts @@ -1,8 +1,8 @@ -export const SQUASH_TOOL_SPEC = `Collapses a contiguous range of conversation into a single summary. +export const COMPRESS_TOOL_SPEC = `Collapses a contiguous range of conversation into a single summary. ## When to Use This Tool -Use \`squash\` when you want to condense an entire sequence of work into a brief summary: +Use \`compress\` when you want to condense an entire sequence of work into a brief summary: - **Phase Completion:** You completed a phase (research, tool calls, implementation) and want to collapse the entire sequence into a summary. - **Exploration Done:** You explored multiple files or ran multiple commands and only need a summary of what you learned. @@ -12,19 +12,19 @@ Use \`squash\` when you want to condense an entire sequence of work into a brief ## When NOT to Use This Tool - **If you need specific details:** If you'll need exact code, file contents, or error messages from the range, keep them. -- **For individual tool outputs:** Use \`discard\` or \`extract\` for single tool outputs. Squash targets conversation ranges. +- **For individual tool outputs:** Use \`prune\` or \`distill\` for single tool outputs. Compress targets conversation ranges. - **If it's recent content:** You may still need recent work for the current phase. ## How It Works -1. \`startString\` — A unique text string that marks the start of the range to squash -2. \`endString\` — A unique text string that marks the end of the range to squash -3. \`topic\` — A short label (3-5 words) describing the squashed content +1. \`startString\` — A unique text string that marks the start of the range to compress +2. \`endString\` — A unique text string that marks the end of the range to compress +3. \`topic\` — A short label (3-5 words) describing the compressed content 4. \`summary\` — The replacement text that will be inserted Everything between startString and endString (inclusive) is removed and replaced with your summary. -**Important:** The squash will FAIL if \`startString\` or \`endString\` is not found in the conversation. The squash will also FAIL if either string is found multiple times. Provide a larger string with more surrounding context to uniquely identify the intended match. +**Important:** The compress will FAIL if \`startString\` or \`endString\` is not found in the conversation. The compress will also FAIL if either string is found multiple times. Provide a larger string with more surrounding context to uniquely identify the intended match. ## Best Practices - **Choose unique strings:** Pick text that appears only once in the conversation. @@ -38,10 +38,10 @@ Everything between startString and endString (inclusive) is removed and replaced ## Example - + Conversation: [Asked about auth] -> [Read 5 files] -> [Analyzed patterns] -> [Found "JWT tokens with 24h expiry"] -[Uses squash with: +[Uses compress with: input: [ "Asked about authentication", "JWT tokens with 24h expiry", @@ -49,9 +49,9 @@ Conversation: [Asked about auth] -> [Read 5 files] -> [Analyzed patterns] -> [Fo "Auth: JWT 24h expiry, bcrypt passwords, refresh rotation. Files: auth.ts, tokens.ts, middleware/auth.ts" ] ] - + Assistant: [Just finished reading auth.ts] -I've read the auth file and now need to make edits based on it. I'm keeping this in context rather than squashing. +I've read the auth file and now need to make edits based on it. I'm keeping this in context rather than compressing. ` diff --git a/lib/prompts/discard-tool-spec.ts b/lib/prompts/discard-tool-spec.ts deleted file mode 100644 index 1c1eea74..00000000 --- a/lib/prompts/discard-tool-spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -export const DISCARD_TOOL_SPEC = `Discards tool outputs from context to manage conversation size and reduce noise. - -## IMPORTANT: The Prunable List -A \`\` list is provided to you showing available tool outputs you can discard when there are tools available for pruning. Each line has the format \`ID: tool, parameter\` (e.g., \`20: read, /path/to/file.ts\`). You MUST only use numeric IDs that appear in this list to select which tools to discard. - -## When to Use This Tool - -Use \`discard\` for removing individual tool outputs that are no longer needed: - -- **Noise:** Irrelevant, unhelpful, or superseded outputs that provide no value. -- **Wrong Files:** You read or accessed something that turned out to be irrelevant. -- **Outdated Info:** Outputs that have been superseded by newer information. - -## When NOT to Use This Tool - -- **If the output contains useful information:** Keep it in context rather than discarding. -- **If you'll need the output later:** Don't discard files you plan to edit or context you'll need for implementation. - -## Best Practices -- **Strategic Batching:** Don't discard single small tool outputs (like short bash commands) unless they are pure noise. Wait until you have several items to perform high-impact discards. -- **Think ahead:** Before discarding, ask: "Will I need this output for upcoming work?" If yes, keep it. - -## Format - -- \`ids\`: Array of numeric IDs as strings from the \`\` list - -## Example - - -Assistant: [Reads 'wrong_file.ts'] -This file isn't relevant to the auth system. I'll remove it to clear the context. -[Uses discard with ids: ["5"]] - - - -Assistant: [Reads config.ts, then reads updated config.ts after changes] -The first read is now outdated. I'll discard it and keep the updated version. -[Uses discard with ids: ["20"]] -` diff --git a/lib/prompts/extract-tool-spec.ts b/lib/prompts/distill-tool-spec.ts similarity index 75% rename from lib/prompts/extract-tool-spec.ts rename to lib/prompts/distill-tool-spec.ts index f680ea9e..9fccc048 100644 --- a/lib/prompts/extract-tool-spec.ts +++ b/lib/prompts/distill-tool-spec.ts @@ -1,11 +1,11 @@ -export const EXTRACT_TOOL_SPEC = `Extracts key findings from tool outputs into distilled knowledge, then removes the raw outputs from context. +export const DISTILL_TOOL_SPEC = `Distills key findings from tool outputs into preserved knowledge, then removes the raw outputs from context. ## IMPORTANT: The Prunable List -A \`\` list is provided to you showing available tool outputs you can extract from when there are tools available for pruning. Each line has the format \`ID: tool, parameter\` (e.g., \`20: read, /path/to/file.ts\`). You MUST only use numeric IDs that appear in this list to select which tools to extract. +A \`\` list is provided to you showing available tool outputs you can distill from when there are tools available for pruning. Each line has the format \`ID: tool, parameter\` (e.g., \`20: read, /path/to/file.ts\`). You MUST only use numeric IDs that appear in this list to select which tools to distill. ## When to Use This Tool -Use \`extract\` when you have individual tool outputs with valuable information you want to **preserve in distilled form** before removing the raw content: +Use \`distill\` when you have individual tool outputs with valuable information you want to **preserve in distilled form** before removing the raw content: - **Large Outputs:** The raw output is too large but contains valuable technical details worth keeping. - **Knowledge Preservation:** You have context that contains valuable information (signatures, logic, constraints) but also a lot of unnecessary detail. @@ -17,8 +17,8 @@ Use \`extract\` when you have individual tool outputs with valuable information ## Best Practices -- **Strategic Batching:** Wait until you have several items or a few large outputs to extract, rather than doing tiny, frequent extractions. Aim for high-impact extractions that significantly reduce context size. -- **Think ahead:** Before extracting, ask: "Will I need the raw output for upcoming work?" If you researched a file you'll later edit, do NOT extract it. +- **Strategic Batching:** Wait until you have several items or a few large outputs to distill, rather than doing tiny, frequent distillations. Aim for high-impact distillations that significantly reduce context size. +- **Think ahead:** Before distilling, ask: "Will I need the raw output for upcoming work?" If you researched a file you'll later edit, do NOT distill it. ## Format @@ -29,19 +29,19 @@ Each distillation string should capture the essential information you need to pr ## Example - + Assistant: [Reads auth service and user types] -I'll preserve the key details before extracting. -[Uses extract with: +I'll preserve the key details before distilling. +[Uses distill with: ids: ["10", "11"], distillation: [ "auth.ts: validateToken(token: string) -> User|null checks cache first (5min TTL) then OIDC. hashPassword uses bcrypt 12 rounds. Tokens must be 128+ chars.", "user.ts: interface User { id: string; email: string; permissions: ('read'|'write'|'admin')[]; status: 'active'|'suspended' }" ] ] - + Assistant: [Reads 'auth.ts' to understand the login flow] -I've understood the auth flow. I'll need to modify this file to add the new validation, so I'm keeping this read in context rather than extracting. +I've understood the auth flow. I'll need to modify this file to add the new validation, so I'm keeping this read in context rather than distilling. ` diff --git a/lib/prompts/index.ts b/lib/prompts/index.ts index a9d70b10..3cfd4295 100644 --- a/lib/prompts/index.ts +++ b/lib/prompts/index.ts @@ -3,9 +3,9 @@ import { dirname, join } from "node:path" import { fileURLToPath } from "node:url" // Tool specs -import { DISCARD_TOOL_SPEC } from "./discard-tool-spec" -import { EXTRACT_TOOL_SPEC } from "./extract-tool-spec" -import { SQUASH_TOOL_SPEC } from "./squash-tool-spec" +import { PRUNE_TOOL_SPEC } from "./prune-tool-spec" +import { DISTILL_TOOL_SPEC } from "./distill-tool-spec" +import { COMPRESS_TOOL_SPEC } from "./compress-tool-spec" const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -14,13 +14,13 @@ const SYSTEM_PROMPT = readFileSync(join(__dirname, "system.md"), "utf-8") const NUDGE = readFileSync(join(__dirname, "nudge.md"), "utf-8") export interface ToolFlags { - discard: boolean - extract: boolean - squash: boolean + prune: boolean + distill: boolean + compress: boolean } function processConditionals(template: string, flags: ToolFlags): string { - const tools = ["discard", "extract", "squash"] as const + const tools = ["prune", "distill", "compress"] as const let result = template // Strip comments: // ... // result = result.replace(/\/\/.*?\/\//g, "") @@ -42,9 +42,9 @@ export function renderNudge(flags: ToolFlags): string { } const PROMPTS: Record = { - "discard-tool-spec": DISCARD_TOOL_SPEC, - "extract-tool-spec": EXTRACT_TOOL_SPEC, - "squash-tool-spec": SQUASH_TOOL_SPEC, + "prune-tool-spec": PRUNE_TOOL_SPEC, + "distill-tool-spec": DISTILL_TOOL_SPEC, + "compress-tool-spec": COMPRESS_TOOL_SPEC, } export function loadPrompt(name: string, vars?: Record): string { diff --git a/lib/prompts/nudge.md b/lib/prompts/nudge.md index 528b858f..078f166e 100644 --- a/lib/prompts/nudge.md +++ b/lib/prompts/nudge.md @@ -6,7 +6,7 @@ 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 `extract` tool. Produce a high-fidelity distillation to preserve insights - be thorough -NOISE REMOVAL: If you read files or ran commands that yielded no value, use the `discard` tool to remove them. If newer tools supersedes older ones, discard the old -PHASE COMPLETION: If a phase is complete, use the `squash` tool to condense the entire sequence into a detailed summary +KNOWLEDGE PRESERVATION: If holding valuable raw data you POTENTIALLY will need in your task, use the `distill` tool. Produce a high-fidelity distillation to preserve insights - be thorough +NOISE REMOVAL: If you read files or ran commands that yielded no value, use the `prune` tool to remove them. If newer tools supersedes older ones, prune the old +PHASE COMPLETION: If a phase is complete, use the `compress` tool to condense the entire sequence into a detailed summary diff --git a/lib/prompts/prune-tool-spec.ts b/lib/prompts/prune-tool-spec.ts new file mode 100644 index 00000000..c2ea3cbb --- /dev/null +++ b/lib/prompts/prune-tool-spec.ts @@ -0,0 +1,39 @@ +export const PRUNE_TOOL_SPEC = `Prunes tool outputs from context to manage conversation size and reduce noise. + +## IMPORTANT: The Prunable List +A \`\` list is provided to you showing available tool outputs you can prune when there are tools available for pruning. Each line has the format \`ID: tool, parameter\` (e.g., \`20: read, /path/to/file.ts\`). You MUST only use numeric IDs that appear in this list to select which tools to prune. + +## When to Use This Tool + +Use \`prune\` for removing individual tool outputs that are no longer needed: + +- **Noise:** Irrelevant, unhelpful, or superseded outputs that provide no value. +- **Wrong Files:** You read or accessed something that turned out to be irrelevant. +- **Outdated Info:** Outputs that have been superseded by newer information. + +## When NOT to Use This Tool + +- **If the output contains useful information:** Keep it in context rather than pruning. +- **If you'll need the output later:** Don't prune files you plan to edit or context you'll need for implementation. + +## Best Practices +- **Strategic Batching:** Don't prune single small tool outputs (like short bash commands) unless they are pure noise. Wait until you have several items to perform high-impact prunes. +- **Think ahead:** Before pruning, ask: "Will I need this output for upcoming work?" If yes, keep it. + +## Format + +- \`ids\`: Array of numeric IDs as strings from the \`\` list + +## Example + + +Assistant: [Reads 'wrong_file.ts'] +This file isn't relevant to the auth system. I'll remove it to clear the context. +[Uses prune with ids: ["5"]] + + + +Assistant: [Reads config.ts, then reads updated config.ts after changes] +The first read is now outdated. I'll prune it and keep the updated version. +[Uses prune with ids: ["20"]] +` diff --git a/lib/prompts/system.md b/lib/prompts/system.md index f2d0e803..1a9233b2 100644 --- a/lib/prompts/system.md +++ b/lib/prompts/system.md @@ -7,9 +7,9 @@ You are operating in a context-constrained environment and must proactively mana IMPORTANT: The `context_info` tool is only available to the environment - you do not have access to it and must not attempt to call it. AVAILABLE TOOLS -`discard`: Remove individual tool outputs that are noise, irrelevant, or superseded. No preservation of content. -`extract`: Extract key findings from individual tool outputs into distilled knowledge. Use when you need to preserve valuable technical details. -`squash`: Collapse a contiguous range of conversation (completed phases) into a single summary. +`prune`: Remove individual tool outputs that are noise, irrelevant, or superseded. No preservation of content. +`distill`: Distill key findings from individual tool outputs into preserved knowledge. Use when you need to preserve valuable technical details. +`compress`: Collapse a contiguous range of conversation (completed phases) into a single summary. PRUNE METHODICALLY - BATCH YOUR ACTIONS Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by pruning. Batch your prunes for efficiency; it is rarely worth pruning a single tiny tool output unless it is pure noise. Evaluate what SHOULD be pruned before jumping the gun. @@ -21,41 +21,41 @@ You MUST NOT prune when: Pruning that forces you to re-call the same tool later is a net loss. Only prune when you're confident the information won't be needed again. - -WHEN TO DISCARD + +WHEN TO PRUNE - **Noise Removal:** Outputs that are irrelevant, unhelpful, or superseded by newer info. - **Wrong Files:** You read or accessed something that turned out to be irrelevant to the current work. - **Outdated Info:** Outputs that have been superseded by newer information. -You WILL evaluate discarding when ANY of these are true: +You WILL evaluate pruning when ANY of these are true: You accessed something that turned out to be irrelevant Information has been superseded by newer outputs You are about to start a new phase of work - + - -WHEN TO EXTRACT + +WHEN TO DISTILL **Large Outputs:** The raw output is too large but contains valuable technical details worth keeping. **Knowledge Preservation:** Valuable context you want to preserve but need to reduce size. Your distillation must be comprehensive, capturing technical details (signatures, logic, constraints) such that the raw output is no longer needed. THINK: high signal, complete technical substitute. -You WILL evaluate extracting when ANY of these are true: +You WILL evaluate distilling when ANY of these are true: - You have large tool outputs with valuable technical details - You need to preserve specific information but reduce context size - You are about to start a new phase of work and want to retain key insights - - - WHEN TO SQUASH + + + WHEN TO COMPRESS - **Phase Completion:** When a phase is complete, condense the entire sequence (research, tool calls, implementation) into a summary. - **Exploration Done:** When you've explored multiple files or ran multiple commands and only need a summary of findings. -You WILL evaluate squashing when ANY of these are true: +You WILL evaluate compressing when ANY of these are true: - Phase is complete - You are about to start a new phase of work - Significant conversation has accumulated that can be summarized - + NOTES When in doubt, KEEP IT. diff --git a/lib/state/persistence.ts b/lib/state/persistence.ts index 91111ef7..11e06a93 100644 --- a/lib/state/persistence.ts +++ b/lib/state/persistence.ts @@ -8,13 +8,13 @@ import * as fs from "fs/promises" import { existsSync } from "fs" import { homedir } from "os" import { join } from "path" -import type { SessionState, SessionStats, Prune, SquashSummary } from "./types" +import type { SessionState, SessionStats, Prune, CompressSummary } from "./types" import type { Logger } from "../logger" export interface PersistedSessionState { sessionName?: string prune: Prune - squashSummaries: SquashSummary[] + compressSummaries: CompressSummary[] stats: SessionStats lastUpdated: string } @@ -46,7 +46,7 @@ export async function saveSessionState( const state: PersistedSessionState = { sessionName: sessionName, prune: sessionState.prune, - squashSummaries: sessionState.squashSummaries, + compressSummaries: sessionState.compressSummaries, stats: sessionState.stats, lastUpdated: new Date().toISOString(), } diff --git a/lib/state/state.ts b/lib/state/state.ts index 98a99693..c8e3866d 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -51,7 +51,7 @@ export function createSessionState(): SessionState { toolIds: [], messageIds: [], }, - squashSummaries: [], + compressSummaries: [], stats: { pruneTokenCounter: 0, totalPruneTokens: 0, @@ -72,7 +72,7 @@ export function resetSessionState(state: SessionState): void { toolIds: [], messageIds: [], } - state.squashSummaries = [] + state.compressSummaries = [] state.stats = { pruneTokenCounter: 0, totalPruneTokens: 0, @@ -118,7 +118,7 @@ export async function ensureSessionInitialized( toolIds: persisted.prune.toolIds || [], messageIds: persisted.prune.messageIds || [], } - state.squashSummaries = persisted.squashSummaries || [] + state.compressSummaries = persisted.compressSummaries || [] 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 80837519..cf6ccb98 100644 --- a/lib/state/tool-cache.ts +++ b/lib/state/tool-cache.ts @@ -44,14 +44,14 @@ export async function syncToolCache( state.currentTurn - turnCounter < turnProtectionTurns state.lastToolPrune = - (part.tool === "discard" || - part.tool === "extract" || - part.tool === "squash") && + (part.tool === "prune" || + part.tool === "distill" || + part.tool === "compress") && part.state.status === "completed" const allProtectedTools = config.tools.settings.protectedTools - if (part.tool === "discard" || part.tool === "extract" || part.tool === "squash") { + if (part.tool === "prune" || part.tool === "distill" || part.tool === "compress") { state.nudgeCounter = 0 } else if (!allProtectedTools.includes(part.tool) && !isProtectedByTurn) { state.nudgeCounter++ diff --git a/lib/state/types.ts b/lib/state/types.ts index 330f8c89..d84f0ee5 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -20,7 +20,7 @@ export interface SessionStats { totalPruneTokens: number } -export interface SquashSummary { +export interface CompressSummary { anchorMessageId: string summary: string } @@ -34,7 +34,7 @@ export interface SessionState { sessionId: string | null isSubAgent: boolean prune: Prune - squashSummaries: SquashSummary[] + compressSummaries: CompressSummary[] stats: SessionStats toolParameters: Map nudgeCounter: number diff --git a/lib/state/utils.ts b/lib/state/utils.ts index be8a08fe..da96afb1 100644 --- a/lib/state/utils.ts +++ b/lib/state/utils.ts @@ -40,7 +40,7 @@ export function resetOnCompaction(state: SessionState): void { state.toolParameters.clear() state.prune.toolIds = [] state.prune.messageIds = [] - state.squashSummaries = [] + state.compressSummaries = [] state.nudgeCounter = 0 state.lastToolPrune = false } diff --git a/lib/strategies/index.ts b/lib/strategies/index.ts index a995254e..e0680e6b 100644 --- a/lib/strategies/index.ts +++ b/lib/strategies/index.ts @@ -1,4 +1,4 @@ export { deduplicate } from "./deduplication" -export { createDiscardTool, createExtractTool, createSquashTool } from "../tools" +export { createPruneTool, createDistillTool, createCompressTool } from "../tools" export { supersedeWrites } from "./supersede-writes" export { purgeErrors } from "./purge-errors" diff --git a/lib/tools/squash.ts b/lib/tools/compress.ts similarity index 76% rename from lib/tools/squash.ts rename to lib/tools/compress.ts index 9ab9425a..f5c30334 100644 --- a/lib/tools/squash.ts +++ b/lib/tools/compress.ts @@ -1,5 +1,5 @@ import { tool } from "@opencode-ai/plugin" -import type { WithParts, SquashSummary } from "../state" +import type { WithParts, CompressSummary } from "../state" import type { PruneToolContext } from "./types" import { ensureSessionInitialized } from "../state" import { saveSessionState } from "../state/persistence" @@ -11,19 +11,19 @@ import { collectToolIdsInRange, collectMessageIdsInRange, } from "./utils" -import { sendSquashNotification } from "../ui/notification" +import { sendCompressNotification } from "../ui/notification" -const SQUASH_TOOL_DESCRIPTION = loadPrompt("squash-tool-spec") +const COMPRESS_TOOL_DESCRIPTION = loadPrompt("compress-tool-spec") -export function createSquashTool(ctx: PruneToolContext): ReturnType { +export function createCompressTool(ctx: PruneToolContext): ReturnType { return tool({ - description: SQUASH_TOOL_DESCRIPTION, + description: COMPRESS_TOOL_DESCRIPTION, args: { input: tool.schema .array(tool.schema.string()) .length(4) .describe( - "[startString, endString, topic, summary] - 4 required strings: (1) startString: unique text from conversation marking range start, (2) endString: unique text marking range end, (3) topic: short 3-5 word label for UI, (4) summary: comprehensive text replacing all squashed content", + "[startString, endString, topic, summary] - 4 required strings: (1) startString: unique text from conversation marking range start, (2) endString: unique text marking range end, (3) topic: short 3-5 word label for UI, (4) summary: comprehensive text replacing all compressed content", ), }, async execute(args, toolCtx) { @@ -32,7 +32,7 @@ export function createSquashTool(ctx: PruneToolContext): ReturnType const [startString, endString, topic, summary] = args.input - logger.info("Squash tool invoked") + logger.info("Compress tool invoked") // logger.info( // JSON.stringify({ // startString: startString?.substring(0, 50) + "...", @@ -53,14 +53,14 @@ export function createSquashTool(ctx: PruneToolContext): ReturnType messages, startString, logger, - state.squashSummaries, + state.compressSummaries, "startString", ) const endResult = findStringInMessages( messages, endString, logger, - state.squashSummaries, + state.compressSummaries, "endString", ) @@ -86,37 +86,37 @@ export function createSquashTool(ctx: PruneToolContext): ReturnType state.prune.messageIds.push(...containedMessageIds) // Remove any existing summaries whose anchors are now inside this range - // This prevents duplicate injections when a larger squash subsumes a smaller one - const removedSummaries = state.squashSummaries.filter((s) => + // This prevents duplicate injections when a larger compress subsumes a smaller one + const removedSummaries = state.compressSummaries.filter((s) => containedMessageIds.includes(s.anchorMessageId), ) if (removedSummaries.length > 0) { - // logger.info("Removing subsumed squash summaries", { + // logger.info("Removing subsumed compress summaries", { // count: removedSummaries.length, // anchorIds: removedSummaries.map((s) => s.anchorMessageId), // }) - state.squashSummaries = state.squashSummaries.filter( + state.compressSummaries = state.compressSummaries.filter( (s) => !containedMessageIds.includes(s.anchorMessageId), ) } - const squashSummary: SquashSummary = { + const compressSummary: CompressSummary = { anchorMessageId: startResult.messageId, summary: summary, } - state.squashSummaries.push(squashSummary) + state.compressSummaries.push(compressSummary) const contentsToTokenize = collectContentInRange( messages, startResult.messageIndex, endResult.messageIndex, ) - const estimatedSquashedTokens = estimateTokensBatch(contentsToTokenize) + const estimatedCompressedTokens = estimateTokensBatch(contentsToTokenize) - state.stats.pruneTokenCounter += estimatedSquashedTokens + state.stats.pruneTokenCounter += estimatedCompressedTokens const currentParams = getCurrentParams(state, messages, logger) - await sendSquashNotification( + await sendCompressNotification( client, logger, ctx.config, @@ -136,20 +136,20 @@ export function createSquashTool(ctx: PruneToolContext): ReturnType state.stats.pruneTokenCounter = 0 state.nudgeCounter = 0 - // logger.info("Squash range created", { + // logger.info("Compress range created", { // startMessageId: startResult.messageId, // endMessageId: endResult.messageId, // toolIdsRemoved: containedToolIds.length, // messagesInRange: containedMessageIds.length, - // estimatedTokens: estimatedSquashedTokens, + // estimatedTokens: estimatedCompressedTokens, // }) saveSessionState(state, logger).catch((err) => logger.error("Failed to persist state", { error: err.message }), ) - const messagesSquashed = endResult.messageIndex - startResult.messageIndex + 1 - return `Squashed ${messagesSquashed} messages (${containedToolIds.length} tool calls) into summary. The content will be replaced with your summary.` + const messagesCompressed = endResult.messageIndex - startResult.messageIndex + 1 + return `Compressed ${messagesCompressed} messages (${containedToolIds.length} tool calls) into summary. The content will be replaced with your summary.` }, }) } diff --git a/lib/tools/extract.ts b/lib/tools/distill.ts similarity index 82% rename from lib/tools/extract.ts rename to lib/tools/distill.ts index 15e5d7c8..ce224e9d 100644 --- a/lib/tools/extract.ts +++ b/lib/tools/distill.ts @@ -4,16 +4,16 @@ import { executePruneOperation } from "./prune-shared" import { PruneReason } from "../ui/notification" import { loadPrompt } from "../prompts" -const EXTRACT_TOOL_DESCRIPTION = loadPrompt("extract-tool-spec") +const DISTILL_TOOL_DESCRIPTION = loadPrompt("distill-tool-spec") -export function createExtractTool(ctx: PruneToolContext): ReturnType { +export function createDistillTool(ctx: PruneToolContext): ReturnType { return tool({ - description: EXTRACT_TOOL_DESCRIPTION, + description: DISTILL_TOOL_DESCRIPTION, args: { ids: tool.schema .array(tool.schema.string()) .min(1) - .describe("Numeric IDs as strings to extract from the list"), + .describe("Numeric IDs as strings to distill from the list"), distillation: tool.schema .array(tool.schema.string()) .min(1) @@ -24,7 +24,7 @@ export function createExtractTool(ctx: PruneToolContext): ReturnType { +export function createPruneTool(ctx: PruneToolContext): ReturnType { return tool({ - description: DISCARD_TOOL_DESCRIPTION, + description: PRUNE_TOOL_DESCRIPTION, args: { ids: tool.schema .array(tool.schema.string()) .min(1) - .describe("Numeric IDs as strings from the list to discard"), + .describe("Numeric IDs as strings from the list to prune"), }, async execute(args, toolCtx) { const numericIds = args.ids const reason = "noise" - return executePruneOperation(ctx, toolCtx, numericIds, reason, "Discard") + return executePruneOperation(ctx, toolCtx, numericIds, reason, "Prune") }, }) } diff --git a/lib/tools/utils.ts b/lib/tools/utils.ts index d5e4e180..8adec13e 100644 --- a/lib/tools/utils.ts +++ b/lib/tools/utils.ts @@ -1,24 +1,24 @@ -import type { WithParts, SquashSummary } from "../state" +import type { WithParts, CompressSummary } from "../state" import type { Logger } from "../logger" /** * Searches messages for a string and returns the message ID where it's found. * Searches in text parts, tool outputs, tool inputs, and other textual content. - * Also searches through existing squash summaries to enable chained squashing. + * Also searches through existing compress summaries to enable chained compression. * Throws an error if the string is not found or found more than once. */ export function findStringInMessages( messages: WithParts[], searchString: string, logger: Logger, - squashSummaries: SquashSummary[] = [], + compressSummaries: CompressSummary[] = [], stringType: "startString" | "endString", ): { messageId: string; messageIndex: number } { const matches: { messageId: string; messageIndex: number }[] = [] - // First, search through existing squash summaries - // This allows referencing text from previous squash operations - for (const summary of squashSummaries) { + // First, search through existing compress summaries + // This allows referencing text from previous compress operations + for (const summary of compressSummaries) { if (summary.summary.includes(searchString)) { const anchorIndex = messages.findIndex((m) => m.info.id === summary.anchorMessageId) if (anchorIndex !== -1) { diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index 07ccf41d..7bb17f7b 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -26,7 +26,7 @@ function buildMinimalMessage( ): string { const extractedTokens = countDistillationTokens(distillation) const extractedSuffix = - extractedTokens > 0 ? ` (extracted ${formatTokenCount(extractedTokens)})` : "" + extractedTokens > 0 ? ` (distilled ${formatTokenCount(extractedTokens)})` : "" const reasonSuffix = reason && extractedTokens === 0 ? ` — ${PRUNE_REASON_LABELS[reason]}` : "" let message = formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter) + @@ -51,7 +51,7 @@ function buildDetailedMessage( const pruneTokenCounterStr = `~${formatTokenCount(state.stats.pruneTokenCounter)}` const extractedTokens = countDistillationTokens(distillation) const extractedSuffix = - extractedTokens > 0 ? `, extracted ${formatTokenCount(extractedTokens)}` : "" + extractedTokens > 0 ? `, distilled ${formatTokenCount(extractedTokens)}` : "" const reasonLabel = reason && extractedTokens === 0 ? ` — ${PRUNE_REASON_LABELS[reason]}` : "" message += `\n\n▣ Pruning (${pruneTokenCounterStr}${extractedSuffix})${reasonLabel}` @@ -85,7 +85,7 @@ export async function sendUnifiedNotification( return false } - const showDistillation = config.tools.extract.showDistillation + const showDistillation = config.tools.distill.showDistillation const message = config.pruneNotification === "minimal" @@ -104,7 +104,7 @@ export async function sendUnifiedNotification( return true } -export async function sendSquashNotification( +export async function sendCompressNotification( client: any, logger: Logger, config: PluginConfig, @@ -137,7 +137,7 @@ export async function sendSquashNotification( endResult.messageIndex, 25, ) - message += `\n\n▣ Squashing (${pruneTokenCounterStr}) ${progressBar}` + message += `\n\n▣ Compressing (${pruneTokenCounterStr}) ${progressBar}` message += `\n→ Topic: ${topic}` message += `\n→ Items: ${messageIds.length} messages` if (toolIds.length > 0) { @@ -145,7 +145,7 @@ export async function sendSquashNotification( } else { message += ` condensed` } - if (config.tools.squash.showSummary) { + if (config.tools.compress.showSummary) { message += `\n→ Summary: ${summary}` } } From 5863a1d0c8f55b85df2dd33c6e28d785cd583303 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Thu, 29 Jan 2026 06:45:45 +0100 Subject: [PATCH 028/113] reorder tool registering --- index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/index.ts b/index.ts index 1537a52b..94291a6a 100644 --- a/index.ts +++ b/index.ts @@ -61,8 +61,8 @@ const plugin: Plugin = (async (ctx) => { ctx.directory, ), tool: { - ...(config.tools.prune.enabled && { - prune: createPruneTool({ + ...(config.tools.distill.enabled && { + distill: createDistillTool({ client: ctx.client, state, logger, @@ -70,8 +70,8 @@ const plugin: Plugin = (async (ctx) => { workingDirectory: ctx.directory, }), }), - ...(config.tools.distill.enabled && { - distill: createDistillTool({ + ...(config.tools.compress.enabled && { + compress: createCompressTool({ client: ctx.client, state, logger, @@ -79,8 +79,8 @@ const plugin: Plugin = (async (ctx) => { workingDirectory: ctx.directory, }), }), - ...(config.tools.compress.enabled && { - compress: createCompressTool({ + ...(config.tools.prune.enabled && { + prune: createPruneTool({ client: ctx.client, state, logger, From efead8f1caac781eb723291bebc8c3428210bab1 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 29 Jan 2026 22:09:49 -0500 Subject: [PATCH 029/113] fix: generate .ts from .md at build time for bundler compatibility Fixes issue where readFileSync with __dirname fails when bundled by Bun (same issue as #222, reintroduced by #327). - Add scripts/generate-prompts.ts prebuild script - Import generated .ts files instead of runtime readFileSync - Remove postbuild .md copy (no longer needed) --- .gitignore | 3 +++ lib/prompts/index.ts | 12 +++------- package.json | 3 ++- scripts/generate-prompts.ts | 44 +++++++++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 scripts/generate-prompts.ts diff --git a/.gitignore b/.gitignore index 177c17b2..5bf7a25f 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ Thumbs.db # OpenCode .opencode/ +# Generated prompt files (from scripts/generate-prompts.ts) +lib/prompts/*.generated.ts + # Tests (local development only) tests/ notes/ diff --git a/lib/prompts/index.ts b/lib/prompts/index.ts index 3cfd4295..44ad6bd8 100644 --- a/lib/prompts/index.ts +++ b/lib/prompts/index.ts @@ -1,17 +1,11 @@ -import { readFileSync } from "node:fs" -import { dirname, join } from "node:path" -import { fileURLToPath } from "node:url" - // Tool specs import { PRUNE_TOOL_SPEC } from "./prune-tool-spec" import { DISTILL_TOOL_SPEC } from "./distill-tool-spec" import { COMPRESS_TOOL_SPEC } from "./compress-tool-spec" -const __dirname = dirname(fileURLToPath(import.meta.url)) - -// Load markdown prompts at module init -const SYSTEM_PROMPT = readFileSync(join(__dirname, "system.md"), "utf-8") -const NUDGE = readFileSync(join(__dirname, "nudge.md"), "utf-8") +// Generated prompts (from .md files via scripts/generate-prompts.ts) +import { SYSTEM as SYSTEM_PROMPT } from "./system.generated" +import { NUDGE } from "./nudge.generated" export interface ToolFlags { prune: boolean diff --git a/package.json b/package.json index 15021281..1587cf9d 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,9 @@ "types": "./dist/index.d.ts", "scripts": { "clean": "rm -rf dist", + "generate:prompts": "tsx scripts/generate-prompts.ts", + "prebuild": "npm run generate:prompts", "build": "npm run clean && tsc", - "postbuild": "rm -rf dist/logs && cp lib/prompts/*.md dist/lib/prompts/", "prepublishOnly": "npm run build", "dev": "opencode plugin dev", "typecheck": "tsc --noEmit", diff --git a/scripts/generate-prompts.ts b/scripts/generate-prompts.ts new file mode 100644 index 00000000..815a54db --- /dev/null +++ b/scripts/generate-prompts.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env tsx +/** + * Prebuild script that generates TypeScript files from Markdown prompts. + * + * This solves the issue where readFileSync with __dirname fails when the + * package is bundled by Bun (see issue #222, PR #272, #327). + * + * The .md files are kept for convenient editing, and this script generates + * .ts files with exported string constants that bundle correctly. + */ + +import { readFileSync, writeFileSync, readdirSync } from "node:fs" +import { dirname, join, basename } from "node:path" +import { fileURLToPath } from "node:url" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const PROMPTS_DIR = join(__dirname, "..", "lib", "prompts") + +// Find all .md files in the prompts directory +const mdFiles = readdirSync(PROMPTS_DIR).filter((f) => f.endsWith(".md")) + +for (const mdFile of mdFiles) { + const mdPath = join(PROMPTS_DIR, mdFile) + const baseName = basename(mdFile, ".md") + const constName = baseName.toUpperCase().replace(/-/g, "_") + const tsPath = join(PROMPTS_DIR, `${baseName}.generated.ts`) + + const content = readFileSync(mdPath, "utf-8") + + // Escape backticks and ${} template expressions for safe embedding in template literal + const escaped = content.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${") + + const tsContent = `// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from ${mdFile} by scripts/generate-prompts.ts +// To modify, edit ${mdFile} and run \`npm run generate:prompts\` + +export const ${constName} = \`${escaped}\` +` + + writeFileSync(tsPath, tsContent) + console.log(`Generated: ${baseName}.generated.ts`) +} + +console.log(`Done! Generated ${mdFiles.length} TypeScript file(s) from Markdown prompts.`) From cdb5862a67bf639b21ccc499420e01c1598186a0 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 29 Jan 2026 22:12:45 -0500 Subject: [PATCH 030/113] refactor: consolidate cli/ into scripts/ --- package.json | 4 ++-- {cli => scripts}/README.md | 0 {cli => scripts}/print.ts | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename {cli => scripts}/README.md (100%) rename {cli => scripts}/print.ts (100%) diff --git a/package.json b/package.json index 1587cf9d..66dbcb42 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,11 @@ "build": "npm run clean && tsc", "prepublishOnly": "npm run build", "dev": "opencode plugin dev", - "typecheck": "tsc --noEmit", + "typecheck": "npm run generate:prompts && tsc --noEmit", "test": "node --import tsx --test tests/*.test.ts", "format": "prettier --write .", "format:check": "prettier --check .", - "dcp": "tsx cli/print.ts" + "dcp": "tsx scripts/print.ts" }, "keywords": [ "opencode", diff --git a/cli/README.md b/scripts/README.md similarity index 100% rename from cli/README.md rename to scripts/README.md diff --git a/cli/print.ts b/scripts/print.ts similarity index 100% rename from cli/print.ts rename to scripts/print.ts From f123eb376e762ec5848ae5d788894bca990e0ffc Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 29 Jan 2026 22:45:43 -0500 Subject: [PATCH 031/113] Fix context summary output to handle cases where only tools or only messages are pruned Previously the output would show "0 tools, 3 messages" if only messages were pruned. Now it conditionally shows both parts properly, omitting the 0-count category. --- lib/commands/context.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/commands/context.ts b/lib/commands/context.ts index 7ecd91c8..2706290d 100644 --- a/lib/commands/context.ts +++ b/lib/commands/context.ts @@ -234,10 +234,12 @@ function formatContextMessage(breakdown: TokenBreakdown): string { if (breakdown.prunedTokens > 0) { const withoutPruning = breakdown.total + breakdown.prunedTokens - const messagePrunePart = - breakdown.prunedMessageCount > 0 ? `, ${breakdown.prunedMessageCount} messages` : "" + const pruned = [] + if (breakdown.prunedCount > 0) pruned.push(`${breakdown.prunedCount} tools`) + if (breakdown.prunedMessageCount > 0) + pruned.push(`${breakdown.prunedMessageCount} messages`) lines.push( - ` Pruned: ${breakdown.prunedCount} tools${messagePrunePart} (~${formatTokenCount(breakdown.prunedTokens)})`, + ` Pruned: ${pruned.join(", ")} (~${formatTokenCount(breakdown.prunedTokens)})`, ) lines.push(` Current context: ~${formatTokenCount(breakdown.total)}`) lines.push(` Without DCP: ~${formatTokenCount(withoutPruning)}`) From 2cd85fc81a124b4f0c64483c71d593ede48e368c Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 29 Jan 2026 23:20:19 -0500 Subject: [PATCH 032/113] revert: show cooldown for errored tools to prevent loop behavior Reverts the change that only showed cooldown on completed tools. Errored tools were getting stuck in loops because they would see prunable context and try to prune again. Now they see the cooldown message instead, which prevents immediate re-pruning. --- lib/state/tool-cache.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/state/tool-cache.ts b/lib/state/tool-cache.ts index cf6ccb98..6b0e595c 100644 --- a/lib/state/tool-cache.ts +++ b/lib/state/tool-cache.ts @@ -44,10 +44,7 @@ export async function syncToolCache( state.currentTurn - turnCounter < turnProtectionTurns state.lastToolPrune = - (part.tool === "prune" || - part.tool === "distill" || - part.tool === "compress") && - part.state.status === "completed" + part.tool === "prune" || part.tool === "distill" || part.tool === "compress" const allProtectedTools = config.tools.settings.protectedTools From 866bdc1029f9825fb53822a4dfc563c26b8feede Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Fri, 30 Jan 2026 00:00:00 -0500 Subject: [PATCH 033/113] spell check --- lib/prompts/system.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/prompts/system.md b/lib/prompts/system.md index 1a9233b2..a0574236 100644 --- a/lib/prompts/system.md +++ b/lib/prompts/system.md @@ -62,7 +62,7 @@ When in doubt, KEEP IT. // **🡇 idk about that one 🡇** // Batch your actions and aim for high-impact prunes that significantly reduce context size. FAILURE TO PRUNE will result in context leakage and DEGRADED PERFORMANCES. -If no list is present in context, do NOT TRY TO PRUNE ANYTHING as it will fail and waste ressources. +If no list is present in context, do NOT TRY TO PRUNE ANYTHING as it will fail and waste resources. There may be tools in session context that do not appear in the list, this is expected, you can ONLY prune what you see in . From d66a6c20303c9575a2c4506031bf2241193643d8 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Fri, 30 Jan 2026 00:09:02 -0500 Subject: [PATCH 034/113] docs: standardize DCP tool order to distill, compress, prune Update all documentation and source files to consistently order the DCP tools as DCP (Distill, Compress, Prune) rather than various inconsistent orderings. Files updated: - README.md: Tools section and protected tools list - dcp.schema.json: Tool configuration properties - lib/prompts/index.ts: ToolFlags interface and tools array - lib/state/tool-cache.ts: Tool name checks - scripts/README.md: Tool flags table --- README.md | 6 +++--- dcp.schema.json | 24 ++++++++++++------------ lib/prompts/index.ts | 4 ++-- lib/state/tool-cache.ts | 4 ++-- scripts/README.md | 2 +- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index afd338c3..c60415fa 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,12 @@ DCP uses multiple tools and strategies to reduce context size: ### Tools -**Prune** — Exposes a `prune` tool that the AI can call to remove completed or noisy tool content from context. - **Distill** — Exposes a `distill` tool that the AI can call to distill valuable context into concise summaries before removing the tool content. **Compress** — Exposes a `compress` tool that the AI can call to collapse a large section of conversation (messages and tools) into a single summary. +**Prune** — Exposes a `prune` tool that the AI can call to remove completed or noisy tool content from context. + ### Strategies **Deduplication** — Identifies repeated tool calls (e.g., reading the same file multiple times) and keeps only the most recent output. Runs automatically on every request with zero LLM cost. @@ -157,7 +157,7 @@ When enabled, turn protection prevents tool outputs from being pruned for a conf ### Protected Tools By default, these tools are always protected from pruning across all strategies: -`task`, `todowrite`, `todoread`, `prune`, `distill`, `compress`, `batch`, `write`, `edit`, `plan_enter`, `plan_exit` +`task`, `todowrite`, `todoread`, `distill`, `compress`, `prune`, `batch`, `write`, `edit`, `plan_enter`, `plan_exit` The `protectedTools` arrays in each section add to this default list. diff --git a/dcp.schema.json b/dcp.schema.json index 9d87cbac..70aa5d4b 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -105,18 +105,6 @@ } } }, - "prune": { - "type": "object", - "description": "Configuration for the prune tool", - "additionalProperties": false, - "properties": { - "enabled": { - "type": "boolean", - "default": true, - "description": "Enable the prune tool" - } - } - }, "distill": { "type": "object", "description": "Configuration for the distill tool", @@ -150,6 +138,18 @@ "description": "Show summary output in the UI" } } + }, + "prune": { + "type": "object", + "description": "Configuration for the prune tool", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable the prune tool" + } + } } } }, diff --git a/lib/prompts/index.ts b/lib/prompts/index.ts index 44ad6bd8..c80115f4 100644 --- a/lib/prompts/index.ts +++ b/lib/prompts/index.ts @@ -8,13 +8,13 @@ import { SYSTEM as SYSTEM_PROMPT } from "./system.generated" import { NUDGE } from "./nudge.generated" export interface ToolFlags { - prune: boolean distill: boolean compress: boolean + prune: boolean } function processConditionals(template: string, flags: ToolFlags): string { - const tools = ["prune", "distill", "compress"] as const + const tools = ["distill", "compress", "prune"] as const let result = template // Strip comments: // ... // result = result.replace(/\/\/.*?\/\//g, "") diff --git a/lib/state/tool-cache.ts b/lib/state/tool-cache.ts index 6b0e595c..b5ad154e 100644 --- a/lib/state/tool-cache.ts +++ b/lib/state/tool-cache.ts @@ -44,11 +44,11 @@ export async function syncToolCache( state.currentTurn - turnCounter < turnProtectionTurns state.lastToolPrune = - part.tool === "prune" || part.tool === "distill" || part.tool === "compress" + part.tool === "distill" || part.tool === "compress" || part.tool === "prune" const allProtectedTools = config.tools.settings.protectedTools - if (part.tool === "prune" || part.tool === "distill" || part.tool === "compress") { + if (part.tool === "distill" || part.tool === "compress" || part.tool === "prune") { state.nudgeCounter = 0 } else if (!allProtectedTools.includes(part.tool) && !isProtectedByTurn) { state.nudgeCounter++ diff --git a/scripts/README.md b/scripts/README.md index abbd3398..a99c256b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -21,9 +21,9 @@ bun run dcp [TYPE] [-p] [-d] [-c] | Flag | Description | | ---------------- | -------------------- | -| `-p, --prune` | Enable prune tool | | `-d, --distill` | Enable distill tool | | `-c, --compress` | Enable compress tool | +| `-p, --prune` | Enable prune tool | If no tool flags specified, all are enabled. From 22f5848cf721b06987b0748d2c4e7659dcbea749 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Fri, 30 Jan 2026 00:27:13 -0500 Subject: [PATCH 035/113] Rename compress tool config from showSummary to showCompression --- README.md | 2 +- dcp.schema.json | 2 +- lib/config.ts | 16 ++++++++-------- lib/ui/notification.ts | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index c60415fa..afb761a5 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ DCP uses its own config file: "compress": { "enabled": true, // Show summary content as an ignored message notification - "showSummary": true, + "showCompression": true, }, }, // Automatic pruning strategies diff --git a/dcp.schema.json b/dcp.schema.json index 70aa5d4b..a9bfed22 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -132,7 +132,7 @@ "default": true, "description": "Enable the compress tool" }, - "showSummary": { + "showCompression": { "type": "boolean", "default": true, "description": "Show summary output in the UI" diff --git a/lib/config.ts b/lib/config.ts index 5f3b4e39..337ddea0 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -20,7 +20,7 @@ export interface DistillTool { export interface CompressTool { enabled: boolean - showSummary: boolean + showCompression: boolean } export interface ToolSettings { @@ -112,7 +112,7 @@ export const VALID_CONFIG_KEYS = new Set([ "tools.distill.showDistillation", "tools.compress", "tools.compress.enabled", - "tools.compress.showSummary", + "tools.compress.showCompression", "strategies", // strategies.deduplication "strategies.deduplication", @@ -317,13 +317,13 @@ function validateConfigTypes(config: Record): ValidationError[] { }) } if ( - tools.compress.showSummary !== undefined && - typeof tools.compress.showSummary !== "boolean" + tools.compress.showCompression !== undefined && + typeof tools.compress.showCompression !== "boolean" ) { errors.push({ - key: "tools.compress.showSummary", + key: "tools.compress.showCompression", expected: "boolean", - actual: typeof tools.compress.showSummary, + actual: typeof tools.compress.showCompression, }) } } @@ -480,7 +480,7 @@ const defaultConfig: PluginConfig = { }, compress: { enabled: true, - showSummary: true, + showCompression: true, }, }, strategies: { @@ -656,7 +656,7 @@ function mergeTools( }, compress: { enabled: override.compress?.enabled ?? base.compress.enabled, - showSummary: override.compress?.showSummary ?? base.compress.showSummary, + showCompression: override.compress?.showCompression ?? base.compress.showCompression, }, } } diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index 7bb17f7b..ec6d399b 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -145,8 +145,8 @@ export async function sendCompressNotification( } else { message += ` condensed` } - if (config.tools.compress.showSummary) { - message += `\n→ Summary: ${summary}` + if (config.tools.compress.showCompression) { + message += `\n→ Compression: ${summary}` } } From 9ad912c35fb2c7d00fdb75f9acd0b092f2bfb7cd Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Fri, 30 Jan 2026 00:32:30 -0500 Subject: [PATCH 036/113] chore: bump version to 1.3.1-beta.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 10ecb124..2efc0095 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tarquinen/opencode-dcp", - "version": "1.3.1-beta.0", + "version": "1.3.1-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "1.3.1-beta.0", + "version": "1.3.1-beta.2", "license": "MIT", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", diff --git a/package.json b/package.json index 66dbcb42..b03bd432 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@tarquinen/opencode-dcp", - "version": "1.3.1-beta.0", + "version": "1.3.1-beta.2", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js", From d71f5c646af142f859543d61cf96b21805f5dd96 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Fri, 30 Jan 2026 20:23:04 -0500 Subject: [PATCH 037/113] stuff --- README.md | 164 +++++++++++++++++++++++++++--------------------------- 1 file changed, 81 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index afb761a5..4d5d62a5 100644 --- a/README.md +++ b/README.md @@ -62,84 +62,86 @@ DCP uses its own config file: - Custom config directory: `$OPENCODE_CONFIG_DIR/dcp.jsonc` (or `dcp.json`), if `OPENCODE_CONFIG_DIR` is set - Project: `.opencode/dcp.jsonc` (or `dcp.json`) in your project's `.opencode` directory -
-Default Configuration (click to expand) - -```jsonc -{ - "$schema": "https://raw.githubusercontent.com/Opencode-DCP/opencode-dynamic-context-pruning/master/dcp.schema.json", - // Enable or disable the plugin - "enabled": true, - // Enable debug logging to ~/.config/opencode/logs/dcp/ - "debug": false, - // Notification display: "off", "minimal", or "detailed" - "pruneNotification": "detailed", - // Slash commands configuration - "commands": { - "enabled": true, - // Additional tools to protect from pruning via commands (e.g., /dcp sweep) - "protectedTools": [], - }, - // Protect from pruning for message turns - "turnProtection": { - "enabled": false, - "turns": 4, - }, - // Protect file operations from pruning via glob patterns - // Patterns match tool parameters.filePath (e.g. read/write/edit) - "protectedFilePatterns": [], - // LLM-driven context pruning tools - "tools": { - // Shared settings for all prune tools - "settings": { - // Nudge the LLM to use prune tools (every tool results) - "nudgeEnabled": true, - "nudgeFrequency": 10, - // Additional tools to protect from pruning - "protectedTools": [], - }, - // Removes tool content from context without preservation (for completed tasks or noise) - "prune": { - "enabled": true, - }, - // Distills key findings into preserved knowledge before removing raw content - "distill": { - "enabled": true, - // Show distillation content as an ignored message notification - "showDistillation": false, - }, - // Collapses a range of conversation content into a single summary - "compress": { - "enabled": true, - // Show summary content as an ignored message notification - "showCompression": true, - }, - }, - // Automatic pruning strategies - "strategies": { - // Remove duplicate tool calls (same tool with same arguments) - "deduplication": { - "enabled": true, - // Additional tools to protect from pruning - "protectedTools": [], - }, - // Prune write tool inputs when the file has been subsequently read - "supersedeWrites": { - "enabled": false, - }, - // Prune tool inputs for errored tools after X turns - "purgeErrors": { - "enabled": true, - // Number of turns before errored tool inputs are pruned - "turns": 4, - // Additional tools to protect from pruning - "protectedTools": [], - }, - }, -} -``` - -
+> [!NOTE] +> +>
+> Default Configuration (click to expand) +> +> ```jsonc +> { +> "$schema": "https://raw.githubusercontent.com/Opencode-DCP/opencode-dynamic-context-pruning/master/dcp.schema.json", +> // Enable or disable the plugin +> "enabled": true, +> // Enable debug logging to ~/.config/opencode/logs/dcp/ +> "debug": false, +> // Notification display: "off", "minimal", or "detailed" +> "pruneNotification": "detailed", +> // Slash commands configuration +> "commands": { +> "enabled": true, +> // Additional tools to protect from pruning via commands (e.g., /dcp sweep) +> "protectedTools": [], +> }, +> // Protect from pruning for message turns past tool invocation +> "turnProtection": { +> "enabled": false, +> "turns": 4, +> }, +> // Protect file operations from pruning via glob patterns +> // Patterns match tool parameters.filePath (e.g. read/write/edit) +> "protectedFilePatterns": [], +> // LLM-driven context pruning tools +> "tools": { +> // Shared settings for all prune tools +> "settings": { +> // Nudge the LLM to use prune tools (every tool results) +> "nudgeEnabled": true, +> "nudgeFrequency": 10, +> // Additional tools to protect from pruning +> "protectedTools": [], +> }, +> // Removes tool content from context without preservation (for completed tasks or noise) +> "prune": { +> "enabled": true, +> }, +> // Distills key findings into preserved knowledge before removing raw content +> "distill": { +> "enabled": true, +> // Show distillation content as an ignored message notification +> "showDistillation": false, +> }, +> // Collapses a range of conversation content into a single summary +> "compress": { +> "enabled": true, +> // Show summary content as an ignored message notification +> "showCompression": true, +> }, +> }, +> // Automatic pruning strategies +> "strategies": { +> // Remove duplicate tool calls (same tool with same arguments) +> "deduplication": { +> "enabled": true, +> // Additional tools to protect from pruning +> "protectedTools": [], +> }, +> // Prune write tool inputs when the file has been subsequently read +> "supersedeWrites": { +> "enabled": false, +> }, +> // Prune tool inputs for errored tools after X turns +> "purgeErrors": { +> "enabled": true, +> // Number of turns before errored tool inputs are pruned +> "turns": 4, +> // Additional tools to protect from pruning +> "protectedTools": [], +> }, +> }, +> } +> ``` +> +>
### Commands @@ -150,13 +152,9 @@ 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`. -### Turn Protection - -When enabled, turn protection prevents tool outputs from being pruned for a configurable number of message turns. This gives the AI time to reference recent tool outputs before they become prunable. Applies to both `prune` and `distill` tools, as well as automatic strategies. - ### Protected Tools -By default, these tools are always protected from pruning across all strategies: +By default, these tools are always protected from pruning: `task`, `todowrite`, `todoread`, `distill`, `compress`, `prune`, `batch`, `write`, `edit`, `plan_enter`, `plan_exit` The `protectedTools` arrays in each section add to this default list. From 4bd1fb8feab714277595740b77ba9b18bc43f84f Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Fri, 30 Jan 2026 20:25:39 -0500 Subject: [PATCH 038/113] maybe --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 4d5d62a5..5dc54b52 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,6 @@ DCP uses its own config file: - Custom config directory: `$OPENCODE_CONFIG_DIR/dcp.jsonc` (or `dcp.json`), if `OPENCODE_CONFIG_DIR` is set - Project: `.opencode/dcp.jsonc` (or `dcp.json`) in your project's `.opencode` directory -> [!NOTE] -> >
> Default Configuration (click to expand) > From 85b0aeb6d97563f71baa36960c7839c5f1b7dc5c Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Fri, 30 Jan 2026 20:35:35 -0500 Subject: [PATCH 039/113] cc cache readme stuff --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5dc54b52..d1f6d4c4 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,9 @@ LLM providers like Anthropic and OpenAI cache prompts based on exact prefix matc > **Note:** In testing, cache hit rates were approximately 65% with DCP enabled vs 85% without. -**Best use case:** Providers that count usage in requests, such as Github Copilot and Google Antigravity have no negative price impact. +**Best use case:** Providers that count usage in requests, such as Github Copilot and Google Antigravity, have no negative price impact. + +**Claude Subscriptions:** Anthropic subscription users (who receive "free" caching) may experience faster limit depletion than hit-rate ratios suggest due to the higher relative cost of cache misses. See [Claude Cache Limits](https://she-llac.com/claude-limits) for details. ## Configuration From b8d7486e5e6f705df9a5b9cb600f9df2cd20ab52 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Fri, 30 Jan 2026 22:06:26 -0500 Subject: [PATCH 040/113] cleanup --- lib/messages/inject.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 5ebe6552..012f24d5 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -148,7 +148,6 @@ export const insertPruneToolContext = ( logger.debug("Last tool was prune - injecting cooldown message") contentParts.push(getCooldownMessage(config)) } else { - // Inject only when prune or distill is enabled if (pruneOrDistillEnabled) { const prunableToolsList = buildPrunableToolsList(state, config, logger, messages) if (prunableToolsList) { @@ -157,7 +156,6 @@ export const insertPruneToolContext = ( } } - // Inject always when compress is enabled (every turn) if (compressEnabled) { const compressContext = buildCompressContext(state, messages) // logger.debug("compress-context: \n" + compressContext) @@ -188,7 +186,6 @@ export const insertPruneToolContext = ( const userInfo = lastUserMessage.info as UserMessage const variant = state.variant ?? userInfo.variant - // Find the last message that isn't an ignored user message const lastNonIgnoredMessage = messages.findLast( (msg) => !(msg.info.role === "user" && isIgnoredUserMessage(msg)), ) @@ -212,7 +209,6 @@ export const insertPruneToolContext = ( const toolPart = createSyntheticToolPart(lastNonIgnoredMessage, combinedContent) lastNonIgnoredMessage.parts.push(toolPart) } else { - // Create a new assistant message with just a text part messages.push( createSyntheticAssistantMessage(lastUserMessage, combinedContent, variant), ) From 0fc988a1dfcd1b274514d3776f3554c1da11e233 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Fri, 30 Jan 2026 22:41:18 -0500 Subject: [PATCH 041/113] cleanup tool cache logic --- lib/state/tool-cache.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/state/tool-cache.ts b/lib/state/tool-cache.ts index b5ad154e..5875375f 100644 --- a/lib/state/tool-cache.ts +++ b/lib/state/tool-cache.ts @@ -43,15 +43,15 @@ export async function syncToolCache( turnProtectionTurns > 0 && state.currentTurn - turnCounter < turnProtectionTurns - state.lastToolPrune = - part.tool === "distill" || part.tool === "compress" || part.tool === "prune" - - const allProtectedTools = config.tools.settings.protectedTools - if (part.tool === "distill" || part.tool === "compress" || part.tool === "prune") { state.nudgeCounter = 0 - } else if (!allProtectedTools.includes(part.tool) && !isProtectedByTurn) { - state.nudgeCounter++ + 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)) { From a8b2551f3b6e5bce9d7de5cbd5d97feefa40d705 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sat, 31 Jan 2026 11:42:10 -0500 Subject: [PATCH 042/113] update dependencies and migrate to v2 sdk --- index.ts | 2 +- lib/messages/utils.ts | 8 +++---- package-lock.json | 49 +++++++++++++++++++------------------------ package.json | 10 ++++----- 4 files changed, 31 insertions(+), 38 deletions(-) diff --git a/index.ts b/index.ts index 94291a6a..d066fac8 100644 --- a/index.ts +++ b/index.ts @@ -37,7 +37,7 @@ const plugin: Plugin = (async (ctx) => { state, logger, config, - ), + ) as any, "chat.message": async ( input: { sessionID: string diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index a0035727..8096763c 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -1,6 +1,6 @@ import { ulid } from "ulid" -import { Logger } from "../logger" import { isMessageCompacted } from "../shared-utils" +import { Logger } from "../logger" import type { SessionState, WithParts } from "../state" import type { UserMessage } from "@opencode-ai/sdk/v2" @@ -26,7 +26,6 @@ export const createSyntheticUserMessage = ( ): WithParts => { const userInfo = baseMessage.info as UserMessage const now = Date.now() - const messageId = generateUniqueId("msg") const partId = generateUniqueId("prt") @@ -45,7 +44,7 @@ export const createSyntheticUserMessage = ( id: partId, sessionID: userInfo.sessionID, messageID: messageId, - type: "text", + type: "text" as const, text: content, }, ], @@ -59,7 +58,6 @@ export const createSyntheticAssistantMessage = ( ): WithParts => { const userInfo = baseMessage.info as UserMessage const now = Date.now() - const messageId = generateUniqueId("msg") const partId = generateUniqueId("prt") @@ -87,7 +85,7 @@ export const createSyntheticAssistantMessage = ( id: partId, sessionID: userInfo.sessionID, messageID: messageId, - type: "text", + type: "text" as const, text: content, }, ], diff --git a/package-lock.json b/package-lock.json index 2efc0095..5eda25fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,15 +10,15 @@ "license": "MIT", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", - "@opencode-ai/sdk": "^1.1.3", + "@opencode-ai/sdk": "^1.1.48", "jsonc-parser": "^3.3.1", "ulid": "^3.0.2", - "zod": "^4.1.13" + "zod": "^4.3.6" }, "devDependencies": { - "@opencode-ai/plugin": "^1.0.143", - "@types/node": "^24.10.1", - "prettier": "^3.4.2", + "@opencode-ai/plugin": "^1.1.48", + "@types/node": "^25.1.0", + "prettier": "^3.8.1", "tsx": "^4.21.0", "typescript": "^5.9.3" }, @@ -494,21 +494,16 @@ } }, "node_modules/@opencode-ai/plugin": { - "version": "1.0.143", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.0.143.tgz", - "integrity": "sha512-yzaCmdazVJMDADJLbMM8KGp1X+Hd/HVyIXMlNt9qcvz/fcs/ET4EwHJsJaQi/9m/jLJ+plwBJAeIW08BMrECPg==", + "version": "1.1.48", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.1.48.tgz", + "integrity": "sha512-KkaSMevXmz7tOwYDMJeWiXE5N8LmRP18qWI5Xhv3+c+FdGPL+l1hQrjSgyv3k7Co7qpCyW3kAUESBB7BzIOl2w==", "dev": true, + "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.0.143", + "@opencode-ai/sdk": "1.1.48", "zod": "4.1.8" } }, - "node_modules/@opencode-ai/plugin/node_modules/@opencode-ai/sdk": { - "version": "1.0.143", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.143.tgz", - "integrity": "sha512-dtmkBfJ7IIAHzL6KCzAlwc9GybfJONVeCsF6ePYySpkuhslDbRkZBJYb5vqGd1H5zdsgjc6JjuvmOf0rPWUL6A==", - "dev": true - }, "node_modules/@opencode-ai/plugin/node_modules/zod": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", @@ -520,15 +515,15 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.3.tgz", - "integrity": "sha512-P4ERbfuT7CilZYyB1l6J/DM6KD0i5V15O+xvsjUitxSS3S2Gr0YsA4bmXU+EsBQGHryUHc81bhJF49a8wSU+tw==", + "version": "1.1.48", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.48.tgz", + "integrity": "sha512-j5/79X45fUPWVD2Ffm/qvwLclDCdPeV+TYMDrm9to0p4pmzhmeKevCsyiRdLg0o0HE3AFRUnOo2rdO9NetN79A==", "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.1.0.tgz", + "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==", "dev": true, "license": "MIT", "dependencies": { @@ -612,9 +607,9 @@ "license": "MIT" }, "node_modules/prettier": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", - "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { @@ -694,9 +689,9 @@ "license": "MIT" }, "node_modules/zod": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", - "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index b03bd432..d490ddd3 100644 --- a/package.json +++ b/package.json @@ -43,15 +43,15 @@ }, "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", - "@opencode-ai/sdk": "^1.1.3", + "@opencode-ai/sdk": "^1.1.48", "jsonc-parser": "^3.3.1", "ulid": "^3.0.2", - "zod": "^4.1.13" + "zod": "^4.3.6" }, "devDependencies": { - "@opencode-ai/plugin": "^1.0.143", - "@types/node": "^24.10.1", - "prettier": "^3.4.2", + "@opencode-ai/plugin": "^1.1.48", + "@types/node": "^25.1.0", + "prettier": "^3.8.1", "tsx": "^4.21.0", "typescript": "^5.9.3" }, From a21285a808f0112b36f4c1045aae18d413c9298a Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sat, 31 Jan 2026 17:23:07 -0500 Subject: [PATCH 043/113] feat: add full tool pruning for edit and write tools - Add pruneFullTool() to remove entire edit/write tool parts from messages - Remove edit/write from default protected tools - Update token calculation to include input parameters for edit/write - Enable supersede writes strategy by default --- README.md | 6 +++--- lib/config.ts | 4 +--- lib/messages/prune.ts | 48 ++++++++++++++++++++++++++++++++++++++++- lib/strategies/utils.ts | 9 ++++++++ 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d1f6d4c4..6f67262e 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ DCP uses multiple tools and strategies to reduce context size: **Deduplication** — Identifies repeated tool calls (e.g., reading the same file multiple times) and keeps only the most recent output. Runs automatically on every request with zero LLM cost. -**Supersede Writes** — Prunes write tool inputs for files that have subsequently been read. When a file is written and later read, the original write content becomes redundant since the current file state is captured in the read result. Runs automatically on every request with zero LLM cost. +**Supersede Writes** — Removes write tool calls for files that have subsequently been read. When a file is written and later read, the original write content becomes redundant since the current file state is captured in the read result. Runs automatically on every request with zero LLM cost. **Purge Errors** — Prunes tool inputs for tools that returned errors after a configurable number of turns (default: 4). Error messages are preserved for context, but the potentially large input content is removed. Runs automatically on every request with zero LLM cost. @@ -127,7 +127,7 @@ DCP uses its own config file: > }, > // Prune write tool inputs when the file has been subsequently read > "supersedeWrites": { -> "enabled": false, +> "enabled": true, > }, > // Prune tool inputs for errored tools after X turns > "purgeErrors": { @@ -155,7 +155,7 @@ DCP provides a `/dcp` slash command: ### Protected Tools By default, these tools are always protected from pruning: -`task`, `todowrite`, `todoread`, `distill`, `compress`, `prune`, `batch`, `write`, `edit`, `plan_enter`, `plan_exit` +`task`, `todowrite`, `todoread`, `distill`, `compress`, `prune`, `batch`, `plan_enter`, `plan_exit` The `protectedTools` arrays in each section add to this default list. diff --git a/lib/config.ts b/lib/config.ts index 337ddea0..6ec97640 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -79,8 +79,6 @@ const DEFAULT_PROTECTED_TOOLS = [ "distill", "compress", "batch", - "write", - "edit", "plan_enter", "plan_exit", ] @@ -489,7 +487,7 @@ const defaultConfig: PluginConfig = { protectedTools: [], }, supersedeWrites: { - enabled: false, + enabled: true, }, purgeErrors: { enabled: true, diff --git a/lib/messages/prune.ts b/lib/messages/prune.ts index 8d616270..c1ac80c5 100644 --- a/lib/messages/prune.ts +++ b/lib/messages/prune.ts @@ -17,11 +17,57 @@ export const prune = ( messages: WithParts[], ): void => { filterCompressedRanges(state, logger, messages) + pruneFullTool(state, logger, messages) pruneToolOutputs(state, logger, messages) pruneToolInputs(state, logger, messages) pruneToolErrors(state, logger, messages) } +const pruneFullTool = (state: SessionState, logger: Logger, messages: WithParts[]): void => { + const messagesToRemove: string[] = [] + + for (const msg of messages) { + if (isMessageCompacted(state, msg)) { + continue + } + + const parts = Array.isArray(msg.parts) ? msg.parts : [] + const partsToRemove: string[] = [] + + for (const part of parts) { + if (part.type !== "tool") { + continue + } + if (!state.prune.toolIds.includes(part.callID)) { + continue + } + if (part.tool !== "edit" && part.tool !== "write") { + continue + } + + partsToRemove.push(part.callID) + } + + if (partsToRemove.length === 0) { + continue + } + + msg.parts = parts.filter( + (part) => part.type !== "tool" || !partsToRemove.includes(part.callID), + ) + + if (msg.parts.length === 0) { + messagesToRemove.push(msg.info.id) + } + } + + if (messagesToRemove.length > 0) { + const result = messages.filter((msg) => !messagesToRemove.includes(msg.info.id)) + messages.length = 0 + messages.push(...result) + } +} + const pruneToolOutputs = (state: SessionState, logger: Logger, messages: WithParts[]): void => { for (const msg of messages) { if (isMessageCompacted(state, msg)) { @@ -39,7 +85,7 @@ const pruneToolOutputs = (state: SessionState, logger: Logger, messages: WithPar if (part.state.status !== "completed") { continue } - if (part.tool === "question") { + if (part.tool === "question" || part.tool === "edit" || part.tool === "write") { continue } diff --git a/lib/strategies/utils.ts b/lib/strategies/utils.ts index c32ba727..d8b05a2e 100644 --- a/lib/strategies/utils.ts +++ b/lib/strategies/utils.ts @@ -72,6 +72,15 @@ export const calculateTokensSaved = ( } continue } + if (part.tool === "edit" || part.tool === "write") { + if (part.state.input) { + const inputContent = + typeof part.state.input === "string" + ? part.state.input + : JSON.stringify(part.state.input) + contents.push(inputContent) + } + } if (part.state.status === "completed") { const content = typeof part.state.output === "string" From dc104e255ca5a7a6951236d7c64542ab6d8834f1 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sat, 31 Jan 2026 18:20:10 -0500 Subject: [PATCH 044/113] fix: move schema validation to execute() for better error messages Zod validation runs before execute(), preventing custom error messages. Removed .length() and .min() constraints from schemas, added manual validation in execute() with clear, actionable error messages. Also filter malformed compressSummaries on load in persistence.ts. --- lib/state/persistence.ts | 20 ++++++++++++++++++++ lib/tools/compress.ts | 25 ++++++++++++++++++++++++- lib/tools/distill.ts | 36 +++++++++++++++++++++++------------- lib/tools/prune.ts | 13 ++++++++++++- 4 files changed, 79 insertions(+), 15 deletions(-) diff --git a/lib/state/persistence.ts b/lib/state/persistence.ts index 11e06a93..bc435359 100644 --- a/lib/state/persistence.ts +++ b/lib/state/persistence.ts @@ -88,6 +88,26 @@ export async function loadSessionState( return null } + if (Array.isArray(state.compressSummaries)) { + const validSummaries = state.compressSummaries.filter( + (s): s is CompressSummary => + s !== null && + typeof s === "object" && + 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: validSummaries.length, + }) + } + state.compressSummaries = validSummaries + } else { + state.compressSummaries = [] + } + logger.info("Loaded session state from disk", { sessionId: sessionId, }) diff --git a/lib/tools/compress.ts b/lib/tools/compress.ts index f5c30334..0d17c879 100644 --- a/lib/tools/compress.ts +++ b/lib/tools/compress.ts @@ -21,7 +21,6 @@ export function createCompressTool(ctx: PruneToolContext): ReturnType list"), distillation: tool.schema .array(tool.schema.string()) - .min(1) .describe( "Required array of distillation strings, one per ID (positional: distillation[0] for ids[0], etc.)", ), }, async execute(args, toolCtx) { - if (!args.distillation || args.distillation.length === 0) { + if (!args.ids || !Array.isArray(args.ids) || args.ids.length === 0) { + ctx.logger.debug("Distill tool called without ids: " + JSON.stringify(args)) + throw new Error("Missing ids. You must provide at least one ID to distill.") + } + + if (!args.ids.every((id) => typeof id === "string" && id.trim() !== "")) { + ctx.logger.debug("Distill 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.', + ) + } + + if ( + !args.distillation || + !Array.isArray(args.distillation) || + args.distillation.length === 0 + ) { ctx.logger.debug( "Distill tool called without distillation: " + JSON.stringify(args), ) throw new Error( - "Missing distillation. You must provide a distillation string for each ID.", + 'Missing distillation. You must provide an array of strings (e.g., ["summary 1", "summary 2"]).', ) } - if (!Array.isArray(args.distillation)) { + if (!args.distillation.every((d) => typeof d === "string")) { ctx.logger.debug( - "Distill tool called with non-array distillation: " + JSON.stringify(args), - ) - throw new Error( - `Invalid distillation format: expected an array of strings, got ${typeof args.distillation}. ` + - `Example: distillation: ["summary for id 0", "summary for id 1"]`, + "Distill tool called with non-string distillation: " + JSON.stringify(args), ) + throw new Error("Invalid distillation. All distillation entries must be strings.") } - // Log the distillation for debugging/analysis - ctx.logger.info("Distillation data received:") - ctx.logger.info(JSON.stringify(args.distillation, null, 2)) + // ctx.logger.info("Distillation data received:") + // ctx.logger.info(JSON.stringify(args.distillation, null, 2)) return executePruneOperation( ctx, diff --git a/lib/tools/prune.ts b/lib/tools/prune.ts index 29448c01..17065aa9 100644 --- a/lib/tools/prune.ts +++ b/lib/tools/prune.ts @@ -12,10 +12,21 @@ export function createPruneTool(ctx: PruneToolContext): ReturnType args: { ids: tool.schema .array(tool.schema.string()) - .min(1) .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" From 7453ed474dba980d20a2de0a56aa4ab7a3ec302d Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sat, 31 Jan 2026 18:22:19 -0500 Subject: [PATCH 045/113] v1.3.2-beta.0 - Bump version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5eda25fc..ced88887 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tarquinen/opencode-dcp", - "version": "1.3.1-beta.2", + "version": "1.3.2-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "1.3.1-beta.2", + "version": "1.3.2-beta.0", "license": "MIT", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", diff --git a/package.json b/package.json index d490ddd3..eaaffbb9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@tarquinen/opencode-dcp", - "version": "1.3.1-beta.2", + "version": "1.3.2-beta.0", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js", From 4e03a4b8c5a1efd2ce7dd616cf52b6be8a84881e Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sat, 31 Jan 2026 20:37:36 -0500 Subject: [PATCH 046/113] refactor: use Set for prune ID storage - Change toolIds and messageIds from string[] to Set - Update all .includes() to .has(), .push() to .add(), .length to .size - Add serialization layer in persistence.ts for JSON compatibility - Remove redundant Set wrappers now that state is already a Set - Fix session initialization order in dcp commands - Fix token calculation for pruned tools --- lib/commands/context.ts | 9 +++++---- lib/commands/stats.ts | 2 +- lib/commands/sweep.ts | 7 ++++--- lib/hooks.ts | 11 +++++++---- lib/messages/inject.ts | 2 +- lib/messages/prune.ts | 12 ++++++------ lib/shared-utils.ts | 2 +- lib/state/persistence.ts | 15 ++++++++++++--- lib/state/state.ts | 12 ++++++------ lib/state/types.ts | 4 ++-- lib/state/utils.ts | 4 ++-- lib/strategies/deduplication.ts | 7 ++++--- lib/strategies/purge-errors.ts | 7 ++++--- lib/strategies/supersede-writes.ts | 10 +++++----- lib/tools/compress.ts | 8 ++++++-- lib/tools/prune-shared.ts | 4 +++- 16 files changed, 69 insertions(+), 47 deletions(-) diff --git a/lib/commands/context.ts b/lib/commands/context.ts index 2706290d..0e23bab7 100644 --- a/lib/commands/context.ts +++ b/lib/commands/context.ts @@ -74,8 +74,8 @@ function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdo tools: 0, toolCount: 0, prunedTokens: state.stats.totalPruneTokens, - prunedCount: state.prune.toolIds.length, - prunedMessageCount: state.prune.messageIds.length, + prunedCount: state.prune.toolIds.size, + prunedMessageCount: state.prune.messageIds.size, total: 0, } @@ -129,7 +129,8 @@ function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdo foundToolIds.add(toolPart.callID) } - if (!isCompacted) { + const isPruned = toolPart.callID && state.prune.toolIds.has(toolPart.callID) + if (!isCompacted && !isPruned) { if (toolPart.state?.input) { const inputStr = typeof toolPart.state.input === "string" @@ -177,7 +178,7 @@ function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdo breakdown.system = Math.max(0, firstInput - firstUserTokens) } - breakdown.tools = Math.max(0, toolInputTokens + toolOutputTokens - breakdown.prunedTokens) + breakdown.tools = toolInputTokens + toolOutputTokens breakdown.assistant = Math.max( 0, breakdown.total - breakdown.system - breakdown.user - breakdown.tools, diff --git a/lib/commands/stats.ts b/lib/commands/stats.ts index 24635947..5e62bf2a 100644 --- a/lib/commands/stats.ts +++ b/lib/commands/stats.ts @@ -48,7 +48,7 @@ export async function handleStatsCommand(ctx: StatsCommandContext): Promise { - if (existingPrunedSet.has(id)) { + if (state.prune.toolIds.has(id)) { return false } const entry = state.toolParameters.get(id) @@ -213,7 +212,9 @@ export async function handleSweepCommand(ctx: SweepCommandContext): Promise { - if (state.prune.toolIds.includes(toolCallId)) { + if (state.prune.toolIds.has(toolCallId)) { return } diff --git a/lib/messages/prune.ts b/lib/messages/prune.ts index c1ac80c5..09169700 100644 --- a/lib/messages/prune.ts +++ b/lib/messages/prune.ts @@ -38,7 +38,7 @@ const pruneFullTool = (state: SessionState, logger: Logger, messages: WithParts[ if (part.type !== "tool") { continue } - if (!state.prune.toolIds.includes(part.callID)) { + if (!state.prune.toolIds.has(part.callID)) { continue } if (part.tool !== "edit" && part.tool !== "write") { @@ -79,7 +79,7 @@ const pruneToolOutputs = (state: SessionState, logger: Logger, messages: WithPar if (part.type !== "tool") { continue } - if (!state.prune.toolIds.includes(part.callID)) { + if (!state.prune.toolIds.has(part.callID)) { continue } if (part.state.status !== "completed") { @@ -105,7 +105,7 @@ const pruneToolInputs = (state: SessionState, logger: Logger, messages: WithPart if (part.type !== "tool") { continue } - if (!state.prune.toolIds.includes(part.callID)) { + if (!state.prune.toolIds.has(part.callID)) { continue } if (part.state.status !== "completed") { @@ -133,7 +133,7 @@ const pruneToolErrors = (state: SessionState, logger: Logger, messages: WithPart if (part.type !== "tool") { continue } - if (!state.prune.toolIds.includes(part.callID)) { + if (!state.prune.toolIds.has(part.callID)) { continue } if (part.state.status !== "error") { @@ -158,7 +158,7 @@ const filterCompressedRanges = ( logger: Logger, messages: WithParts[], ): void => { - if (!state.prune.messageIds?.length) { + if (!state.prune.messageIds?.size) { return } @@ -193,7 +193,7 @@ const filterCompressedRanges = ( } // Skip messages that are in the prune list - if (state.prune.messageIds.includes(msgId)) { + if (state.prune.messageIds.has(msgId)) { continue } diff --git a/lib/shared-utils.ts b/lib/shared-utils.ts index df0fceef..0baab713 100644 --- a/lib/shared-utils.ts +++ b/lib/shared-utils.ts @@ -5,7 +5,7 @@ export const isMessageCompacted = (state: SessionState, msg: WithParts): boolean if (msg.info.time.created < state.lastCompaction) { return true } - if (state.prune.messageIds.includes(msg.info.id)) { + if (state.prune.messageIds.has(msg.info.id)) { return true } return false diff --git a/lib/state/persistence.ts b/lib/state/persistence.ts index bc435359..2067dd95 100644 --- a/lib/state/persistence.ts +++ b/lib/state/persistence.ts @@ -8,12 +8,18 @@ import * as fs from "fs/promises" import { existsSync } from "fs" import { homedir } from "os" import { join } from "path" -import type { SessionState, SessionStats, Prune, CompressSummary } from "./types" +import type { SessionState, SessionStats, CompressSummary } from "./types" import type { Logger } from "../logger" +/** Prune state as stored on disk (arrays for JSON compatibility) */ +export interface PersistedPrune { + toolIds: string[] + messageIds: string[] +} + export interface PersistedSessionState { sessionName?: string - prune: Prune + prune: PersistedPrune compressSummaries: CompressSummary[] stats: SessionStats lastUpdated: string @@ -45,7 +51,10 @@ export async function saveSessionState( const state: PersistedSessionState = { sessionName: sessionName, - prune: sessionState.prune, + prune: { + toolIds: [...sessionState.prune.toolIds], + messageIds: [...sessionState.prune.messageIds], + }, compressSummaries: sessionState.compressSummaries, stats: sessionState.stats, lastUpdated: new Date().toISOString(), diff --git a/lib/state/state.ts b/lib/state/state.ts index c8e3866d..50866352 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -48,8 +48,8 @@ export function createSessionState(): SessionState { sessionId: null, isSubAgent: false, prune: { - toolIds: [], - messageIds: [], + toolIds: new Set(), + messageIds: new Set(), }, compressSummaries: [], stats: { @@ -69,8 +69,8 @@ export function resetSessionState(state: SessionState): void { state.sessionId = null state.isSubAgent = false state.prune = { - toolIds: [], - messageIds: [], + toolIds: new Set(), + messageIds: new Set(), } state.compressSummaries = [] state.stats = { @@ -115,8 +115,8 @@ export async function ensureSessionInitialized( } state.prune = { - toolIds: persisted.prune.toolIds || [], - messageIds: persisted.prune.messageIds || [], + toolIds: new Set(persisted.prune.toolIds || []), + messageIds: new Set(persisted.prune.messageIds || []), } state.compressSummaries = persisted.compressSummaries || [] state.stats = { diff --git a/lib/state/types.ts b/lib/state/types.ts index d84f0ee5..7d5d8494 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -26,8 +26,8 @@ export interface CompressSummary { } export interface Prune { - toolIds: string[] - messageIds: string[] + toolIds: Set + messageIds: Set } export interface SessionState { diff --git a/lib/state/utils.ts b/lib/state/utils.ts index da96afb1..343a3574 100644 --- a/lib/state/utils.ts +++ b/lib/state/utils.ts @@ -38,8 +38,8 @@ export function countTurns(state: SessionState, messages: WithParts[]): number { export function resetOnCompaction(state: SessionState): void { state.toolParameters.clear() - state.prune.toolIds = [] - state.prune.messageIds = [] + state.prune.toolIds = new Set() + state.prune.messageIds = new Set() state.compressSummaries = [] state.nudgeCounter = 0 state.lastToolPrune = false diff --git a/lib/strategies/deduplication.ts b/lib/strategies/deduplication.ts index fb6ce4ed..9d909dc5 100644 --- a/lib/strategies/deduplication.ts +++ b/lib/strategies/deduplication.ts @@ -27,8 +27,7 @@ export const deduplicate = ( } // Filter out IDs already pruned - const alreadyPruned = new Set(state.prune.toolIds) - const unprunedIds = allToolIds.filter((id) => !alreadyPruned.has(id)) + const unprunedIds = allToolIds.filter((id) => !state.prune.toolIds.has(id)) if (unprunedIds.length === 0) { return @@ -77,7 +76,9 @@ export const deduplicate = ( state.stats.totalPruneTokens += calculateTokensSaved(state, messages, newPruneIds) if (newPruneIds.length > 0) { - state.prune.toolIds.push(...newPruneIds) + for (const id of newPruneIds) { + state.prune.toolIds.add(id) + } logger.debug(`Marked ${newPruneIds.length} duplicate tool calls for pruning`) } } diff --git a/lib/strategies/purge-errors.ts b/lib/strategies/purge-errors.ts index c3debf69..48b4ad5e 100644 --- a/lib/strategies/purge-errors.ts +++ b/lib/strategies/purge-errors.ts @@ -30,8 +30,7 @@ export const purgeErrors = ( } // Filter out IDs already pruned - const alreadyPruned = new Set(state.prune.toolIds) - const unprunedIds = allToolIds.filter((id) => !alreadyPruned.has(id)) + const unprunedIds = allToolIds.filter((id) => !state.prune.toolIds.has(id)) if (unprunedIds.length === 0) { return @@ -72,7 +71,9 @@ export const purgeErrors = ( if (newPruneIds.length > 0) { state.stats.totalPruneTokens += calculateTokensSaved(state, messages, newPruneIds) - state.prune.toolIds.push(...newPruneIds) + for (const id of newPruneIds) { + state.prune.toolIds.add(id) + } logger.debug( `Marked ${newPruneIds.length} error tool calls for pruning (older than ${turnThreshold} turns)`, ) diff --git a/lib/strategies/supersede-writes.ts b/lib/strategies/supersede-writes.ts index ef765c42..5d940242 100644 --- a/lib/strategies/supersede-writes.ts +++ b/lib/strategies/supersede-writes.ts @@ -30,9 +30,7 @@ export const supersedeWrites = ( } // Filter out IDs already pruned - const alreadyPruned = new Set(state.prune.toolIds) - - const unprunedIds = allToolIds.filter((id) => !alreadyPruned.has(id)) + const unprunedIds = allToolIds.filter((id) => !state.prune.toolIds.has(id)) if (unprunedIds.length === 0) { return } @@ -85,7 +83,7 @@ export const supersedeWrites = ( // For each write, check if there's a read that comes after it for (const write of writes) { // Skip if already pruned - if (alreadyPruned.has(write.id)) { + if (state.prune.toolIds.has(write.id)) { continue } @@ -99,7 +97,9 @@ export const supersedeWrites = ( if (newPruneIds.length > 0) { state.stats.totalPruneTokens += calculateTokensSaved(state, messages, newPruneIds) - state.prune.toolIds.push(...newPruneIds) + for (const id of newPruneIds) { + state.prune.toolIds.add(id) + } logger.debug(`Marked ${newPruneIds.length} superseded write tool calls for pruning`) } } diff --git a/lib/tools/compress.ts b/lib/tools/compress.ts index 0d17c879..84b9f83f 100644 --- a/lib/tools/compress.ts +++ b/lib/tools/compress.ts @@ -105,8 +105,12 @@ export function createCompressTool(ctx: PruneToolContext): ReturnType toolIdList[index]) - state.prune.toolIds.push(...pruneToolIds) + for (const id of pruneToolIds) { + state.prune.toolIds.add(id) + } const toolMetadata = new Map() for (const id of pruneToolIds) { From b3d9e5b45cba46407794908f6ef80e83e24a548c Mon Sep 17 00:00:00 2001 From: essinghigh Date: Sun, 1 Feb 2026 01:39:44 +0000 Subject: [PATCH 047/113] feat: support toast notifications via notificationType config option --- dcp.schema.json | 6 ++++ lib/config.ts | 17 ++++++++++ lib/ui/notification.ts | 74 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/dcp.schema.json b/dcp.schema.json index a9bfed22..d044098f 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -26,6 +26,12 @@ "default": "detailed", "description": "Level of notification shown when pruning occurs" }, + "pruneNotificationType": { + "type": "string", + "enum": ["chat", "toast"], + "default": "chat", + "description": "Where to display prune notifications (chat message or toast notification)" + }, "commands": { "type": "object", "description": "Configuration for DCP slash commands (/dcp)", diff --git a/lib/config.ts b/lib/config.ts index 6ec97640..bdea0385 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -60,6 +60,7 @@ export interface PluginConfig { enabled: boolean debug: boolean pruneNotification: "off" | "minimal" | "detailed" + pruneNotificationType: "chat" | "toast" commands: Commands turnProtection: TurnProtection protectedFilePatterns: string[] @@ -91,6 +92,7 @@ export const VALID_CONFIG_KEYS = new Set([ "debug", "showUpdateToasts", // Deprecated but kept for backwards compatibility "pruneNotification", + "pruneNotificationType", "turnProtection", "turnProtection.enabled", "turnProtection.turns", @@ -173,6 +175,17 @@ function validateConfigTypes(config: Record): ValidationError[] { } } + if (config.pruneNotificationType !== undefined) { + const validValues = ["chat", "toast"] + if (!validValues.includes(config.pruneNotificationType)) { + errors.push({ + key: "pruneNotificationType", + expected: '"chat" | "toast"', + actual: JSON.stringify(config.pruneNotificationType), + }) + } + } + if (config.protectedFilePatterns !== undefined) { if (!Array.isArray(config.protectedFilePatterns)) { errors.push({ @@ -454,6 +467,7 @@ const defaultConfig: PluginConfig = { enabled: true, debug: false, pruneNotification: "detailed", + pruneNotificationType: "chat", commands: { enabled: true, protectedTools: [...DEFAULT_PROTECTED_TOOLS], @@ -732,6 +746,7 @@ export function getConfig(ctx: PluginInput): PluginConfig { 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), turnProtection: { enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, @@ -775,6 +790,7 @@ export function getConfig(ctx: PluginInput): PluginConfig { 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), turnProtection: { enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, @@ -815,6 +831,7 @@ export function getConfig(ctx: PluginInput): PluginConfig { 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), turnProtection: { enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index ec6d399b..b9338d7d 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -63,6 +63,39 @@ function buildDetailedMessage( return (message + formatExtracted(showDistillation ? distillation : undefined)).trim() } +const TOAST_BODY_MAX_LINES = 12 +const TOAST_SUMMARY_MAX_CHARS = 600 + +function truncateToastBody(body: string, maxLines: number = TOAST_BODY_MAX_LINES): string { + const lines = body.split("\n") + if (lines.length <= maxLines) { + return body + } + const kept = lines.slice(0, maxLines - 1) + const remaining = lines.length - maxLines + 1 + return kept.join("\n") + `\n... and ${remaining} more` +} + +function truncateToastSummary(summary: string, maxChars: number = TOAST_SUMMARY_MAX_CHARS): string { + if (summary.length <= maxChars) { + return summary + } + return summary.slice(0, maxChars - 3) + "..." +} + +function truncateExtractedSection(message: string, maxChars: number = TOAST_SUMMARY_MAX_CHARS): string { + const marker = "\n\n▣ Extracted" + const index = message.indexOf(marker) + if (index === -1) { + return message + } + const extracted = message.slice(index) + if (extracted.length <= maxChars) { + return message + } + return message.slice(0, index) + truncateToastSummary(extracted, maxChars) +} + export async function sendUnifiedNotification( client: any, logger: Logger, @@ -100,6 +133,22 @@ export async function sendUnifiedNotification( showDistillation, ) + if (config.pruneNotificationType === "toast") { + let toastMessage = truncateExtractedSection(message) + toastMessage = + config.pruneNotification === "minimal" ? toastMessage : truncateToastBody(toastMessage) + + await client.tui.showToast({ + body: { + title: "DCP: Prune Notification", + message: toastMessage, + variant: "info", + duration: 5000, + }, + }) + return true + } + await sendIgnoredMessage(client, sessionId, message, params, logger) return true } @@ -150,6 +199,31 @@ export async function sendCompressNotification( } } + if (config.pruneNotificationType === "toast") { + let toastMessage = message + if (config.tools.compress.showCompression) { + const truncatedSummary = truncateToastSummary(summary) + if (truncatedSummary !== summary) { + toastMessage = toastMessage.replace( + `\n→ Compression: ${summary}`, + `\n→ Compression: ${truncatedSummary}`, + ) + } + } + toastMessage = + config.pruneNotification === "minimal" ? toastMessage : truncateToastBody(toastMessage) + + await client.tui.showToast({ + body: { + title: "DCP: Compress Notification", + message: toastMessage, + variant: "info", + duration: 5000, + }, + }) + return true + } + await sendIgnoredMessage(client, sessionId, message, params, logger) return true } From 8f469adbbd6edbc610a22236bb20946e3ce5a2da Mon Sep 17 00:00:00 2001 From: essinghigh Date: Sun, 1 Feb 2026 02:07:19 +0000 Subject: [PATCH 048/113] prettier --- lib/config.ts | 9 ++++++--- lib/ui/notification.ts | 5 ++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index bdea0385..bfac6dbd 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -746,7 +746,8 @@ export function getConfig(ctx: PluginInput): PluginConfig { enabled: result.data.enabled ?? config.enabled, debug: result.data.debug ?? config.debug, pruneNotification: result.data.pruneNotification ?? config.pruneNotification, - pruneNotificationType: result.data.pruneNotificationType ?? config.pruneNotificationType, + pruneNotificationType: + result.data.pruneNotificationType ?? config.pruneNotificationType, commands: mergeCommands(config.commands, result.data.commands as any), turnProtection: { enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, @@ -790,7 +791,8 @@ export function getConfig(ctx: PluginInput): PluginConfig { enabled: result.data.enabled ?? config.enabled, debug: result.data.debug ?? config.debug, pruneNotification: result.data.pruneNotification ?? config.pruneNotification, - pruneNotificationType: result.data.pruneNotificationType ?? config.pruneNotificationType, + pruneNotificationType: + result.data.pruneNotificationType ?? config.pruneNotificationType, commands: mergeCommands(config.commands, result.data.commands as any), turnProtection: { enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, @@ -831,7 +833,8 @@ export function getConfig(ctx: PluginInput): PluginConfig { enabled: result.data.enabled ?? config.enabled, debug: result.data.debug ?? config.debug, pruneNotification: result.data.pruneNotification ?? config.pruneNotification, - pruneNotificationType: result.data.pruneNotificationType ?? config.pruneNotificationType, + pruneNotificationType: + result.data.pruneNotificationType ?? config.pruneNotificationType, commands: mergeCommands(config.commands, result.data.commands as any), turnProtection: { enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index b9338d7d..9d628175 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -83,7 +83,10 @@ function truncateToastSummary(summary: string, maxChars: number = TOAST_SUMMARY_ return summary.slice(0, maxChars - 3) + "..." } -function truncateExtractedSection(message: string, maxChars: number = TOAST_SUMMARY_MAX_CHARS): string { +function truncateExtractedSection( + message: string, + maxChars: number = TOAST_SUMMARY_MAX_CHARS, +): string { const marker = "\n\n▣ Extracted" const index = message.indexOf(marker) if (index === -1) { From 61488688383f28fbc20cfc49829894f1e55c9903 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sat, 31 Jan 2026 20:59:21 -0500 Subject: [PATCH 049/113] refactor: revert to simpler tool part injection for all models Remove hybrid injection strategy that used synthetic assistant messages for most models while special-casing DeepSeek/Kimi with tool parts. Now uses tool part injection universally when last message is assistant. --- lib/messages/inject.ts | 24 +++++-------------- lib/messages/utils.ts | 52 ------------------------------------------ 2 files changed, 6 insertions(+), 70 deletions(-) diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index d13d8b19..32a9f196 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -7,9 +7,7 @@ import { extractParameterKey, buildToolIdList, createSyntheticUserMessage, - createSyntheticAssistantMessage, createSyntheticToolPart, - isDeepSeekOrKimi, isIgnoredUserMessage, } from "./utils" import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" @@ -197,21 +195,11 @@ export const insertPruneToolContext = ( if (!lastNonIgnoredMessage || lastNonIgnoredMessage.info.role === "user") { messages.push(createSyntheticUserMessage(lastUserMessage, combinedContent, variant)) } else { - // For DeepSeek and Kimi, append tool part to existing message, for some reason they don't - // output reasoning parts following an assistant injection containing either just text part, - // or text part with synth reasoning, and there's no docs on how their reasoning encryption - // works as far as I can find. IDK what's going on here, seems like the only possible ways - // to inject for them is a user role message, or a tool part apeended to last assistant message. - const providerID = userInfo.model?.providerID || "" - const modelID = userInfo.model?.modelID || "" - - if (isDeepSeekOrKimi(providerID, modelID)) { - const toolPart = createSyntheticToolPart(lastNonIgnoredMessage, combinedContent) - lastNonIgnoredMessage.parts.push(toolPart) - } else { - messages.push( - createSyntheticAssistantMessage(lastUserMessage, combinedContent, variant), - ) - } + // Append tool part to existing assistant message. This approach works universally across + // models including DeepSeek and Kimi which don't output reasoning parts following an + // assistant injection containing text parts. Tool parts appended to the last assistant + // message are the safest way to inject context without disrupting model behavior. + const toolPart = createSyntheticToolPart(lastNonIgnoredMessage, combinedContent) + lastNonIgnoredMessage.parts.push(toolPart) } } diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index 8096763c..e4d4c6b3 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -8,17 +8,6 @@ export const COMPRESS_SUMMARY_PREFIX = "[Compressed conversation block]\n\n" const generateUniqueId = (prefix: string): string => `${prefix}_${ulid()}` -export const isDeepSeekOrKimi = (providerID: string, modelID: string): boolean => { - const lowerProviderID = providerID.toLowerCase() - const lowerModelID = modelID.toLowerCase() - return ( - lowerProviderID.includes("deepseek") || - lowerProviderID.includes("kimi") || - lowerModelID.includes("deepseek") || - lowerModelID.includes("kimi") - ) -} - export const createSyntheticUserMessage = ( baseMessage: WithParts, content: string, @@ -51,47 +40,6 @@ export const createSyntheticUserMessage = ( } } -export const createSyntheticAssistantMessage = ( - baseMessage: WithParts, - content: string, - variant?: string, -): WithParts => { - const userInfo = baseMessage.info as UserMessage - const now = Date.now() - const messageId = generateUniqueId("msg") - const partId = generateUniqueId("prt") - - return { - info: { - id: messageId, - sessionID: userInfo.sessionID, - role: "assistant" as const, - agent: userInfo.agent || "code", - parentID: userInfo.id, - modelID: userInfo.model.modelID, - providerID: userInfo.model.providerID, - mode: "default", - path: { - cwd: "/", - root: "/", - }, - time: { created: now, completed: now }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - ...(variant !== undefined && { variant }), - }, - parts: [ - { - id: partId, - sessionID: userInfo.sessionID, - messageID: messageId, - type: "text" as const, - text: content, - }, - ], - } -} - export const createSyntheticToolPart = (baseMessage: WithParts, content: string) => { const userInfo = baseMessage.info as UserMessage const now = Date.now() From 698392ef00a00db08736d08915a7e4ab0f0161b5 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sat, 31 Jan 2026 21:03:01 -0500 Subject: [PATCH 050/113] fix: restore Gemini thoughtSignature bypass for synthetic tool parts --- lib/messages/inject.ts | 3 ++- lib/messages/utils.ts | 18 ++++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 32a9f196..6143f9fd 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -199,7 +199,8 @@ export const insertPruneToolContext = ( // models including DeepSeek and Kimi which don't output reasoning parts following an // assistant injection containing text parts. Tool parts appended to the last assistant // message are the safest way to inject context without disrupting model behavior. - const toolPart = createSyntheticToolPart(lastNonIgnoredMessage, combinedContent) + const modelID = userInfo.model?.modelID || "" + const toolPart = createSyntheticToolPart(lastNonIgnoredMessage, combinedContent, modelID) lastNonIgnoredMessage.parts.push(toolPart) } } diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index e4d4c6b3..8cfd2e42 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -8,6 +8,11 @@ export const COMPRESS_SUMMARY_PREFIX = "[Compressed conversation block]\n\n" const generateUniqueId = (prefix: string): string => `${prefix}_${ulid()}` +const isGeminiModel = (modelID: string): boolean => { + const lowerModelID = modelID.toLowerCase() + return lowerModelID.includes("gemini") +} + export const createSyntheticUserMessage = ( baseMessage: WithParts, content: string, @@ -40,13 +45,22 @@ export const createSyntheticUserMessage = ( } } -export const createSyntheticToolPart = (baseMessage: WithParts, content: string) => { +export const createSyntheticToolPart = ( + baseMessage: WithParts, + content: string, + modelID: string, +) => { const userInfo = baseMessage.info as UserMessage const now = Date.now() const partId = generateUniqueId("prt") const callId = generateUniqueId("call") + // Gemini requires thoughtSignature bypass to accept synthetic tool parts + const toolPartMetadata = isGeminiModel(modelID) + ? { google: { thoughtSignature: "skip_thought_signature_validator" } } + : {} + return { id: partId, sessionID: userInfo.sessionID, @@ -59,7 +73,7 @@ export const createSyntheticToolPart = (baseMessage: WithParts, content: string) input: {}, output: content, title: "Context Info", - metadata: {}, + metadata: toolPartMetadata, time: { start: now, end: now }, }, } From 0bcacfbaf8a4af785b3550f52d4b8facc1689636 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sun, 1 Feb 2026 00:38:32 -0500 Subject: [PATCH 051/113] refactor: rename prunedCount to prunedToolCount for clarity --- lib/commands/context.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/commands/context.ts b/lib/commands/context.ts index 0e23bab7..15328692 100644 --- a/lib/commands/context.ts +++ b/lib/commands/context.ts @@ -61,7 +61,7 @@ interface TokenBreakdown { tools: number toolCount: number prunedTokens: number - prunedCount: number + prunedToolCount: number prunedMessageCount: number total: number } @@ -74,7 +74,7 @@ function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdo tools: 0, toolCount: 0, prunedTokens: state.stats.totalPruneTokens, - prunedCount: state.prune.toolIds.size, + prunedToolCount: state.prune.toolIds.size, prunedMessageCount: state.prune.messageIds.size, total: 0, } @@ -198,7 +198,7 @@ function formatContextMessage(breakdown: TokenBreakdown): string { const lines: string[] = [] const barWidth = 30 - const toolsInContext = breakdown.toolCount - breakdown.prunedCount + const toolsInContext = breakdown.toolCount - breakdown.prunedToolCount const toolsLabel = `Tools (${toolsInContext})` const categories = [ @@ -236,7 +236,7 @@ function formatContextMessage(breakdown: TokenBreakdown): string { if (breakdown.prunedTokens > 0) { const withoutPruning = breakdown.total + breakdown.prunedTokens const pruned = [] - if (breakdown.prunedCount > 0) pruned.push(`${breakdown.prunedCount} tools`) + if (breakdown.prunedToolCount > 0) pruned.push(`${breakdown.prunedToolCount} tools`) if (breakdown.prunedMessageCount > 0) pruned.push(`${breakdown.prunedMessageCount} messages`) lines.push( From e5959288b21604ca2ce1c9e2762b79ad580497fd Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sun, 1 Feb 2026 00:38:33 -0500 Subject: [PATCH 052/113] docs: add pruneNotificationType to README config example --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 6f67262e..8b222a76 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ DCP uses its own config file: > "debug": false, > // Notification display: "off", "minimal", or "detailed" > "pruneNotification": "detailed", +> // Notification type: "chat" (in-conversation) or "toast" (system toast) +> "pruneNotificationType": "chat", > // Slash commands configuration > "commands": { > "enabled": true, From e82ed4d1c2e0a4bf04395103d0e293d2dae2e9ac Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sun, 1 Feb 2026 01:24:26 -0500 Subject: [PATCH 053/113] feat: add message count tracking to stats display --- lib/commands/stats.ts | 18 ++++++++++++------ lib/state/persistence.ts | 3 +++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/lib/commands/stats.ts b/lib/commands/stats.ts index 5e62bf2a..a554309d 100644 --- a/lib/commands/stats.ts +++ b/lib/commands/stats.ts @@ -21,6 +21,7 @@ export interface StatsCommandContext { function formatStatsMessage( sessionTokens: number, sessionTools: number, + sessionMessages: number, allTime: AggregatedStats, ): string { const lines: string[] = [] @@ -31,14 +32,16 @@ function formatStatsMessage( lines.push("") lines.push("Session:") lines.push("─".repeat(60)) - lines.push(` Tokens pruned: ~${formatTokenCount(sessionTokens)}`) - lines.push(` Tools pruned: ${sessionTools}`) + lines.push(` Tokens pruned: ~${formatTokenCount(sessionTokens)}`) + lines.push(` Tools pruned: ${sessionTools}`) + lines.push(` Messages pruned: ${sessionMessages}`) lines.push("") lines.push("All-time:") lines.push("─".repeat(60)) - lines.push(` Tokens saved: ~${formatTokenCount(allTime.totalTokens)}`) - lines.push(` Tools pruned: ${allTime.totalTools}`) - lines.push(` Sessions: ${allTime.sessionCount}`) + lines.push(` Tokens saved: ~${formatTokenCount(allTime.totalTokens)}`) + lines.push(` Tools pruned: ${allTime.totalTools}`) + lines.push(` Messages pruned: ${allTime.totalMessages}`) + lines.push(` Sessions: ${allTime.sessionCount}`) return lines.join("\n") } @@ -49,11 +52,12 @@ export async function handleStatsCommand(ctx: StatsCommandContext): Promise Date: Sun, 1 Feb 2026 01:26:39 -0500 Subject: [PATCH 054/113] v1.3.3-beta.0 - Bump version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ced88887..317b7cd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tarquinen/opencode-dcp", - "version": "1.3.2-beta.0", + "version": "1.3.3-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "1.3.2-beta.0", + "version": "1.3.3-beta.0", "license": "MIT", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", diff --git a/package.json b/package.json index eaaffbb9..eeee5acb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@tarquinen/opencode-dcp", - "version": "1.3.2-beta.0", + "version": "1.3.3-beta.0", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js", From 5166df33f85fbce970a63a6e3b773bb3d2b54ec8 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 2 Feb 2026 01:56:57 -0500 Subject: [PATCH 055/113] refactor: inject context as text part in existing user message --- lib/messages/inject.ts | 12 ++++++++---- lib/messages/utils.ts | 13 +++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 6143f9fd..ada9e47b 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -6,7 +6,7 @@ import { renderNudge } from "../prompts" import { extractParameterKey, buildToolIdList, - createSyntheticUserMessage, + createSyntheticTextPart, createSyntheticToolPart, isIgnoredUserMessage, } from "./utils" @@ -182,18 +182,22 @@ export const insertPruneToolContext = ( } const userInfo = lastUserMessage.info as UserMessage - const variant = state.variant ?? userInfo.variant const lastNonIgnoredMessage = messages.findLast( (msg) => !(msg.info.role === "user" && isIgnoredUserMessage(msg)), ) + if (!lastNonIgnoredMessage) { + return + } + // It's not safe to inject assistant role messages following a user message as models such // as Claude expect the assistant "turn" to start with reasoning parts. Reasoning parts in many // cases also cannot be faked as they may be encrypted by the model. // Gemini only accepts synth reasoning text if it is "skip_thought_signature_validator" - if (!lastNonIgnoredMessage || lastNonIgnoredMessage.info.role === "user") { - messages.push(createSyntheticUserMessage(lastUserMessage, combinedContent, variant)) + if (lastNonIgnoredMessage.info.role === "user") { + const textPart = createSyntheticTextPart(lastNonIgnoredMessage, combinedContent) + lastNonIgnoredMessage.parts.push(textPart) } else { // Append tool part to existing assistant message. This approach works universally across // models including DeepSeek and Kimi which don't output reasoning parts following an diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index 8cfd2e42..869735c1 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -45,6 +45,19 @@ export const createSyntheticUserMessage = ( } } +export const createSyntheticTextPart = (baseMessage: WithParts, content: string) => { + const userInfo = baseMessage.info as UserMessage + const partId = generateUniqueId("prt") + + return { + id: partId, + sessionID: userInfo.sessionID, + messageID: baseMessage.info.id, + type: "text" as const, + text: content, + } +} + export const createSyntheticToolPart = ( baseMessage: WithParts, content: string, From 3a1bfd67e97efabe5ebb78e60f48eadcb3c30778 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 2 Feb 2026 02:17:35 -0500 Subject: [PATCH 056/113] feat: use synthetic assistant messages for non-DeepSeek/Kimi injection --- lib/messages/inject.ts | 23 +++++++++++++++---- lib/messages/utils.ts | 52 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index ada9e47b..b356b3c7 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -8,7 +8,9 @@ import { buildToolIdList, createSyntheticTextPart, createSyntheticToolPart, + createSyntheticAssistantMessage, isIgnoredUserMessage, + isDeepSeekOrKimi, } from "./utils" import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" import { getLastUserMessage, isMessageCompacted } from "../shared-utils" @@ -182,6 +184,7 @@ export const insertPruneToolContext = ( } const userInfo = lastUserMessage.info as UserMessage + const variant = state.variant ?? userInfo.variant const lastNonIgnoredMessage = messages.findLast( (msg) => !(msg.info.role === "user" && isIgnoredUserMessage(msg)), @@ -199,12 +202,24 @@ export const insertPruneToolContext = ( const textPart = createSyntheticTextPart(lastNonIgnoredMessage, combinedContent) lastNonIgnoredMessage.parts.push(textPart) } else { - // Append tool part to existing assistant message. This approach works universally across - // models including DeepSeek and Kimi which don't output reasoning parts following an + // For non-user message case: push a new synthetic assistant message or append tool part + // for DeepSeek/Kimi. DeepSeek and Kimi don't output reasoning parts following an // assistant injection containing text parts. Tool parts appended to the last assistant // message are the safest way to inject context without disrupting model behavior. + const providerID = userInfo.model?.providerID || "" const modelID = userInfo.model?.modelID || "" - const toolPart = createSyntheticToolPart(lastNonIgnoredMessage, combinedContent, modelID) - lastNonIgnoredMessage.parts.push(toolPart) + + if (isDeepSeekOrKimi(providerID, modelID)) { + const toolPart = createSyntheticToolPart( + lastNonIgnoredMessage, + combinedContent, + modelID, + ) + lastNonIgnoredMessage.parts.push(toolPart) + } else { + messages.push( + createSyntheticAssistantMessage(lastUserMessage, combinedContent, variant), + ) + } } } diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index 869735c1..a5133738 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -13,6 +13,17 @@ const isGeminiModel = (modelID: string): boolean => { return lowerModelID.includes("gemini") } +export const isDeepSeekOrKimi = (providerID: string, modelID: string): boolean => { + const lowerProviderID = providerID.toLowerCase() + const lowerModelID = modelID.toLowerCase() + return ( + lowerProviderID.includes("deepseek") || + lowerProviderID.includes("kimi") || + lowerModelID.includes("deepseek") || + lowerModelID.includes("kimi") + ) +} + export const createSyntheticUserMessage = ( baseMessage: WithParts, content: string, @@ -45,6 +56,47 @@ export const createSyntheticUserMessage = ( } } +export const createSyntheticAssistantMessage = ( + baseMessage: WithParts, + content: string, + variant?: string, +): WithParts => { + const userInfo = baseMessage.info as UserMessage + const now = Date.now() + const messageId = generateUniqueId("msg") + const partId = generateUniqueId("prt") + + return { + info: { + id: messageId, + sessionID: userInfo.sessionID, + role: "assistant" as const, + agent: userInfo.agent || "code", + parentID: userInfo.id, + modelID: userInfo.model.modelID, + providerID: userInfo.model.providerID, + mode: "default", + path: { + cwd: "/", + root: "/", + }, + time: { created: now, completed: now }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + ...(variant !== undefined && { variant }), + }, + parts: [ + { + id: partId, + sessionID: userInfo.sessionID, + messageID: messageId, + type: "text" as const, + text: content, + }, + ], + } +} + export const createSyntheticTextPart = (baseMessage: WithParts, content: string) => { const userInfo = baseMessage.info as UserMessage const partId = generateUniqueId("prt") From 0467654fb9dc1c0d696f5264a5fab77c45d488d9 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 2 Feb 2026 20:21:02 -0500 Subject: [PATCH 057/113] refactor: use tool parts for all non-user-message injections --- lib/messages/inject.ts | 8 +++++- lib/messages/utils.ts | 57 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index b356b3c7..e1dc1206 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -9,6 +9,7 @@ import { createSyntheticTextPart, createSyntheticToolPart, createSyntheticAssistantMessage, + createSyntheticAssistantMessageWithToolPart, isIgnoredUserMessage, isDeepSeekOrKimi, } from "./utils" @@ -218,7 +219,12 @@ export const insertPruneToolContext = ( lastNonIgnoredMessage.parts.push(toolPart) } else { messages.push( - createSyntheticAssistantMessage(lastUserMessage, combinedContent, variant), + createSyntheticAssistantMessageWithToolPart( + lastUserMessage, + combinedContent, + modelID, + variant, + ), ) } } diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index a5133738..3dd08265 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -97,6 +97,63 @@ export const createSyntheticAssistantMessage = ( } } +export const createSyntheticAssistantMessageWithToolPart = ( + baseMessage: WithParts, + content: string, + modelID: string, + variant?: string, +): WithParts => { + const userInfo = baseMessage.info as UserMessage + const now = Date.now() + const messageId = generateUniqueId("msg") + const partId = generateUniqueId("prt") + const callId = generateUniqueId("call") + + // Gemini requires thoughtSignature bypass to accept synthetic tool parts + const toolPartMetadata = isGeminiModel(modelID) + ? { google: { thoughtSignature: "skip_thought_signature_validator" } } + : {} + + return { + info: { + id: messageId, + sessionID: userInfo.sessionID, + role: "assistant" as const, + agent: userInfo.agent || "code", + parentID: userInfo.id, + modelID: userInfo.model.modelID, + providerID: userInfo.model.providerID, + mode: "default", + path: { + cwd: "/", + root: "/", + }, + time: { created: now, completed: now }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + ...(variant !== undefined && { variant }), + }, + parts: [ + { + id: partId, + sessionID: userInfo.sessionID, + messageID: messageId, + type: "tool" as const, + callID: callId, + tool: "context_info", + state: { + status: "completed" as const, + input: {}, + output: content, + title: "Context Info", + metadata: toolPartMetadata, + time: { start: now, end: now }, + }, + }, + ], + } +} + export const createSyntheticTextPart = (baseMessage: WithParts, content: string) => { const userInfo = baseMessage.info as UserMessage const partId = generateUniqueId("prt") From 7e44bd7b350375b5e3660d3fdba34b001121f571 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 2 Feb 2026 20:28:57 -0500 Subject: [PATCH 058/113] chore: track tests/, gitignore tests/results/ --- .gitignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 5bf7a25f..d277a4e1 100644 --- a/.gitignore +++ b/.gitignore @@ -31,8 +31,8 @@ Thumbs.db # Generated prompt files (from scripts/generate-prompts.ts) lib/prompts/*.generated.ts -# Tests (local development only) -tests/ +# Tests +tests/results/ notes/ test-update.ts From 5df9e013f4c7451bfabe80d4e3612177046c03bc Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 2 Feb 2026 20:29:00 -0500 Subject: [PATCH 059/113] feat: add session analysis utility scripts Python CLI tools for analyzing OpenCode session data: - opencode-dcp-stats: DCP cache impact analysis - opencode-find-session: find sessions by title - opencode-session-timeline: per-step token timeline - opencode-token-stats: aggregate token usage stats --- scripts/opencode-dcp-stats | 489 ++++++++++++++++++++++++++++++ scripts/opencode-find-session | 146 +++++++++ scripts/opencode-session-timeline | 412 +++++++++++++++++++++++++ scripts/opencode-token-stats | 195 ++++++++++++ 4 files changed, 1242 insertions(+) create mode 100755 scripts/opencode-dcp-stats create mode 100755 scripts/opencode-find-session create mode 100755 scripts/opencode-session-timeline create mode 100755 scripts/opencode-token-stats diff --git a/scripts/opencode-dcp-stats b/scripts/opencode-dcp-stats new file mode 100755 index 00000000..ab0059d5 --- /dev/null +++ b/scripts/opencode-dcp-stats @@ -0,0 +1,489 @@ +#!/usr/bin/env python3 +""" +Analyze Dynamic Context Pruning (DCP) tool impact on cache efficiency. +Tracks cache hit rates and context size changes before/after DCP tool invocations. + +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) +DCP_TOOLS = { + "prune", "discard", "extract", "context_pruning", + "squash", "compress", "consolidate", "distill" +} + +# Anthropic pricing: cache read is ~10% of input cost +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 is_ignored_message(message: dict, parts: list[dict]) -> bool: + """ + Check if a message should be ignored (DCP notification messages). + Returns True if message has no parts OR all parts have ignored=true. + Mirrors the isIgnoredUserMessage logic from the DCP plugin. + """ + if not parts: + return True + + # Check text parts for ignored flag + text_parts = [p for p in parts if p.get("type") == "text"] + if not text_parts: + return False + + for part in text_parts: + if not part.get("ignored", False): + return False + + return True + + +def count_real_user_messages(storage: Path, session_id: str) -> int: + """Count user messages that are not ignored (real user interactions).""" + messages = get_session_messages(storage, session_id) + count = 0 + + for msg in messages: + # Only count user role messages + if msg.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 + + +def extract_step_finish(parts: list[dict]) -> Optional[dict]: + """Extract step-finish record from message parts.""" + for part in parts: + if part.get("type") == "step-finish" and "tokens" in part: + return part + return None + + +def extract_dcp_tools(parts: list[dict]) -> list[dict]: + """Extract all DCP tool calls from message parts.""" + dcp_calls = [] + for part in parts: + if part.get("type") == "tool": + tool_name = part.get("tool", "") + if tool_name in DCP_TOOLS: + dcp_calls.append({ + "tool": tool_name, + "state": part.get("state", {}), + "part_id": part.get("_id", "") + }) + return dcp_calls + + +def calc_cache_hit_rate(tokens: dict) -> float: + """Calculate cache hit rate from token dict.""" + input_tokens = tokens.get("input", 0) + cache = tokens.get("cache", {}) + cache_read = cache.get("read", 0) + total_context = input_tokens + cache_read + if total_context == 0: + return 0.0 + return (cache_read / total_context) * 100 + + +def analyze_session(storage: Path, 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": [], + "total_dcp_calls": 0, + "total_steps": 0, + "by_tool": defaultdict(lambda: { + "calls": 0, + "hit_rate_before_sum": 0, + "hit_rate_after_sum": 0, + "context_before_sum": 0, + "context_after_sum": 0, + "input_before_sum": 0, + "input_after_sum": 0, + "cache_before_sum": 0, + "cache_after_sum": 0, + "events_with_data": 0 + }), + # Track hit rates by distance from last DCP call + "hit_rates_by_distance": defaultdict(list) + } + + 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) + + step_finish = extract_step_finish(parts) + dcp_tools = extract_dcp_tools(parts) + + if step_finish: + result["total_steps"] += 1 + tokens = step_finish.get("tokens", {}) + curr_hit_rate = calc_cache_hit_rate(tokens) + + # Track hit rate by distance from last DCP call + if steps_since_dcp is not None: + result["hit_rates_by_distance"][steps_since_dcp].append(curr_hit_rate) + steps_since_dcp += 1 + + # If previous step had DCP tools, measure impact + if prev_dcp_tools and prev_step is not None: + prev_tokens = prev_step.get("tokens", {}) + + prev_input = prev_tokens.get("input", 0) + prev_cache = prev_tokens.get("cache", {}).get("read", 0) + prev_context = prev_input + prev_cache + prev_hit_rate = calc_cache_hit_rate(prev_tokens) + + curr_input = tokens.get("input", 0) + curr_cache = tokens.get("cache", {}).get("read", 0) + curr_context = curr_input + curr_cache + curr_hit_rate = calc_cache_hit_rate(tokens) + + for dcp in prev_dcp_tools: + tool_name = dcp["tool"] + result["total_dcp_calls"] += 1 + + event = { + "tool": tool_name, + "input_before": prev_input, + "input_after": curr_input, + "cache_before": prev_cache, + "cache_after": curr_cache, + "context_before": prev_context, + "context_after": curr_context, + "hit_rate_before": round(prev_hit_rate, 1), + "hit_rate_after": round(curr_hit_rate, 1), + "hit_rate_delta": round(curr_hit_rate - prev_hit_rate, 1), + "context_delta": curr_context - prev_context, + "message_id": msg_id + } + result["dcp_events"].append(event) + + # Aggregate stats + stats = result["by_tool"][tool_name] + stats["calls"] += 1 + stats["hit_rate_before_sum"] += prev_hit_rate + stats["hit_rate_after_sum"] += curr_hit_rate + stats["context_before_sum"] += prev_context + stats["context_after_sum"] += curr_context + stats["input_before_sum"] += prev_input + stats["input_after_sum"] += curr_input + stats["cache_before_sum"] += prev_cache + stats["cache_after_sum"] += curr_cache + stats["events_with_data"] += 1 + + prev_step = step_finish + prev_dcp_tools = dcp_tools + + # Reset distance counter if this step had DCP tools + if dcp_tools: + steps_since_dcp = 0 + + return result + + +def analyze_sessions(num_sessions: int = 20, min_messages: int = 5, output_json: bool = False, verbose: bool = False, session_id: str = None): + """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] + else: + sessions = sorted(message_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True)[:num_sessions] + + all_results = [] + grand_totals = { + "sessions_analyzed": 0, + "sessions_with_dcp": 0, + "sessions_skipped_short": 0, + "total_dcp_calls": 0, + "total_steps": 0, + "min_messages_filter": min_messages, + "by_tool": defaultdict(lambda: { + "calls": 0, + "hit_rate_before_sum": 0, + "hit_rate_after_sum": 0, + "context_before_sum": 0, + "context_after_sum": 0, + "input_before_sum": 0, + "input_after_sum": 0, + "cache_before_sum": 0, + "cache_after_sum": 0, + "events_with_data": 0 + }), + "hit_rates_by_distance": defaultdict(list) + } + + for session_path in sessions: + session_id = session_path.name + + # Check minimum message count (excluding ignored messages) + real_user_messages = count_real_user_messages(storage, session_id) + if real_user_messages < min_messages: + grand_totals["sessions_skipped_short"] += 1 + continue + + result = analyze_session(storage, 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 + + 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) + + if output_json: + output = { + "sessions": all_results, + "totals": dict(grand_totals), + "generated_at": datetime.now().isoformat() + } + output["totals"]["by_tool"] = {k: dict(v) for k, v in grand_totals["by_tool"].items()} + print(json.dumps(output, indent=2, default=str)) + else: + print_summary(all_results, grand_totals, verbose) + + +def print_summary(results: list, totals: dict, verbose: bool = False): + """Print human-readable summary.""" + print("=" * 110) + print("DCP (Dynamic Context Pruning) Cache Impact Analysis") + print("=" * 110) + print() + + print("OVERVIEW") + print("-" * 50) + print(f" Min user messages filter: {totals['min_messages_filter']:>10,}") + print(f" Sessions scanned: {totals['sessions_analyzed'] + totals['sessions_skipped_short']:>10,}") + print(f" Sessions skipped (short): {totals['sessions_skipped_short']:>10,}") + print(f" Sessions analyzed: {totals['sessions_analyzed']:>10,}") + print(f" Sessions with DCP: {totals['sessions_with_dcp']:>10,}") + print(f" Total DCP tool calls: {totals['total_dcp_calls']:>10,}") + print(f" Total steps: {totals['total_steps']:>10,}") + print() + + # Per-tool breakdown with cache hit rate and context changes + if totals["by_tool"]: + print("PER-TOOL BREAKDOWN") + print("-" * 110) + print(f"{'Tool':<18} {'Calls':>7} {'Avg Hit% Before':>16} {'Avg Hit% After':>15} {'Delta':>8} {'Avg Ctx Before':>15} {'Avg Ctx After':>14}") + print("-" * 110) + + for tool, stats in sorted(totals["by_tool"].items(), key=lambda x: x[1]["calls"], reverse=True): + calls = stats["calls"] + if calls == 0: + continue + + avg_hit_before = stats["hit_rate_before_sum"] / calls + avg_hit_after = stats["hit_rate_after_sum"] / calls + hit_delta = avg_hit_after - avg_hit_before + avg_ctx_before = stats["context_before_sum"] / calls + avg_ctx_after = stats["context_after_sum"] / calls + + delta_str = f"{hit_delta:+.1f}%" + print(f"{tool:<18} {calls:>7,} {avg_hit_before:>15.1f}% {avg_hit_after:>14.1f}% {delta_str:>8} {avg_ctx_before:>14,.0f} {avg_ctx_after:>13,.0f}") + print() + + # Cost analysis + print("COST IMPACT ANALYSIS") + print("-" * 80) + total_input_before = sum(s["input_before_sum"] for s in totals["by_tool"].values()) + total_input_after = sum(s["input_after_sum"] for s in totals["by_tool"].values()) + total_cache_before = sum(s["cache_before_sum"] for s in totals["by_tool"].values()) + total_cache_after = sum(s["cache_after_sum"] for s in totals["by_tool"].values()) + + # Tokens moved from cache to input = cache lost + cache_preserved = total_cache_after + cache_lost_to_input = max(0, total_cache_before - total_cache_after) + + if totals["total_dcp_calls"] > 0: + # Average context reduction + avg_ctx_before = (total_input_before + total_cache_before) / totals["total_dcp_calls"] + avg_ctx_after = (total_input_after + total_cache_after) / totals["total_dcp_calls"] + ctx_reduction = avg_ctx_before - avg_ctx_after + + print(f" Total DCP events measured: {totals['total_dcp_calls']:>12,}") + print(f" Avg context before DCP: {avg_ctx_before:>12,.0f} tokens") + print(f" Avg context after DCP: {avg_ctx_after:>12,.0f} tokens") + print(f" Avg context change: {ctx_reduction:>+12,.0f} tokens") + print() + + # Cache efficiency + overall_hit_before = (total_cache_before / (total_input_before + total_cache_before) * 100) if (total_input_before + total_cache_before) > 0 else 0 + overall_hit_after = (total_cache_after / (total_input_after + total_cache_after) * 100) if (total_input_after + total_cache_after) > 0 else 0 + + print(f" Overall cache hit rate before: {overall_hit_before:>11.1f}%") + print(f" Overall cache hit rate after: {overall_hit_after:>11.1f}%") + print(f" Hit rate change: {overall_hit_after - overall_hit_before:>+11.1f}%") + print() + + # Cache recovery analysis - hit rates by distance from last DCP call + if totals["hit_rates_by_distance"]: + print("CACHE RECOVERY ANALYSIS (Hit Rate by Steps Since Last DCP Call)") + print("-" * 80) + print(f"{'Steps After DCP':<18} {'Samples':>10} {'Avg Hit%':>12} {'Min':>10} {'Max':>10}") + print("-" * 80) + + for dist in sorted(totals["hit_rates_by_distance"].keys())[:15]: + rates = totals["hit_rates_by_distance"][dist] + if rates: + avg_rate = sum(rates) / len(rates) + min_rate = min(rates) + max_rate = max(rates) + print(f"{dist:<18} {len(rates):>10,} {avg_rate:>11.1f}% {min_rate:>9.1f}% {max_rate:>9.1f}%") + print() + print(" (If cache fully recovers, later steps should approach 85%)") + print() + + # Per-session breakdown (if verbose or few sessions) + if verbose or len(results) <= 10: + if results: + print("PER-SESSION BREAKDOWN") + print("-" * 110) + print(f"{'Session':<25} {'Title':<30} {'User Msgs':>10} {'DCP Calls':>10} {'Avg Hit% Delta':>15}") + print("-" * 110) + + for r in results: + sid = r["session_id"][:24] + title = r["title"][:29] + user_msgs = r.get("user_messages", 0) + avg_delta = 0 + if r["dcp_events"]: + avg_delta = sum(e["hit_rate_delta"] for e in r["dcp_events"]) / len(r["dcp_events"]) + delta_str = f"{avg_delta:+.1f}%" + print(f"{sid:<25} {title:<30} {user_msgs:>10,} {r['total_dcp_calls']:>10,} {delta_str:>15}") + print() + + # Individual DCP events (only in verbose mode) + if verbose: + print("INDIVIDUAL DCP EVENTS") + print("-" * 110) + for r in results: + if r["dcp_events"]: + print(f"\n Session: {r['title'][:60]}") + for event in r["dcp_events"][:15]: + ctx_delta = f"{event['context_delta']:+,}" + hit_delta = f"{event['hit_rate_delta']:+.1f}%" + print(f" {event['tool']:<12} hit%: {event['hit_rate_before']:>5.1f} -> {event['hit_rate_after']:>5.1f} ({hit_delta:>7}) ctx: {event['context_before']:>8,} -> {event['context_after']:>8,} ({ctx_delta:>8})") + + print("=" * 110) + + +def main(): + parser = argparse.ArgumentParser(description="Analyze DCP tool cache impact") + parser.add_argument("--sessions", "-n", type=int, default=20, + help="Number of recent sessions to scan (default: 20)") + parser.add_argument("--session", "-s", type=str, default=None, + help="Analyze specific session ID") + parser.add_argument("--min-messages", "-m", type=int, default=5, + help="Minimum real user messages required (default: 5)") + parser.add_argument("--json", "-j", action="store_true", + help="Output as JSON") + parser.add_argument("--verbose", "-v", action="store_true", + help="Show detailed per-event breakdown") + 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 + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/opencode-find-session b/scripts/opencode-find-session new file mode 100755 index 00000000..5b7e2087 --- /dev/null +++ b/scripts/opencode-find-session @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +""" +Find OpenCode session IDs by title search. +Returns matching session IDs ordered by last usage time. + +Usage: opencode-find-session [--exact] [--json] +""" + +import json +import argparse +from pathlib import Path +from datetime import datetime + + +def get_all_sessions(storage: Path) -> list[dict]: + """Get all sessions with their metadata.""" + session_dir = storage / "session" + message_dir = storage / "message" + + if not session_dir.exists(): + return [] + + sessions = [] + + for app_dir in session_dir.iterdir(): + if not app_dir.is_dir(): + continue + + for session_file in app_dir.glob("*.json"): + try: + session = json.loads(session_file.read_text()) + session_id = session_file.stem + + # Get last modified time from message directory + msg_path = message_dir / session_id + if msg_path.exists(): + mtime = msg_path.stat().st_mtime + else: + mtime = session_file.stat().st_mtime + + sessions.append({ + "id": session_id, + "title": session.get("title", "Untitled"), + "created_at": session.get("createdAt"), + "last_used": mtime, + "last_used_iso": datetime.fromtimestamp(mtime).isoformat() + }) + except (json.JSONDecodeError, IOError): + pass + + return sessions + + +def search_sessions(sessions: list[dict], search_term: str, exact: bool = False) -> list[dict]: + """Search sessions by title.""" + results = [] + search_lower = search_term.lower() + + for session in sessions: + title = session.get("title", "") + title_lower = title.lower() + + if exact: + if title_lower == search_lower: + results.append(session) + else: + if search_lower in title_lower: + results.append(session) + + # Sort by last used time, most recent first + results.sort(key=lambda s: s["last_used"], reverse=True) + + return results + + +def print_results(results: list[dict], search_term: str): + """Print search results.""" + if not results: + print(f"No sessions found matching: {search_term}") + return + + if len(results) == 1: + # Single result - just print the ID for easy piping + print(results[0]["id"]) + else: + # Multiple results - show a table + print(f"Found {len(results)} sessions matching: {search_term}") + print() + print(f"{'Session ID':<32} {'Last Used':<20} {'Title'}") + print("-" * 100) + + for r in results: + last_used = datetime.fromtimestamp(r["last_used"]).strftime("%Y-%m-%d %H:%M") + title = r["title"][:50] if len(r["title"]) > 50 else r["title"] + print(f"{r['id']:<32} {last_used:<20} {title}") + + +def main(): + parser = argparse.ArgumentParser( + description="Find OpenCode session IDs by title search" + ) + parser.add_argument( + "search_term", + type=str, + help="Text to search for in session titles" + ) + parser.add_argument( + "--exact", "-e", + action="store_true", + help="Require exact title match (case-insensitive)" + ) + parser.add_argument( + "--json", "-j", + action="store_true", + help="Output as JSON" + ) + parser.add_argument( + "--all", "-a", + action="store_true", + help="Show all sessions (ignore search term)" + ) + args = parser.parse_args() + + storage = Path.home() / ".local/share/opencode/storage" + + if not storage.exists(): + print("Error: OpenCode storage not found at", storage) + return 1 + + sessions = get_all_sessions(storage) + + if args.all: + results = sorted(sessions, key=lambda s: s["last_used"], reverse=True) + else: + results = search_sessions(sessions, args.search_term, args.exact) + + if args.json: + print(json.dumps(results, indent=2, default=str)) + else: + print_results(results, args.search_term if not args.all else "(all)") + + return 0 + + +if __name__ == "__main__": + exit(main()) diff --git a/scripts/opencode-session-timeline b/scripts/opencode-session-timeline new file mode 100755 index 00000000..a3683cea --- /dev/null +++ b/scripts/opencode-session-timeline @@ -0,0 +1,412 @@ +#!/usr/bin/env python3 +""" +Analyze token values at each step within a single OpenCode session. +Shows cache growth over time and highlights DCP tool usage that causes cache drops. + +Usage: opencode-session-timeline [--session ID] [--json] [--no-color] +""" + +import json +import argparse +from pathlib import Path +from typing import Optional +from datetime import datetime + +# DCP tool names (tools that prune context and reduce cache) +DCP_TOOLS = { + "prune", "discard", "extract", "context_pruning", + "squash", "compress", "consolidate", "distill" +} + +# ANSI colors +class Colors: + RESET = "\033[0m" + BOLD = "\033[1m" + DIM = "\033[2m" + RED = "\033[31m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + BLUE = "\033[34m" + MAGENTA = "\033[35m" + CYAN = "\033[36m" + +NO_COLOR = Colors() +for attr in dir(NO_COLOR): + if not attr.startswith('_'): + setattr(NO_COLOR, attr, "") + + +def format_duration(ms: Optional[int], colors: Colors = None) -> str: + """Format milliseconds as human-readable duration.""" + if ms is None: + return "-" + + seconds = ms / 1000 + if seconds < 60: + return f"{seconds:.1f}s" + elif seconds < 3600: + minutes = int(seconds // 60) + secs = seconds % 60 + return f"{minutes}m{secs:.0f}s" + else: + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + return f"{hours}h{minutes}m" + + +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"] = str(msg_file) + msg["_id"] = msg_file.stem + # Extract timing info + time_info = msg.get("time", {}) + msg["_created"] = time_info.get("created") + msg["_completed"] = time_info.get("completed") + 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"] = str(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 extract_step_data(parts: list[dict]) -> Optional[dict]: + """Extract step-finish data and tool calls from message parts.""" + step_finish = None + tools_used = [] + dcp_tools_used = [] + + for part in parts: + if part.get("type") == "step-finish" and "tokens" in part: + step_finish = part + elif part.get("type") == "tool": + tool_name = part.get("tool", "") + tools_used.append(tool_name) + if tool_name in DCP_TOOLS: + dcp_tools_used.append(tool_name) + + if step_finish is None: + return None + + tokens = step_finish.get("tokens", {}) + cache = tokens.get("cache", {}) + + return { + "input": tokens.get("input", 0), + "output": tokens.get("output", 0), + "reasoning": tokens.get("reasoning", 0), + "cache_read": cache.get("read", 0), + "cache_write": cache.get("write", 0), + "cost": step_finish.get("cost", 0), + "reason": step_finish.get("reason", "unknown"), + "tools_used": tools_used, + "dcp_tools_used": dcp_tools_used, + "has_dcp": len(dcp_tools_used) > 0 + } + + +def get_most_recent_session(storage: Path) -> Optional[str]: + """Get the most recent session ID.""" + message_dir = storage / "message" + if not message_dir.exists(): + return None + + sessions = sorted(message_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True) + return sessions[0].name if sessions else None + + +def get_session_title(storage: Path, session_id: str) -> str: + """Get session title from metadata.""" + session_dir = storage / "session" + if not session_dir.exists(): + return "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()) + return sess.get("title", "Untitled") + except (json.JSONDecodeError, IOError): + pass + return "Unknown" + + +def analyze_session(storage: Path, session_id: str) -> dict: + """Analyze a single session step by step.""" + messages = get_session_messages(storage, session_id) + title = get_session_title(storage, session_id) + + steps = [] + for msg in messages: + msg_id = msg.get("_id", "") + parts = get_message_parts(storage, msg_id) + step_data = extract_step_data(parts) + + if step_data: + step_data["message_id"] = msg_id + step_data["created"] = msg.get("_created") + step_data["completed"] = msg.get("_completed") + steps.append(step_data) + + # Calculate deltas + for i, step in enumerate(steps): + if i == 0: + step["cache_read_delta"] = step["cache_read"] + step["input_delta"] = step["input"] + else: + prev = steps[i - 1] + step["cache_read_delta"] = step["cache_read"] - prev["cache_read"] + step["input_delta"] = step["input"] - prev["input"] + + # Calculate cache hit rate + total_context = step["input"] + step["cache_read"] + step["cache_hit_rate"] = (step["cache_read"] / total_context * 100) if total_context > 0 else 0 + + # Calculate step duration and time since previous step + created = step.get("created") + completed = step.get("completed") + + if created and completed: + step["duration_ms"] = completed - created + else: + step["duration_ms"] = None + + if i == 0: + step["time_since_prev_ms"] = None + else: + prev_completed = steps[i - 1].get("completed") + if prev_completed and created: + step["time_since_prev_ms"] = created - prev_completed + else: + step["time_since_prev_ms"] = None + + return { + "session_id": session_id, + "title": title, + "steps": steps, + "total_steps": len(steps) + } + + +def print_timeline(result: dict, colors: Colors): + """Print the step-by-step timeline.""" + c = colors + + print(f"{c.BOLD}{'=' * 130}{c.RESET}") + print(f"{c.BOLD}SESSION TIMELINE: Token Values at Each Step{c.RESET}") + print(f"{c.BOLD}{'=' * 130}{c.RESET}") + print() + print(f" Session: {c.CYAN}{result['session_id']}{c.RESET}") + print(f" Title: {result['title']}") + print(f" Steps: {result['total_steps']}") + print() + + if not result["steps"]: + print(" No steps found in this session.") + return + + # Header + print(f"{c.BOLD}{'Step':<6} {'Cache Read':>12} {'Δ Cache':>12} {'Input':>10} {'Output':>10} {'Cache %':>9} {'Duration':>10} {'Gap':>10} {'DCP Tools':<15} {'Reason':<12}{c.RESET}") + print("-" * 130) + + prev_cache = 0 + for i, step in enumerate(result["steps"], 1): + cache_read = step["cache_read"] + cache_delta = step["cache_read_delta"] + input_tokens = step["input"] + output_tokens = step["output"] + cache_pct = step["cache_hit_rate"] + has_dcp = step["has_dcp"] + dcp_tools = step["dcp_tools_used"] + reason = step["reason"] + + # Color the delta based on direction + if cache_delta > 0: + delta_str = f"{c.GREEN}+{cache_delta:,}{c.RESET}" + elif cache_delta < 0: + delta_str = f"{c.RED}{cache_delta:,}{c.RESET}" + else: + delta_str = f"{c.DIM}0{c.RESET}" + + # Pad delta string for alignment (accounting for color codes) + delta_display = f"{cache_delta:+,}" if cache_delta != 0 else "0" + delta_padded = f"{delta_str:>22}" if cache_delta != 0 else f"{c.DIM}{'0':>12}{c.RESET}" + + # Highlight DCP rows + if has_dcp: + row_prefix = f"{c.YELLOW}{c.BOLD}" + row_suffix = c.RESET + dcp_str = f"{c.YELLOW}{', '.join(dcp_tools)}{c.RESET}" + else: + row_prefix = "" + row_suffix = "" + dcp_str = f"{c.DIM}-{c.RESET}" + + # Cache percentage coloring + if cache_pct >= 80: + pct_str = f"{c.GREEN}{cache_pct:>8.1f}%{c.RESET}" + elif cache_pct >= 50: + pct_str = f"{c.YELLOW}{cache_pct:>8.1f}%{c.RESET}" + else: + pct_str = f"{c.RED}{cache_pct:>8.1f}%{c.RESET}" + + # Format delta with proper width + if cache_delta > 0: + delta_formatted = f"{c.GREEN}{'+' + f'{cache_delta:,}':>11}{c.RESET}" + elif cache_delta < 0: + delta_formatted = f"{c.RED}{f'{cache_delta:,}':>12}{c.RESET}" + else: + delta_formatted = f"{c.DIM}{'0':>12}{c.RESET}" + + print(f"{row_prefix}{i:<6}{row_suffix} {cache_read:>12,} {delta_formatted} {input_tokens:>10,} {output_tokens:>10,} {pct_str} {format_duration(step.get('duration_ms')):>10} {format_duration(step.get('time_since_prev_ms')):>10} {dcp_str:<15} {reason:<12}") + + prev_cache = cache_read + + print("-" * 130) + print() + + # Summary statistics + steps = result["steps"] + 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) + + dcp_steps = [s for s in steps if s["has_dcp"]] + cache_increases = [s for s in steps if s["cache_read_delta"] > 0] + cache_decreases = [s for s in steps if s["cache_read_delta"] < 0] + + # Overall cache hit rate + total_context = total_input + total_cache_read + overall_cache_rate = (total_cache_read / total_context * 100) if total_context > 0 else 0 + + print(f"{c.BOLD}CACHE BEHAVIOR SUMMARY{c.RESET}") + print("-" * 50) + + # Overall cache hit rate with coloring + if overall_cache_rate >= 80: + rate_str = f"{c.GREEN}{overall_cache_rate:.1f}%{c.RESET}" + elif overall_cache_rate >= 50: + rate_str = f"{c.YELLOW}{overall_cache_rate:.1f}%{c.RESET}" + else: + rate_str = f"{c.RED}{overall_cache_rate:.1f}%{c.RESET}" + + print(f" {c.BOLD}Overall cache hit rate: {rate_str}{c.RESET}") + print(f" Total input tokens: {total_input:>12,}") + print(f" Total cache read tokens: {total_cache_read:>12,}") + print() + print(f" Steps with cache increase: {c.GREEN}{len(cache_increases):>5}{c.RESET}") + print(f" Steps with cache decrease: {c.RED}{len(cache_decreases):>5}{c.RESET}") + print(f" Steps with DCP tools: {c.YELLOW}{len(dcp_steps):>5}{c.RESET}") + print() + + if dcp_steps: + dcp_decreases = [s for s in dcp_steps if s["cache_read_delta"] < 0] + print(f" DCP steps with cache drop: {len(dcp_decreases)}/{len(dcp_steps)}") + if dcp_decreases: + avg_drop = sum(s["cache_read_delta"] for s in dcp_decreases) / len(dcp_decreases) + print(f" Avg cache drop on DCP: {c.RED}{avg_drop:,.0f}{c.RESET} tokens") + + print() + + # Cache growth verification + if len(steps) >= 2: + first_cache = steps[0]["cache_read"] + last_cache = steps[-1]["cache_read"] + max_cache = max(s["cache_read"] for s in steps) + + print(f"{c.BOLD}CACHE GROWTH VERIFICATION{c.RESET}") + print("-" * 50) + print(f" First step cache read: {first_cache:>12,}") + print(f" Last step cache read: {last_cache:>12,}") + print(f" Max cache read observed: {max_cache:>12,}") + + if last_cache > first_cache: + growth = last_cache - first_cache + print(f" Net cache growth: {c.GREEN}+{growth:>11,}{c.RESET}") + print(f"\n {c.GREEN}✓ Provider caching appears to be working{c.RESET}") + elif last_cache < first_cache: + loss = first_cache - last_cache + print(f" Net cache loss: {c.RED}-{loss:>11,}{c.RESET}") + if dcp_steps: + print(f"\n {c.YELLOW}⚠ Cache decreased (likely due to DCP pruning){c.RESET}") + else: + print(f"\n {c.RED}⚠ Cache decreased without DCP - investigate{c.RESET}") + else: + print(f"\n {c.DIM}Cache unchanged between first and last step{c.RESET}") + + print() + print(f"{c.BOLD}{'=' * 130}{c.RESET}") + + +def main(): + parser = argparse.ArgumentParser( + description="Analyze token values at each step within an OpenCode session" + ) + parser.add_argument( + "--session", "-s", type=str, default=None, + help="Session ID to analyze (default: most recent)" + ) + parser.add_argument( + "--json", "-j", action="store_true", + help="Output as JSON" + ) + parser.add_argument( + "--no-color", action="store_true", + help="Disable colored output" + ) + args = parser.parse_args() + + storage = Path.home() / ".local/share/opencode/storage" + + if not storage.exists(): + print("Error: OpenCode storage not found at", storage) + return 1 + + session_id = args.session + if session_id is None: + session_id = get_most_recent_session(storage) + if session_id is None: + print("Error: No sessions found") + return 1 + + result = analyze_session(storage, session_id) + + if args.json: + # Remove non-serializable fields + print(json.dumps(result, indent=2, default=str)) + else: + colors = NO_COLOR if args.no_color else Colors() + print_timeline(result, colors) + + return 0 + + +if __name__ == "__main__": + exit(main()) diff --git a/scripts/opencode-token-stats b/scripts/opencode-token-stats new file mode 100755 index 00000000..3a7d6dba --- /dev/null +++ b/scripts/opencode-token-stats @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +Analyze token usage across recent OpenCode sessions. +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 + + # 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] + else: + # Get recent sessions sorted by modification time + sessions = sorted(message_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True)[:num_sessions] + + results = [] + grand_totals = { + "input": 0, "output": 0, "reasoning": 0, + "cache_read": 0, "cache_write": 0, + "steps": 0, "sessions": 0, + "reasons": {"tool-calls": 0, "stop": 0, "other": 0} + } + + for session_path in sessions: + session_id = session_path.name + totals = { + "input": 0, "output": 0, "reasoning": 0, + "cache_read": 0, "cache_write": 0, + "cost": 0.0, "steps": 0, + "reasons": {"tool-calls": 0, "stop": 0, "other": 0} + } + + # 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 + + # 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 + + # Calculate derived metrics + total_tokens = totals["input"] + totals["output"] + totals["cache_read"] + cache_hit_rate = (totals["cache_read"] / (totals["input"] + totals["cache_read"]) * 100) if (totals["input"] + totals["cache_read"]) > 0 else 0 + + session_result = { + "session_id": session_id, + "title": title, + "created": created, + "steps": totals["steps"], + "tokens": { + "input": totals["input"], + "output": totals["output"], + "reasoning": totals["reasoning"], + "cache_read": totals["cache_read"], + "cache_write": totals["cache_write"], + "total": total_tokens + }, + "cost": totals["cost"], + "cache_hit_rate": round(cache_hit_rate, 1), + "finish_reasons": totals["reasons"] + } + results.append(session_result) + + # Update grand totals + grand_totals["input"] += totals["input"] + grand_totals["output"] += totals["output"] + grand_totals["reasoning"] += totals["reasoning"] + grand_totals["cache_read"] += totals["cache_read"] + grand_totals["cache_write"] += totals["cache_write"] + grand_totals["steps"] += totals["steps"] + grand_totals["sessions"] += 1 + for reason, count in totals["reasons"].items(): + grand_totals["reasons"][reason] += count + + # Output + if output_json: + output = { + "sessions": results, + "totals": grand_totals, + "generated_at": datetime.now().isoformat() + } + print(json.dumps(output, indent=2)) + else: + print_summary(results, grand_totals) + +def print_summary(results, grand_totals): + print("=" * 120) + print("OPENCODE SESSION TOKEN ANALYSIS") + print("=" * 120) + print() + + # Per-session breakdown + print(f"{'Session':<25} {'Title':<30} {'Steps':>6} {'Input':>12} {'Output':>10} {'Reasoning':>10} {'Cache Read':>12} {'Cache Write':>12} {'Cache %':>8}") + print("-" * 120) + + for r in results: + t = r["tokens"] + print(f"{r['session_id'][:24]:<25} {r['title'][:29]:<30} {r['steps']:>6} {t['input']:>12,} {t['output']:>10,} {t['reasoning']:>10,} {t['cache_read']:>12,} {t['cache_write']:>12,} {r['cache_hit_rate']:>7.1f}%") + + print("-" * 120) + print() + + # Grand totals + total_all = grand_totals["input"] + grand_totals["output"] + grand_totals["cache_read"] + overall_cache_rate = (grand_totals["cache_read"] / (grand_totals["input"] + grand_totals["cache_read"]) * 100) if (grand_totals["input"] + grand_totals["cache_read"]) > 0 else 0 + + print("TOTALS ACROSS ALL SESSIONS") + print("-" * 50) + print(f" Sessions analyzed: {grand_totals['sessions']:>15,}") + print(f" Total steps: {grand_totals['steps']:>15,}") + avg_steps = grand_totals['steps'] / grand_totals['sessions'] if grand_totals['sessions'] > 0 else 0 + print(f" Avg steps/session: {avg_steps:>15.1f}") + print() + print(" TOKEN BREAKDOWN:") + print(f" Input tokens: {grand_totals['input']:>15,}") + print(f" Output tokens: {grand_totals['output']:>15,}") + print(f" Reasoning tokens: {grand_totals['reasoning']:>15,}") + print(f" Cache read: {grand_totals['cache_read']:>15,}") + print(f" Cache write: {grand_totals['cache_write']:>15,}") + print(f" ─────────────────────────────────────────────") + print(f" TOTAL: {total_all:>15,}") + print() + print(f" Overall cache hit rate: {overall_cache_rate:.1f}%") + print() + print(" STEP FINISH REASONS:") + for reason, count in grand_totals["reasons"].items(): + if count > 0: + print(f" {reason}: {count:>10,}") + print() + print("=" * 120) + +def main(): + parser = argparse.ArgumentParser(description="Analyze OpenCode session token usage") + 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") + args = parser.parse_args() + + analyze_sessions(num_sessions=args.sessions, output_json=args.json, session_id=args.session) + +if __name__ == "__main__": + main() From 16c8f25ee3a43107b6477a27e6834999bb3b588e Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 2 Feb 2026 20:32:52 -0500 Subject: [PATCH 060/113] test: add DCP cache testing script --- tests/test-dcp-cache.sh | 364 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100755 tests/test-dcp-cache.sh diff --git a/tests/test-dcp-cache.sh b/tests/test-dcp-cache.sh new file mode 100755 index 00000000..49e7dfac --- /dev/null +++ b/tests/test-dcp-cache.sh @@ -0,0 +1,364 @@ +#!/usr/bin/env bash +# +# DCP Token Cache Test Script +# Tests how Dynamic Context Pruning affects token caching across different providers/models +# +# Usage: +# ./test-dcp-cache.sh [OPTIONS] +# +# Options: +# --provider NAME Run test for specific provider only +# --dry-run Show what would be executed without running +# --results-dir DIR Custom results directory (default: ./results) +# --port PORT Port for server (default: 4096, enables TUI attach) +# --no-server Don't start a server, use standalone mode (no TUI attach) +# --help Show this help message +# +# To watch tests in real-time: +# Terminal 1: ./test-dcp-cache.sh --provider anthropic +# Terminal 2: opencode attach http://localhost:4096 +# + +set -euo pipefail + +# ============================================================================ +# CONFIGURATION - Modify these to change which models are tested +# ============================================================================ + +# Models to test: one per provider +# Format: ["provider-name"]="provider/model-id" +declare -A MODELS=( + ["opencode-kimi"]="opencode/kimi-k2.5-free" + ["kimi"]="kimi-for-coding/k2p5" + ["llm-proxy-cli-gemini"]="llm-proxy/cli_gemini-3-flash-high" + ["llm-proxy-ant-gemini"]="llm-proxy/ant_gemini-3-flash-high" + ["llm-proxy-opus"]="llm-proxy/claude-opus-4-5-thinking" + ["openai"]="openai/gpt-5.2-codex" + ["openrouter-haiku"]="openrouter/anthropic/claude-haiku-4.5" +) + +# Codebases to analyze (ordered from simple to complex) +# Format: "clone_command|description" +CODEBASES=( + "git clone --depth 1 https://github.com/sindresorhus/is-odd.git|is-odd: minimal npm package (~10 lines)" + "git clone --depth 1 https://github.com/chalk/chalk.git|chalk: small terminal styling utility" + "git clone --depth 1 https://github.com/tj/commander.js.git|commander: medium-complexity CLI framework" + "git clone --depth 1 https://github.com/yargs/yargs.git|yargs: medium-complex argument parser" + "cp -r ~/.config/opencode/opencode|opencode: full-featured coding assistant (local copy)" +) + +# Base prompt template - {CODEBASE_CMD} and {CODEBASE_DESC} will be replaced +PROMPT_TEMPLATE='Clone/copy {CODEBASE_DESC} to /tmp/{CODEBASE_NAME} and give me a comprehensive summary of what it does and how it works. Analyze the directory structure, key files, main functionality, and architecture. Do not use subagents.' + +# ============================================================================ +# SCRIPT LOGIC - Generally no need to modify below +# ============================================================================ + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPTS_DIR="${SCRIPT_DIR}/../scripts" +RESULTS_DIR="${SCRIPT_DIR}/results" +DRY_RUN=false +SPECIFIC_PROVIDER="" +SERVER_PORT="4096" +USE_SERVER=true +SERVER_PID="" + +cleanup() { + if [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then + log "Stopping opencode server (PID: $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +usage() { + head -20 "$0" | tail -18 | sed 's/^# \?//' + echo "" + echo "Configured models:" + for provider in "${!MODELS[@]}"; do + echo " $provider: ${MODELS[$provider]}" + done +} + +log() { + echo "[$(date '+%H:%M:%S')] $*" +} + +log_section() { + echo "" + echo "============================================================================" + echo "$*" + echo "============================================================================" +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --provider) + SPECIFIC_PROVIDER="$2" + shift 2 + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --results-dir) + RESULTS_DIR="$2" + shift 2 + ;; + --port) + SERVER_PORT="$2" + shift 2 + ;; + --no-server) + USE_SERVER=false + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" + usage + exit 1 + ;; + esac +done + +# Create timestamp for this test run +TIMESTAMP=$(date '+%Y%m%d_%H%M%S') +RUN_DIR="${RESULTS_DIR}/${TIMESTAMP}" + +# Validate specific provider if given +if [[ -n "$SPECIFIC_PROVIDER" ]] && [[ -z "${MODELS[$SPECIFIC_PROVIDER]:-}" ]]; then + echo "Error: Unknown provider '$SPECIFIC_PROVIDER'" + echo "Available providers: ${!MODELS[*]}" + exit 1 +fi + +# Build list of providers to test +if [[ -n "$SPECIFIC_PROVIDER" ]]; then + PROVIDERS=("$SPECIFIC_PROVIDER") +else + PROVIDERS=("${!MODELS[@]}") +fi + +log_section "DCP Token Cache Test" +log "Results directory: ${RUN_DIR}" +log "Providers to test: ${PROVIDERS[*]}" +log "Codebases: ${#CODEBASES[@]}" +log "Dry run: ${DRY_RUN}" + +if [[ "$USE_SERVER" == "true" ]]; then + log "Server port: ${SERVER_PORT}" + log "" + log ">>> To watch in real-time, run in another terminal:" + log ">>> opencode attach http://localhost:${SERVER_PORT}" +else + log "Server mode: disabled (standalone runs, no TUI attach)" +fi + +if [[ "$DRY_RUN" == "true" ]]; then + log_section "DRY RUN - Commands that would be executed" +fi + +# Create results directory +if [[ "$DRY_RUN" == "false" ]]; then + mkdir -p "$RUN_DIR" + # Save test configuration (simple approach without complex jq) + { + echo "{" + echo " \"timestamp\": \"${TIMESTAMP}\"," + echo " \"providers\": [\"${PROVIDERS[*]// /\", \"}\"]," + echo " \"codebases\": ${#CODEBASES[@]}," + echo " \"server_port\": ${SERVER_PORT:-null}" + echo "}" + } > "${RUN_DIR}/config.json" +fi + +# Start server if requested +if [[ "$DRY_RUN" == "false" ]] && [[ "$USE_SERVER" == "true" ]]; then + log "" + + # Check if port is already in use + if lsof -i ":${SERVER_PORT}" &>/dev/null; then + log "Port ${SERVER_PORT} is already in use." + read -p "Kill existing process and continue? [y/N] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + log "Killing process on port ${SERVER_PORT}..." + fuser -k "${SERVER_PORT}/tcp" 2>/dev/null || lsof -ti ":${SERVER_PORT}" | xargs -r kill -9 + sleep 1 + else + log "Aborting. Free port ${SERVER_PORT} or use --port to specify a different port." + exit 1 + fi + fi + + log "Starting opencode server on port ${SERVER_PORT}..." + opencode serve --port "$SERVER_PORT" & + SERVER_PID=$! + + # Wait for server to be ready + sleep 2 + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + log "Error: Server failed to start" + exit 1 + fi + log "Server started (PID: $SERVER_PID)" +fi + +# Build base command depending on server mode +if [[ "$USE_SERVER" == "true" ]]; then + BASE_CMD="opencode run --attach http://localhost:${SERVER_PORT}" +else + BASE_CMD="opencode run" +fi + +# Run tests for each provider +for provider in "${PROVIDERS[@]}"; do + model="${MODELS[$provider]}" + provider_dir="${RUN_DIR}/${provider}" + + log_section "Testing Provider: ${provider}" + log "Model: ${model}" + + if [[ "$DRY_RUN" == "false" ]]; then + mkdir -p "$provider_dir" + fi + + SESSION_ID="" + PROMPT_NUM=0 + + for codebase_entry in "${CODEBASES[@]}"; do + PROMPT_NUM=$((PROMPT_NUM + 1)) + + # Parse codebase entry + IFS='|' read -r clone_cmd codebase_desc <<< "$codebase_entry" + codebase_name=$(echo "$clone_cmd" | grep -oE '[^/]+\.git$' | sed 's/\.git$//' || echo "$clone_cmd" | awk '{print $NF}') + + # Build the prompt + prompt="${PROMPT_TEMPLATE}" + prompt="${prompt//\{CODEBASE_CMD\}/$clone_cmd}" + prompt="${prompt//\{CODEBASE_DESC\}/$codebase_desc}" + prompt="${prompt//\{CODEBASE_NAME\}/$codebase_name}" + + log "" + log "Prompt ${PROMPT_NUM}/${#CODEBASES[@]}: ${codebase_desc}" + + # Build opencode command (for display only, actual execution below) + if [[ -z "$SESSION_ID" ]]; then + # First prompt - create new session + display_cmd="${BASE_CMD} -m '${model}' --title 'DCP Test: ${provider}' ''" + else + # Subsequent prompts - continue session + display_cmd="${BASE_CMD} -m '${model}' --session '${SESSION_ID}' ''" + fi + + if [[ "$DRY_RUN" == "true" ]]; then + echo " $ $display_cmd" + # Simulate session ID for dry run + if [[ -z "$SESSION_ID" ]]; then + SESSION_ID="dry-run-session-id" + fi + else + log "Executing: $display_cmd" + + # Run opencode and capture output + output_file="${provider_dir}/prompt_${PROMPT_NUM}_output.txt" + json_file="${provider_dir}/prompt_${PROMPT_NUM}_events.json" + + if [[ -z "$SESSION_ID" ]]; then + # First run - use --format json to capture session ID from events + log "Using JSON format to capture session ID..." + if [[ "$USE_SERVER" == "true" ]]; then + opencode run --attach "http://localhost:${SERVER_PORT}" \ + -m "${model}" \ + --title "DCP Test: ${provider}" \ + --format json \ + "${prompt}" 2>&1 | tee "$json_file" + else + opencode run \ + -m "${model}" \ + --title "DCP Test: ${provider}" \ + --format json \ + "${prompt}" 2>&1 | tee "$json_file" + fi + + # Extract session ID from the first JSON event + SESSION_ID=$(head -1 "$json_file" | jq -r '.sessionID // empty' 2>/dev/null || echo "") + + if [[ -z "$SESSION_ID" ]]; then + log "Warning: Could not extract session ID from JSON output" + # Fallback to opencode-find-session + log "Falling back to session search..." + SESSION_ID=$("${SCRIPTS_DIR}/opencode-find-session" "DCP Test: ${provider}" 2>/dev/null | head -1 || echo "") + fi + + if [[ -z "$SESSION_ID" ]]; then + log "Error: Could not find session ID, cannot continue session" + log "Will create new sessions for each prompt (cache test will be less meaningful)" + fi + + log "Session ID: ${SESSION_ID:-unknown}" + echo "$SESSION_ID" > "${provider_dir}/session_id.txt" + else + # Subsequent prompts - continue session with normal output + if [[ "$USE_SERVER" == "true" ]]; then + opencode run --attach "http://localhost:${SERVER_PORT}" \ + -m "${model}" \ + --session "${SESSION_ID}" \ + "${prompt}" 2>&1 | tee "$output_file" + else + opencode run \ + -m "${model}" \ + --session "${SESSION_ID}" \ + "${prompt}" 2>&1 | tee "$output_file" + fi + fi + + log "Output saved to: ${output_file:-$json_file}" + fi + done + + # Collect analysis after all prompts for this provider + if [[ "$DRY_RUN" == "false" ]] && [[ -n "$SESSION_ID" ]]; then + log "" + log "Collecting cache analysis for session ${SESSION_ID}..." + + # Session timeline + "${SCRIPTS_DIR}/opencode-session-timeline" --session "$SESSION_ID" --no-color > "${provider_dir}/session_timeline.txt" 2>&1 || true + "${SCRIPTS_DIR}/opencode-session-timeline" --session "$SESSION_ID" --json > "${provider_dir}/session_timeline.json" 2>&1 || true + + # Token stats + "${SCRIPTS_DIR}/opencode-token-stats" --session "$SESSION_ID" > "${provider_dir}/token_stats.txt" 2>&1 || true + "${SCRIPTS_DIR}/opencode-token-stats" --session "$SESSION_ID" --json > "${provider_dir}/token_stats.json" 2>&1 || true + + # DCP stats + "${SCRIPTS_DIR}/opencode-dcp-stats" --session "$SESSION_ID" > "${provider_dir}/dcp_stats.txt" 2>&1 || true + "${SCRIPTS_DIR}/opencode-dcp-stats" --session "$SESSION_ID" --json > "${provider_dir}/dcp_stats.json" 2>&1 || true + + log "Analysis saved to: ${provider_dir}/" + elif [[ "$DRY_RUN" == "true" ]]; then + echo "" + echo " # After session completes:" + echo " $ ${SCRIPTS_DIR}/opencode-session-timeline --session \$SESSION_ID > ${provider_dir}/session_timeline.txt" + echo " $ ${SCRIPTS_DIR}/opencode-token-stats --session \$SESSION_ID > ${provider_dir}/token_stats.txt" + echo " $ ${SCRIPTS_DIR}/opencode-dcp-stats --session \$SESSION_ID > ${provider_dir}/dcp_stats.txt" + fi +done + +log_section "Test Complete" +if [[ "$DRY_RUN" == "false" ]]; then + log "Results saved to: ${RUN_DIR}" + log "" + log "To view results:" + echo " ls -la ${RUN_DIR}/" + for provider in "${PROVIDERS[@]}"; do + echo " cat ${RUN_DIR}/${provider}/session_timeline.txt" + done +else + log "Dry run complete. Run without --dry-run to execute tests." +fi From 1a3b7f35ce6f33c97541158548617c6126522ca5 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 2 Feb 2026 23:52:16 -0500 Subject: [PATCH 061/113] refactor: simplify context injection to use tool parts universally Remove model-specific branching (DeepSeek/Kimi check) and unused helper functions. Now all non-user-message injections use synthetic tool parts appended to the last message, eliminating ~100 lines of code. --- lib/messages/inject.ts | 36 +++----------- lib/messages/utils.ts | 109 ----------------------------------------- 2 files changed, 6 insertions(+), 139 deletions(-) diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index e1dc1206..7035b8eb 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -8,10 +8,7 @@ import { buildToolIdList, createSyntheticTextPart, createSyntheticToolPart, - createSyntheticAssistantMessage, - createSyntheticAssistantMessageWithToolPart, isIgnoredUserMessage, - isDeepSeekOrKimi, } from "./utils" import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" import { getLastUserMessage, isMessageCompacted } from "../shared-utils" @@ -195,37 +192,16 @@ export const insertPruneToolContext = ( return } - // It's not safe to inject assistant role messages following a user message as models such - // as Claude expect the assistant "turn" to start with reasoning parts. Reasoning parts in many - // cases also cannot be faked as they may be encrypted by the model. - // Gemini only accepts synth reasoning text if it is "skip_thought_signature_validator" + // 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.parts.push(textPart) } else { - // For non-user message case: push a new synthetic assistant message or append tool part - // for DeepSeek/Kimi. DeepSeek and Kimi don't output reasoning parts following an - // assistant injection containing text parts. Tool parts appended to the last assistant - // message are the safest way to inject context without disrupting model behavior. - const providerID = userInfo.model?.providerID || "" const modelID = userInfo.model?.modelID || "" - - if (isDeepSeekOrKimi(providerID, modelID)) { - const toolPart = createSyntheticToolPart( - lastNonIgnoredMessage, - combinedContent, - modelID, - ) - lastNonIgnoredMessage.parts.push(toolPart) - } else { - messages.push( - createSyntheticAssistantMessageWithToolPart( - lastUserMessage, - combinedContent, - modelID, - variant, - ), - ) - } + const toolPart = createSyntheticToolPart(lastNonIgnoredMessage, combinedContent, modelID) + lastNonIgnoredMessage.parts.push(toolPart) } } diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index 3dd08265..869735c1 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -13,17 +13,6 @@ const isGeminiModel = (modelID: string): boolean => { return lowerModelID.includes("gemini") } -export const isDeepSeekOrKimi = (providerID: string, modelID: string): boolean => { - const lowerProviderID = providerID.toLowerCase() - const lowerModelID = modelID.toLowerCase() - return ( - lowerProviderID.includes("deepseek") || - lowerProviderID.includes("kimi") || - lowerModelID.includes("deepseek") || - lowerModelID.includes("kimi") - ) -} - export const createSyntheticUserMessage = ( baseMessage: WithParts, content: string, @@ -56,104 +45,6 @@ export const createSyntheticUserMessage = ( } } -export const createSyntheticAssistantMessage = ( - baseMessage: WithParts, - content: string, - variant?: string, -): WithParts => { - const userInfo = baseMessage.info as UserMessage - const now = Date.now() - const messageId = generateUniqueId("msg") - const partId = generateUniqueId("prt") - - return { - info: { - id: messageId, - sessionID: userInfo.sessionID, - role: "assistant" as const, - agent: userInfo.agent || "code", - parentID: userInfo.id, - modelID: userInfo.model.modelID, - providerID: userInfo.model.providerID, - mode: "default", - path: { - cwd: "/", - root: "/", - }, - time: { created: now, completed: now }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - ...(variant !== undefined && { variant }), - }, - parts: [ - { - id: partId, - sessionID: userInfo.sessionID, - messageID: messageId, - type: "text" as const, - text: content, - }, - ], - } -} - -export const createSyntheticAssistantMessageWithToolPart = ( - baseMessage: WithParts, - content: string, - modelID: string, - variant?: string, -): WithParts => { - const userInfo = baseMessage.info as UserMessage - const now = Date.now() - const messageId = generateUniqueId("msg") - const partId = generateUniqueId("prt") - const callId = generateUniqueId("call") - - // Gemini requires thoughtSignature bypass to accept synthetic tool parts - const toolPartMetadata = isGeminiModel(modelID) - ? { google: { thoughtSignature: "skip_thought_signature_validator" } } - : {} - - return { - info: { - id: messageId, - sessionID: userInfo.sessionID, - role: "assistant" as const, - agent: userInfo.agent || "code", - parentID: userInfo.id, - modelID: userInfo.model.modelID, - providerID: userInfo.model.providerID, - mode: "default", - path: { - cwd: "/", - root: "/", - }, - time: { created: now, completed: now }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - ...(variant !== undefined && { variant }), - }, - parts: [ - { - id: partId, - sessionID: userInfo.sessionID, - messageID: messageId, - type: "tool" as const, - callID: callId, - tool: "context_info", - state: { - status: "completed" as const, - input: {}, - output: content, - title: "Context Info", - metadata: toolPartMetadata, - time: { start: now, end: now }, - }, - }, - ], - } -} - export const createSyntheticTextPart = (baseMessage: WithParts, content: string) => { const userInfo = baseMessage.info as UserMessage const partId = generateUniqueId("prt") From cbbf60ada390b28443766b42db38c970bc0f2a58 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 2 Feb 2026 23:56:28 -0500 Subject: [PATCH 062/113] chore: remove unused variant variable from inject.ts --- lib/messages/inject.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 7035b8eb..09113678 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -182,7 +182,6 @@ export const insertPruneToolContext = ( } const userInfo = lastUserMessage.info as UserMessage - const variant = state.variant ?? userInfo.variant const lastNonIgnoredMessage = messages.findLast( (msg) => !(msg.info.role === "user" && isIgnoredUserMessage(msg)), From e5b20381e7e427ff08b641a1a765d228d31b3fc9 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 3 Feb 2026 01:38:35 -0500 Subject: [PATCH 063/113] feat: add token counting for prunable tools --- lib/state/tool-cache.ts | 10 +++- lib/state/types.ts | 2 + lib/strategies/utils.ts | 100 +++++++++++++++++++++++++++------------- 3 files changed, 79 insertions(+), 33 deletions(-) diff --git a/lib/state/tool-cache.ts b/lib/state/tool-cache.ts index 5875375f..a11d9bdd 100644 --- a/lib/state/tool-cache.ts +++ b/lib/state/tool-cache.ts @@ -2,6 +2,7 @@ import type { SessionState, ToolStatus, WithParts } from "./index" import type { Logger } from "../logger" import { PluginConfig } from "../config" import { isMessageCompacted } from "../shared-utils" +import { countToolTokens } from "../strategies/utils" const MAX_TOOL_CACHE_SIZE = 1000 @@ -62,14 +63,21 @@ export async function syncToolCache( continue } + const allProtectedTools = config.tools.settings.protectedTools + const isProtectedTool = allProtectedTools.includes(part.tool) + const tokenCount = isProtectedTool ? undefined : countToolTokens(part) + state.toolParameters.set(part.callID, { tool: part.tool, parameters: part.state?.input ?? {}, status: part.state.status as ToolStatus | undefined, error: part.state.status === "error" ? part.state.error : undefined, turn: turnCounter, + tokenCount, }) - logger.info(`Cached tool id: ${part.callID} (created on turn ${turnCounter})`) + logger.info( + `Cached tool id: ${part.callID} (turn ${turnCounter}${tokenCount !== undefined ? `, ~${tokenCount} tokens` : ""})`, + ) } } diff --git a/lib/state/types.ts b/lib/state/types.ts index 7d5d8494..ec42dd10 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -13,6 +13,7 @@ export interface ToolParameterEntry { status?: ToolStatus error?: string turn: number + tokenCount?: number } export interface SessionStats { @@ -42,4 +43,5 @@ export interface SessionState { lastCompaction: number currentTurn: number variant: string | undefined + modelContextLimit: number | undefined } diff --git a/lib/strategies/utils.ts b/lib/strategies/utils.ts index d8b05a2e..d89bb730 100644 --- a/lib/strategies/utils.ts +++ b/lib/strategies/utils.ts @@ -1,9 +1,31 @@ import { SessionState, WithParts } from "../state" -import { UserMessage } from "@opencode-ai/sdk/v2" +import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" import { Logger } from "../logger" import { countTokens as anthropicCountTokens } from "@anthropic-ai/tokenizer" import { getLastUserMessage, isMessageCompacted } from "../shared-utils" +/** + * Get current token usage from the last assistant message. + * Returns total tokens (input + output + reasoning + cache). + */ +export function getCurrentTokenUsage(messages: WithParts[]): number { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.info.role === "assistant") { + const assistantInfo = msg.info as AssistantMessage + if (assistantInfo.tokens?.output > 0) { + const input = assistantInfo.tokens?.input || 0 + const output = assistantInfo.tokens?.output || 0 + const reasoning = assistantInfo.tokens?.reasoning || 0 + const cacheRead = assistantInfo.tokens?.cache?.read || 0 + const cacheWrite = assistantInfo.tokens?.cache?.write || 0 + return input + output + reasoning + cacheRead + cacheWrite + } + } + } + return 0 +} + export function getCurrentParams( state: SessionState, messages: WithParts[], @@ -47,6 +69,50 @@ export function estimateTokensBatch(texts: string[]): number { return countTokens(texts.join(" ")) } +export function extractToolContent(part: any): string[] { + const contents: string[] = [] + + if (part.tool === "question") { + const questions = part.state?.input?.questions + if (questions !== undefined) { + const content = typeof questions === "string" ? questions : JSON.stringify(questions) + contents.push(content) + } + return contents + } + + if (part.tool === "edit" || part.tool === "write") { + if (part.state?.input) { + const inputContent = + typeof part.state.input === "string" + ? part.state.input + : JSON.stringify(part.state.input) + contents.push(inputContent) + } + } + + if (part.state?.status === "completed" && part.state?.output) { + const content = + typeof part.state.output === "string" + ? part.state.output + : JSON.stringify(part.state.output) + contents.push(content) + } else if (part.state?.status === "error" && part.state?.error) { + const content = + typeof part.state.error === "string" + ? part.state.error + : JSON.stringify(part.state.error) + contents.push(content) + } + + return contents +} + +export function countToolTokens(part: any): number { + const contents = extractToolContent(part) + return estimateTokensBatch(contents) +} + export const calculateTokensSaved = ( state: SessionState, messages: WithParts[], @@ -63,37 +129,7 @@ export const calculateTokensSaved = ( if (part.type !== "tool" || !pruneToolIds.includes(part.callID)) { continue } - if (part.tool === "question") { - const questions = part.state.input?.questions - if (questions !== undefined) { - const content = - typeof questions === "string" ? questions : JSON.stringify(questions) - contents.push(content) - } - continue - } - if (part.tool === "edit" || part.tool === "write") { - if (part.state.input) { - const inputContent = - typeof part.state.input === "string" - ? part.state.input - : JSON.stringify(part.state.input) - contents.push(inputContent) - } - } - if (part.state.status === "completed") { - const content = - typeof part.state.output === "string" - ? part.state.output - : JSON.stringify(part.state.output) - contents.push(content) - } else if (part.state.status === "error") { - const content = - typeof part.state.error === "string" - ? part.state.error - : JSON.stringify(part.state.error) - contents.push(content) - } + contents.push(...extractToolContent(part)) } } return estimateTokensBatch(contents) From 7304f0a1a63dfb1f7c889d8ee49281b7ca9a10eb Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 3 Feb 2026 01:38:40 -0500 Subject: [PATCH 064/113] feat: add context usage info to prunable tools list --- lib/hooks.ts | 10 +++++++++- lib/messages/inject.ts | 23 +++++++++++++++++++---- lib/messages/utils.ts | 22 ++++++++++++++++++++++ lib/state/state.ts | 2 ++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/lib/hooks.ts b/lib/hooks.ts index 58c9e4ca..508b98f0 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -23,7 +23,15 @@ export function createSystemPromptHandler( logger: Logger, config: PluginConfig, ) { - return async (_input: unknown, output: { system: string[] }) => { + return async ( + input: { sessionID?: string; model: { limit: { context: number } } }, + output: { system: string[] }, + ) => { + if (input.model?.limit?.context) { + state.modelContextLimit = input.model.limit.context + logger.debug("Cached model context limit", { limit: state.modelContextLimit }) + } + if (state.isSubAgent) { return } diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 09113678..629e7e80 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -9,14 +9,20 @@ import { createSyntheticTextPart, createSyntheticToolPart, isIgnoredUserMessage, + formatContextHeader, + type ContextInfo, } from "./utils" import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" import { getLastUserMessage, isMessageCompacted } from "../shared-utils" +import { getCurrentTokenUsage } from "../strategies/utils" -export const wrapPrunableTools = (content: string): string => ` -The following tools have been invoked and are available for pruning. This list does not mandate immediate action. Consider your current goals and the resources you need before pruning valuable tool inputs or outputs. Consolidate your prunes for efficiency; it is rarely worth pruning a single tiny tool output. Keep the context free of noise. +export const wrapPrunableTools = (content: string, contextInfo?: ContextInfo): string => { + const contextHeader = formatContextHeader(contextInfo) + return ` +${contextHeader}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. @@ -112,7 +118,11 @@ const buildPrunableToolsList = ( const description = paramKey ? `${toolParameterEntry.tool}, ${paramKey}` : toolParameterEntry.tool - lines.push(`${numericId}: ${description}`) + 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}`, ) @@ -122,7 +132,12 @@ const buildPrunableToolsList = ( return "" } - return wrapPrunableTools(lines.join("\n")) + const contextInfo: ContextInfo = { + used: getCurrentTokenUsage(messages), + limit: state.modelContextLimit, + } + + return wrapPrunableTools(lines.join("\n"), contextInfo) } export const insertPruneToolContext = ( diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index 869735c1..0057fba4 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -3,9 +3,31 @@ import { isMessageCompacted } from "../shared-utils" import { Logger } from "../logger" import type { SessionState, WithParts } from "../state" import type { UserMessage } from "@opencode-ai/sdk/v2" +import { formatTokenCount } from "../ui/utils" export const COMPRESS_SUMMARY_PREFIX = "[Compressed conversation block]\n\n" +export interface ContextInfo { + used: number + limit: number | undefined +} + +export function formatContextHeader(contextInfo?: ContextInfo): string { + if (!contextInfo || contextInfo.used === 0) { + return "" + } + + const usedStr = formatTokenCount(contextInfo.used) + + if (contextInfo.limit) { + const limitStr = formatTokenCount(contextInfo.limit) + const percentage = Math.round((contextInfo.used / contextInfo.limit) * 100) + return `Context: ~${usedStr} / ${limitStr} (${percentage}% used)\n` + } + + return `Context: ~${usedStr}\n` +} + const generateUniqueId = (prefix: string): string => `${prefix}_${ulid()}` const isGeminiModel = (modelID: string): boolean => { diff --git a/lib/state/state.ts b/lib/state/state.ts index 50866352..5a14f481 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -62,6 +62,7 @@ export function createSessionState(): SessionState { lastCompaction: 0, currentTurn: 0, variant: undefined, + modelContextLimit: undefined, } } @@ -83,6 +84,7 @@ export function resetSessionState(state: SessionState): void { state.lastCompaction = 0 state.currentTurn = 0 state.variant = undefined + state.modelContextLimit = undefined } export async function ensureSessionInitialized( From b5a4d0573582561501e4e937b92efe4349e8295f Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 3 Feb 2026 02:06:50 -0500 Subject: [PATCH 065/113] refactor: use userInfo consistently in synthetic part creation --- lib/messages/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index 0057fba4..58da82ee 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -74,7 +74,7 @@ export const createSyntheticTextPart = (baseMessage: WithParts, content: string) return { id: partId, sessionID: userInfo.sessionID, - messageID: baseMessage.info.id, + messageID: userInfo.id, type: "text" as const, text: content, } @@ -99,7 +99,7 @@ export const createSyntheticToolPart = ( return { id: partId, sessionID: userInfo.sessionID, - messageID: baseMessage.info.id, + messageID: userInfo.id, type: "tool" as const, callID: callId, tool: "context_info", From e76a8ce4b2ca3a5f093dc731541a14b33f07d40c Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 3 Feb 2026 18:42:24 -0500 Subject: [PATCH 066/113] readme update --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8b222a76..afacf2ad 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/dansmolsky) [![npm version](https://img.shields.io/npm/v/@tarquinen/opencode-dcp.svg)](https://www.npmjs.com/package/@tarquinen/opencode-dcp) -Automatically reduces token usage in OpenCode by removing obsolete tools from conversation history. +Automatically reduces token usage in OpenCode by removing obsolete content from conversation history. ![DCP in action](assets/images/dcp-demo5.png) @@ -50,7 +50,7 @@ LLM providers like Anthropic and OpenAI cache prompts based on exact prefix matc **Trade-off:** You lose some cache read benefits but gain larger token savings from reduced context size and performance improvements through reduced context poisoning. In most cases, the token savings outweigh the cache miss cost—especially in long sessions where context bloat becomes significant. -> **Note:** In testing, cache hit rates were approximately 65% with DCP enabled vs 85% without. +> **Note:** In testing, cache hit rates were approximately 80% with DCP enabled vs 85% without for most providers. **Best use case:** Providers that count usage in requests, such as Github Copilot and Google Antigravity, have no negative price impact. From 19484ca0b208817d54e8bc43230167f1ad291259 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 3 Feb 2026 19:30:42 -0500 Subject: [PATCH 067/113] Relicense to AGPL-3.0 and add Contributor License Agreement (CLA) --- CONTRIBUTING.md | 22 ++ LICENSE | 640 ++++++++++++++++++++++++++++++++++++++++++++++-- README.md | 2 +- package.json | 2 +- 4 files changed, 643 insertions(+), 23 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..afd7b323 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,22 @@ +# Contributing to DCP + +Thank you for your interest in contributing to Dynamic Context Pruning (DCP)! + +## License and Contributor License Agreement (CLA) + +By contributing to this project, you agree that: + +1. Your contributions are licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)**. +2. You grant the project maintainer(s) a non-exclusive, perpetual, worldwide, royalty-free license to use, modify, and re-license your contributions under any terms they choose, including commercial or proprietary licenses. + +This "Dual Licensing" model allows the project to remain free and open source while providing a path for commercial sustainability by offering alternative licensing arrangements to corporations. + +## Getting Started + +1. Fork the repository. +2. Create a feature branch. +3. Implement your changes and add tests if applicable. +4. Ensure all tests pass and the code is formatted. +5. Submit a Pull Request. + +We look forward to your contributions! diff --git a/LICENSE b/LICENSE index 46ae85da..ca9b0551 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,619 @@ -MIT License - -Copyright (c) 2025 tarquinen - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md index afacf2ad..ee569d52 100644 --- a/README.md +++ b/README.md @@ -175,4 +175,4 @@ Restart OpenCode after making config changes. ## License -MIT +AGPL-3.0-or-later diff --git a/package.json b/package.json index eeee5acb..8a489139 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ }, "homepage": "https://github.com/Tarquinen/opencode-dynamic-context-pruning#readme", "author": "tarquinen", - "license": "MIT", + "license": "AGPL-3.0-or-later", "peerDependencies": { "@opencode-ai/plugin": ">=0.13.7" }, From 3828d47d814d0043547dc9a84f28a73005db3d82 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 3 Feb 2026 19:32:35 -0500 Subject: [PATCH 068/113] Add PR template and update CONTRIBUTING.md with official CLA info --- .github/PULL_REQUEST_TEMPLATE.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..280730c1 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +### Summary + + +### Checklist +- [ ] I have read the [CONTRIBUTING.md](https://github.com/Opencode-DCP/opencode-dynamic-context-pruning/blob/dev/CONTRIBUTING.md) +- [ ] I have signed the CLA via the CLA Assistant bot (check the bot comment below after opening the PR) +- [ ] My changes follow the project's coding style +- [ ] I have added tests to cover my changes (if applicable) From 2f036c642edcfe87323a1a5745dfcba157c2d1af Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 3 Feb 2026 19:32:44 -0500 Subject: [PATCH 069/113] Clarify CLA process and reference SAP template in CONTRIBUTING.md --- CONTRIBUTING.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index afd7b323..310862a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,12 +4,11 @@ Thank you for your interest in contributing to Dynamic Context Pruning (DCP)! ## License and Contributor License Agreement (CLA) -By contributing to this project, you agree that: +This project uses the **GNU Affero General Public License v3.0 (AGPL-3.0)**. -1. Your contributions are licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)**. -2. You grant the project maintainer(s) a non-exclusive, perpetual, worldwide, royalty-free license to use, modify, and re-license your contributions under any terms they choose, including commercial or proprietary licenses. +To maintain the project's sustainability, we require contributors to sign a **Contributor License Agreement (CLA)**. We use the standard **SAP Individual Contributor License Agreement**, which allows us to keep the project open-source while preserving the ability to offer commercial licenses to organizations that cannot comply with the AGPL-3.0. -This "Dual Licensing" model allows the project to remain free and open source while providing a path for commercial sustainability by offering alternative licensing arrangements to corporations. +When you open a Pull Request, the **[CLA Assistant](https://cla-assistant.io/)** bot will automatically ask you to sign the agreement if you haven't already. ## Getting Started From ebf8ba2fdd4e3ea2c1980e6da3437432b0ee1c81 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 3 Feb 2026 19:39:21 -0500 Subject: [PATCH 070/113] chore: format legal and PR files --- .github/PULL_REQUEST_TEMPLATE.md | 2 ++ CONTRIBUTING.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 280730c1..e6eccc4c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,7 +1,9 @@ ### Summary + ### Checklist + - [ ] I have read the [CONTRIBUTING.md](https://github.com/Opencode-DCP/opencode-dynamic-context-pruning/blob/dev/CONTRIBUTING.md) - [ ] I have signed the CLA via the CLA Assistant bot (check the bot comment below after opening the PR) - [ ] My changes follow the project's coding style diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 310862a8..ceeaad13 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thank you for your interest in contributing to Dynamic Context Pruning (DCP)! ## License and Contributor License Agreement (CLA) -This project uses the **GNU Affero General Public License v3.0 (AGPL-3.0)**. +This project uses the **GNU Affero General Public License v3.0 (AGPL-3.0)**. To maintain the project's sustainability, we require contributors to sign a **Contributor License Agreement (CLA)**. We use the standard **SAP Individual Contributor License Agreement**, which allows us to keep the project open-source while preserving the ability to offer commercial licenses to organizations that cannot comply with the AGPL-3.0. From 5b88376fa28dea1af081c0d39e051045b114e149 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 3 Feb 2026 20:20:31 -0500 Subject: [PATCH 071/113] chore: switch to implicit CLA model --- .github/PULL_REQUEST_TEMPLATE.md | 10 ---------- CONTRIBUTING.md | 11 ++++++++--- 2 files changed, 8 insertions(+), 13 deletions(-) delete mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index e6eccc4c..00000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,10 +0,0 @@ -### Summary - - - -### Checklist - -- [ ] I have read the [CONTRIBUTING.md](https://github.com/Opencode-DCP/opencode-dynamic-context-pruning/blob/dev/CONTRIBUTING.md) -- [ ] I have signed the CLA via the CLA Assistant bot (check the bot comment below after opening the PR) -- [ ] My changes follow the project's coding style -- [ ] I have added tests to cover my changes (if applicable) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ceeaad13..d4e30979 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,13 +2,18 @@ Thank you for your interest in contributing to Dynamic Context Pruning (DCP)! -## License and Contributor License Agreement (CLA) +## License and Contributions This project uses the **GNU Affero General Public License v3.0 (AGPL-3.0)**. -To maintain the project's sustainability, we require contributors to sign a **Contributor License Agreement (CLA)**. We use the standard **SAP Individual Contributor License Agreement**, which allows us to keep the project open-source while preserving the ability to offer commercial licenses to organizations that cannot comply with the AGPL-3.0. +### Contribution Agreement -When you open a Pull Request, the **[CLA Assistant](https://cla-assistant.io/)** bot will automatically ask you to sign the agreement if you haven't already. +By submitting a Pull Request to this project, you agree that: + +1. Your contributions are licensed under the **AGPL-3.0**. +2. You grant the project maintainer(s) a non-exclusive, perpetual, irrevocable, worldwide, royalty-free, transferable license to use, modify, and re-license your contributions under any terms they choose, including commercial or proprietary licenses. + +This arrangement ensures the project remains Open Source while providing a path for commercial sustainability. ## Getting Started From 4e434190f522e99f8f64d4ef9ee5150e6d2ccecc Mon Sep 17 00:00:00 2001 From: Corentin AZAIS Date: Wed, 4 Feb 2026 11:28:59 +0100 Subject: [PATCH 072/113] docs: add uniform token pricing providers to best use cases Mention providers like Cerebras that bill cached tokens at the same rate as regular input tokens, making DCP purely beneficial with no cache-miss penalty. --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4d39d2cd..8f704185 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,10 @@ LLM providers like Anthropic and OpenAI cache prompts based on exact prefix matc > **Note:** In testing, cache hit rates were approximately 65% with DCP enabled vs 85% without. -**Best use case:** Providers that count usage in requests, such as Github Copilot and Google Antigravity have no negative price impact. +**Best use cases:** + +- **Request-based billing** — Providers that count usage in requests, such as Github Copilot and Google Antigravity, have no negative price impact. +- **Uniform token pricing** — Providers that bill cached tokens at the same rate as regular input tokens, such as Cerebras, see pure savings with no cache-miss penalty. ## Configuration @@ -57,7 +60,7 @@ DCP uses its own config file: - Global: `~/.config/opencode/dcp.jsonc` (or `dcp.json`), created automatically on first run - Custom config directory: `$OPENCODE_CONFIG_DIR/dcp.jsonc` (or `dcp.json`), if `OPENCODE_CONFIG_DIR` is set -- Project: `.opencode/dcp.jsonc` (or `dcp.json`) in your project’s `.opencode` directory +- Project: `.opencode/dcp.jsonc` (or `dcp.json`) in your project's `.opencode` directory
Default Configuration (click to expand) From e27a99729e9ddc1720ef3ec2803ebd0556d53c47 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Fri, 30 Jan 2026 07:09:43 +0100 Subject: [PATCH 073/113] WIP: system prompt refactor --- lib/prompts/system.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/prompts/system.md b/lib/prompts/system.md index a0574236..c62dcf51 100644 --- a/lib/prompts/system.md +++ b/lib/prompts/system.md @@ -1,15 +1,11 @@ +You operate a context-constrained environment and MUST PROACTIVELY MANAGE IT TO AVOID CONTEXT ROT -ENVIRONMENT -You are operating in a context-constrained environment and must proactively manage your context window. The environment calls the `context_info` tool to provide an up-to-date list after each turn. Use this information when deciding what to prune. - -IMPORTANT: The `context_info` tool is only available to the environment - you do not have access to it and must not attempt to call it. - -AVAILABLE TOOLS -`prune`: Remove individual tool outputs that are noise, irrelevant, or superseded. No preservation of content. -`distill`: Distill key findings from individual tool outputs into preserved knowledge. Use when you need to preserve valuable technical details. +AVAILABLE TOOLS FOR CONTEXT MANAGEMENT +`distill`: condense key findings from individual tool calls into high-fidelity distillation to preserve insights. Use to extract valuable and relevant context. BE THOROUGH, your distillation MUST be high-signal, low noise and complete, such that the raw output is no longer needed. `compress`: Collapse a contiguous range of conversation (completed phases) into a single summary. +`prune`: Remove individual tool outputs that are noise, irrelevant, or superseded. No preservation of content. PRUNE METHODICALLY - BATCH YOUR ACTIONS Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by pruning. Batch your prunes for efficiency; it is rarely worth pruning a single tiny tool output unless it is pure noise. Evaluate what SHOULD be pruned before jumping the gun. @@ -70,6 +66,10 @@ There may be tools in session context that do not appear in the After each turn, the environment injects a synthetic message containing a list and optional nudge instruction. You do not have access to this mechanism. +The environment calls the `context_info` tool to provide an up-to-date list after each turn. Use this information when deciding what to prune. + +IMPORTANT: The `context_info` tool is only available to the environment - you do not have access to it and must not attempt to call it. + CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: - NEVER reference the prune encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the prune encouragement appears. From 7baf250c000bc6aa3f53baed42c72b8fd8dce91b Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Fri, 30 Jan 2026 09:04:28 +0100 Subject: [PATCH 074/113] progress --- lib/prompts/nudge.md | 2 +- lib/prompts/system.md | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/prompts/nudge.md b/lib/prompts/nudge.md index 078f166e..4e4d8e4a 100644 --- a/lib/prompts/nudge.md +++ b/lib/prompts/nudge.md @@ -7,6 +7,6 @@ You should prioritize context management, but do not interrupt a critical atomic IMMEDIATE ACTION REQUIRED KNOWLEDGE PRESERVATION: If holding valuable raw data you POTENTIALLY will need in your task, use the `distill` tool. Produce a high-fidelity distillation to preserve insights - be thorough -NOISE REMOVAL: If you read files or ran commands that yielded no value, use the `prune` tool to remove them. If newer tools supersedes older ones, prune the old PHASE COMPLETION: If a phase is complete, use the `compress` tool to condense the entire sequence into a detailed summary +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/system.md b/lib/prompts/system.md index c62dcf51..58cfef60 100644 --- a/lib/prompts/system.md +++ b/lib/prompts/system.md @@ -1,11 +1,14 @@ -You operate a context-constrained environment and MUST PROACTIVELY MANAGE IT TO AVOID CONTEXT ROT +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. AVAILABLE TOOLS FOR CONTEXT MANAGEMENT -`distill`: condense key findings from individual tool calls into high-fidelity distillation to preserve insights. Use to extract valuable and relevant context. BE THOROUGH, your distillation MUST be high-signal, low noise and complete, such that the raw output is no longer needed. -`compress`: Collapse a contiguous range of conversation (completed phases) into a single summary. -`prune`: Remove individual tool outputs that are noise, irrelevant, or superseded. No preservation of content. +`distill`: condense key findings from individual tool calls into high-fidelity distillation to preserve insights. Use to extract valuable and relevant context. BE THOROUGH, your distillation MUST be high-signal, low noise and complete, such that the raw tool output is no longer needed. +`compress`: collapse a contiguous range of the conversation into a summary. Use to retain key insights while SIGNIFICANTLY reducing context size. Compress conversation phases organically as they get completed, think more micro than macro here. Make SURE to retain the ins and outs and specifics of the range you are compressing with a complete and detailed summary - do not be cheap. +`prune`: remove individual tool calls that are noise, irrelevant, or superseded. No preservation of content. Do NOT let irrelevant context accumulate but be judicious and strategic when pruning to maintain necessary context. + +// **🡇 add an tag for conditionals 🡇** // +AVOID CONTEXT ROT - EVALUATE YOUR CONTEXT AND MANAGE REGULARLY. AVOID USING MANAGEMENT TOOLS AS THE ONLY TOOL CALL IN YOUR RESPONSE, PARALLELIZE IT WITH OTHER TOOL RELEVANT TO YOUR TASK CONTINUATION (read, edit, bash...) PRUNE METHODICALLY - BATCH YOUR ACTIONS Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by pruning. Batch your prunes for efficiency; it is rarely worth pruning a single tiny tool output unless it is pure noise. Evaluate what SHOULD be pruned before jumping the gun. From e5c56fc6174259fee4994db1de60118a7c2bffd9 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Fri, 30 Jan 2026 09:41:12 +0100 Subject: [PATCH 075/113] system --- lib/prompts/system.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/prompts/system.md b/lib/prompts/system.md index 58cfef60..df27a9f2 100644 --- a/lib/prompts/system.md +++ b/lib/prompts/system.md @@ -8,7 +8,7 @@ AVAILABLE TOOLS FOR CONTEXT MANAGEMENT `prune`: remove individual tool calls that are noise, irrelevant, or superseded. No preservation of content. Do NOT let irrelevant context accumulate but be judicious and strategic when pruning to maintain necessary context. // **🡇 add an tag for conditionals 🡇** // -AVOID CONTEXT ROT - EVALUATE YOUR CONTEXT AND MANAGE REGULARLY. AVOID USING MANAGEMENT TOOLS AS THE ONLY TOOL CALL IN YOUR RESPONSE, PARALLELIZE IT WITH OTHER TOOL RELEVANT TO YOUR TASK CONTINUATION (read, edit, bash...) +AVOID CONTEXT ROT - EVALUATE YOUR CONTEXT AND MANAGE REGULARLY. AVOID USING MANAGEMENT TOOLS AS THE ONLY TOOL CALLS IN YOUR RESPONSE, PARALLELIZE WITH OTHER RELEVANT TOOLS TO YOUR TASK CONTINUATION (read, edit, bash...) PRUNE METHODICALLY - BATCH YOUR ACTIONS Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by pruning. Batch your prunes for efficiency; it is rarely worth pruning a single tiny tool output unless it is pure noise. Evaluate what SHOULD be pruned before jumping the gun. @@ -46,8 +46,8 @@ You WILL evaluate distilling when ANY of these are true: WHEN TO COMPRESS -- **Phase Completion:** When a phase is complete, condense the entire sequence (research, tool calls, implementation) into a summary. -- **Exploration Done:** When you've explored multiple files or ran multiple commands and only need a summary of findings. +- Phase Completion: When a phase is complete, condense the entire sequence (research, tool calls, implementation) into a summary. +- Exploration Done: When you've explored multiple files or ran multiple commands and only need a summary of findings. You WILL evaluate compressing when ANY of these are true: From 6ea200146516ca7758407d76174ed22aa82fc605 Mon Sep 17 00:00:00 2001 From: Spoon <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Sat, 31 Jan 2026 19:02:51 +0100 Subject: [PATCH 076/113] Update system.md --- lib/prompts/system.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/prompts/system.md b/lib/prompts/system.md index df27a9f2..5f2e3369 100644 --- a/lib/prompts/system.md +++ b/lib/prompts/system.md @@ -3,9 +3,9 @@ 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. AVAILABLE TOOLS FOR CONTEXT MANAGEMENT -`distill`: condense key findings from individual tool calls into high-fidelity distillation to preserve insights. Use to extract valuable and relevant context. BE THOROUGH, your distillation MUST be high-signal, low noise and complete, such that the raw tool output is no longer needed. -`compress`: collapse a contiguous range of the conversation into a summary. Use to retain key insights while SIGNIFICANTLY reducing context size. Compress conversation phases organically as they get completed, think more micro than macro here. Make SURE to retain the ins and outs and specifics of the range you are compressing with a complete and detailed summary - do not be cheap. -`prune`: remove individual tool calls that are noise, irrelevant, or superseded. No preservation of content. Do NOT let irrelevant context accumulate but be judicious and strategic when pruning to maintain necessary context. +`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, such that the raw tool output is no longer needed. +`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 micro, not 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 // **🡇 add an tag for conditionals 🡇** // AVOID CONTEXT ROT - EVALUATE YOUR CONTEXT AND MANAGE REGULARLY. AVOID USING MANAGEMENT TOOLS AS THE ONLY TOOL CALLS IN YOUR RESPONSE, PARALLELIZE WITH OTHER RELEVANT TOOLS TO YOUR TASK CONTINUATION (read, edit, bash...) From 9722f54e35445031bcf9d21808591f190024d9c2 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Tue, 3 Feb 2026 06:22:44 +0100 Subject: [PATCH 077/113] compress default ask permission --- index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/index.ts b/index.ts index d066fac8..87b6feab 100644 --- a/index.ts +++ b/index.ts @@ -112,6 +112,15 @@ const plugin: Plugin = (async (ctx) => { logger.info( `Added ${toolsToAdd.map((t) => `'${t}'`).join(" and ")} to experimental.primary_tools via config mutation`, ) + + // Set compress permission to ask + if (config.tools.compress.enabled) { + const permission = opencodeConfig.permission ?? {} + opencodeConfig.permission = { + ...permission, + compress: "ask", + } as typeof permission + } } }, } From 47406beacfdb32f2a3dd40b124bdc68240528e73 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Tue, 3 Feb 2026 06:59:24 +0100 Subject: [PATCH 078/113] system --- lib/prompts/system.md | 72 +++++++++++++------------------------------ 1 file changed, 21 insertions(+), 51 deletions(-) diff --git a/lib/prompts/system.md b/lib/prompts/system.md index 5f2e3369..f88f64ae 100644 --- a/lib/prompts/system.md +++ b/lib/prompts/system.md @@ -3,69 +3,36 @@ 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. 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, such that the raw tool output is no longer needed. -`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 micro, not 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. +`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 -// **🡇 add an tag for conditionals 🡇** // -AVOID CONTEXT ROT - EVALUATE YOUR CONTEXT AND MANAGE REGULARLY. AVOID USING MANAGEMENT TOOLS AS THE ONLY TOOL CALLS IN YOUR RESPONSE, PARALLELIZE WITH OTHER RELEVANT TOOLS TO YOUR TASK CONTINUATION (read, edit, bash...) +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. + -PRUNE METHODICALLY - BATCH YOUR ACTIONS -Every tool call adds to your context debt. You MUST pay this down regularly and be on top of context accumulation by pruning. Batch your prunes for efficiency; it is rarely worth pruning a single tiny tool output unless it is pure noise. Evaluate what SHOULD be pruned before jumping the gun. +THE COMPRESS TOOL +`compress` is sledge hammer 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. -You MUST NOT prune when: +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. -- The tool output will be needed for upcoming implementation work -- The output contains files or context you'll need to reference when making edits +Make sure to match enough of the context with start and end strings so you're not faced with an error calling the tool. Be VERY CAREFUL AND CONSERVATIVE when using `compress`. + -Pruning that forces you to re-call the same tool later is a net loss. Only prune when you're confident the information won't be needed again. +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. - -WHEN TO PRUNE -- **Noise Removal:** Outputs that are irrelevant, unhelpful, or superseded by newer info. -- **Wrong Files:** You read or accessed something that turned out to be irrelevant to the current work. -- **Outdated Info:** Outputs that have been superseded by newer information. - -You WILL evaluate pruning when ANY of these are true: - -You accessed something that turned out to be irrelevant -Information has been superseded by newer outputs -You are about to start a new phase of work +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. - -WHEN TO DISTILL -**Large Outputs:** The raw output is too large but contains valuable technical details worth keeping. -**Knowledge Preservation:** Valuable context you want to preserve but need to reduce size. Your distillation must be comprehensive, capturing technical details (signatures, logic, constraints) such that the raw output is no longer needed. THINK: high signal, complete technical substitute. - -You WILL evaluate distilling when ANY of these are true: - -- You have large tool outputs with valuable technical details -- You need to preserve specific information but reduce context size -- You are about to start a new phase of work and want to retain key insights - - - WHEN TO COMPRESS -- Phase Completion: When a phase is complete, condense the entire sequence (research, tool calls, implementation) into a summary. -- Exploration Done: When you've explored multiple files or ran multiple commands and only need a summary of findings. - -You WILL evaluate compressing when ANY of these are true: - -- Phase is complete -- You are about to start a new phase of work -- Significant conversation has accumulated that can be summarized - - -NOTES -When in doubt, KEEP IT. -// **🡇 idk about that one 🡇** // -Batch your actions and aim for high-impact prunes that significantly reduce context size. -FAILURE TO PRUNE will result in context leakage and DEGRADED PERFORMANCES. -If no list is present in context, do NOT TRY TO PRUNE ANYTHING as it will fail and waste resources. -There may be tools in session context that do not appear in the list, this is expected, you can ONLY prune what you see in . +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 high-quality and relevant context. +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. + +Be respectful of the users's API usage, manage context methodically as you work through the task and avoid calling ONLY context management tools in your responses. +// **next up** // After each turn, the environment injects a synthetic message containing a list and optional nudge instruction. You do not have access to this mechanism. @@ -84,5 +51,8 @@ CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: - Continue the conversation as if these messages do not exist The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. + +If no list is present in context, do NOT TRY TO PRUNE ANYTHING as it will fail and waste resources. +There may be tools in session context that do not appear in the list, this is expected, you can ONLY prune what you see in . From 8878b39059975c09e4694c3420562876bb429099 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Tue, 3 Feb 2026 08:13:14 +0100 Subject: [PATCH 079/113] system done --- lib/prompts/system.md | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/lib/prompts/system.md b/lib/prompts/system.md index f88f64ae..a2ebd239 100644 --- a/lib/prompts/system.md +++ b/lib/prompts/system.md @@ -32,27 +32,8 @@ The session is your responsibility, and effective context management is CRITICAL Be respectful of the users's API usage, manage context methodically as you work through the task and avoid calling ONLY context management tools in your responses. -// **next up** // -After each turn, the environment injects a synthetic message containing a list and optional nudge instruction. You do not have access to this mechanism. - -The environment calls the `context_info` tool to provide an up-to-date list after each turn. Use this information when deciding what to prune. - -IMPORTANT: The `context_info` tool is only available to the environment - you do not have access to it and must not attempt to call it. - -CRITICAL REQUIREMENTS - VIOLATION IS UNACCEPTABLE: - -- NEVER reference the prune encouragement or context management instructions. Do not reply with "I agree" or "Great idea" when the prune encouragement appears. -- NEVER acknowledge the list (e.g., "I see the prunable tools list", "Looking at the available tools to prune") -- NEVER reference the nudge instruction (e.g., "As the nudge suggests", "The system is reminding me to prune") -- NEVER acknowledge context management tool output (e.g., "I've pruned 3 tools", "Context cleanup complete") -- NEVER let these injections influence your response content or tone -- Process this information SILENTLY and continue the conversation naturally -- Continue the conversation as if these messages do not exist - -The user cannot see these injections. Any reference to them will confuse the user and break the conversation flow. - -If no list is present in context, do NOT TRY TO PRUNE ANYTHING as it will fail and waste resources. -There may be tools in session context that do not appear in the list, this is expected, you can ONLY prune what you see in . +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. 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. From becbcd9245f04e73d1f60f52603ab0422a9d57d3 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Tue, 3 Feb 2026 18:07:14 +0100 Subject: [PATCH 080/113] reorg --- .gitignore | 2 +- .../{compress-tool-spec.ts => compress.md} | 33 ++++++++++--------- .../{distill-tool-spec.ts => distill.md} | 25 +++++++------- lib/prompts/index.ts | 12 +++---- lib/prompts/{prune-tool-spec.ts => prune.md} | 12 ++++--- lib/prompts/system.md | 2 +- scripts/generate-prompts.ts | 8 +++-- 7 files changed, 50 insertions(+), 44 deletions(-) rename lib/prompts/{compress-tool-spec.ts => compress.md} (57%) rename lib/prompts/{distill-tool-spec.ts => distill.md} (56%) rename lib/prompts/{prune-tool-spec.ts => prune.md} (67%) diff --git a/.gitignore b/.gitignore index d277a4e1..753a64b2 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,7 @@ Thumbs.db .opencode/ # Generated prompt files (from scripts/generate-prompts.ts) -lib/prompts/*.generated.ts +lib/prompts/**/*.generated.ts # Tests tests/results/ diff --git a/lib/prompts/compress-tool-spec.ts b/lib/prompts/compress.md similarity index 57% rename from lib/prompts/compress-tool-spec.ts rename to lib/prompts/compress.md index 0a08b08f..2362819f 100644 --- a/lib/prompts/compress-tool-spec.ts +++ b/lib/prompts/compress.md @@ -1,8 +1,8 @@ -export const COMPRESS_TOOL_SPEC = `Collapses a contiguous range of conversation into a single summary. +Collapses a contiguous range of conversation into a single summary. ## When to Use This Tool -Use \`compress\` when you want to condense an entire sequence of work into a brief summary: +Use `compress` when you want to condense an entire sequence of work into a brief summary: - **Phase Completion:** You completed a phase (research, tool calls, implementation) and want to collapse the entire sequence into a summary. - **Exploration Done:** You explored multiple files or ran multiple commands and only need a summary of what you learned. @@ -12,21 +12,22 @@ Use \`compress\` when you want to condense an entire sequence of work into a bri ## When NOT to Use This Tool - **If you need specific details:** If you'll need exact code, file contents, or error messages from the range, keep them. -- **For individual tool outputs:** Use \`prune\` or \`distill\` for single tool outputs. Compress targets conversation ranges. +- **For individual tool outputs:** Use `prune` or `distill` for single tool outputs. Compress targets conversation ranges. - **If it's recent content:** You may still need recent work for the current phase. ## How It Works -1. \`startString\` — A unique text string that marks the start of the range to compress -2. \`endString\` — A unique text string that marks the end of the range to compress -3. \`topic\` — A short label (3-5 words) describing the compressed content -4. \`summary\` — The replacement text that will be inserted +1. `startString` — A unique text string that marks the start of the range to compress +2. `endString` — A unique text string that marks the end of the range to compress +3. `topic` — A short label (3-5 words) describing the compressed content +4. `summary` — The replacement text that will be inserted Everything between startString and endString (inclusive) is removed and replaced with your summary. -**Important:** The compress will FAIL if \`startString\` or \`endString\` is not found in the conversation. The compress will also FAIL if either string is found multiple times. Provide a larger string with more surrounding context to uniquely identify the intended match. +**Important:** The compress will FAIL if `startString` or `endString` is not found in the conversation. The compress will also FAIL if either string is found multiple times. Provide a larger string with more surrounding context to uniquely identify the intended match. ## Best Practices + - **Choose unique strings:** Pick text that appears only once in the conversation. - **Write concise topics:** Examples: "Auth System Exploration", "Token Logic Refactor" - **Write comprehensive summaries:** Include key information like file names, function signatures, and important findings. @@ -34,7 +35,7 @@ Everything between startString and endString (inclusive) is removed and replaced ## Format -- \`input\`: Array with four elements: [startString, endString, topic, summary] +- `input`: Array with four elements: [startString, endString, topic, summary] ## Example @@ -42,16 +43,16 @@ Everything between startString and endString (inclusive) is removed and replaced Conversation: [Asked about auth] -> [Read 5 files] -> [Analyzed patterns] -> [Found "JWT tokens with 24h expiry"] [Uses compress with: - input: [ - "Asked about authentication", - "JWT tokens with 24h expiry", - "Auth System Exploration", - "Auth: JWT 24h expiry, bcrypt passwords, refresh rotation. Files: auth.ts, tokens.ts, middleware/auth.ts" - ] +input: [ +"Asked about authentication", +"JWT tokens with 24h expiry", +"Auth System Exploration", +"Auth: JWT 24h expiry, bcrypt passwords, refresh rotation. Files: auth.ts, tokens.ts, middleware/auth.ts" +] ] Assistant: [Just finished reading auth.ts] I've read the auth file and now need to make edits based on it. I'm keeping this in context rather than compressing. -` + diff --git a/lib/prompts/distill-tool-spec.ts b/lib/prompts/distill.md similarity index 56% rename from lib/prompts/distill-tool-spec.ts rename to lib/prompts/distill.md index 9fccc048..f9d390a6 100644 --- a/lib/prompts/distill-tool-spec.ts +++ b/lib/prompts/distill.md @@ -1,11 +1,12 @@ -export const DISTILL_TOOL_SPEC = `Distills key findings from tool outputs into preserved knowledge, then removes the raw outputs from context. +Distills key findings from tool outputs into preserved knowledge, then removes the raw outputs from context. ## IMPORTANT: The Prunable List -A \`\` list is provided to you showing available tool outputs you can distill from when there are tools available for pruning. Each line has the format \`ID: tool, parameter\` (e.g., \`20: read, /path/to/file.ts\`). You MUST only use numeric IDs that appear in this list to select which tools to distill. + +A `` list is provided to you showing available tool outputs you can distill from when there are tools available for pruning. Each line has the format `ID: tool, parameter` (e.g., `20: read, /path/to/file.ts`). You MUST only use numeric IDs that appear in this list to select which tools to distill. ## When to Use This Tool -Use \`distill\` when you have individual tool outputs with valuable information you want to **preserve in distilled form** before removing the raw content: +Use `distill` when you have individual tool outputs with valuable information you want to **preserve in distilled form** before removing the raw content: - **Large Outputs:** The raw output is too large but contains valuable technical details worth keeping. - **Knowledge Preservation:** You have context that contains valuable information (signatures, logic, constraints) but also a lot of unnecessary detail. @@ -15,15 +16,15 @@ Use \`distill\` when you have individual tool outputs with valuable information - **If you need precise syntax:** If you'll edit a file or grep for exact strings, keep the raw output. - **If uncertain:** Prefer keeping over re-fetching. - ## Best Practices + - **Strategic Batching:** Wait until you have several items or a few large outputs to distill, rather than doing tiny, frequent distillations. Aim for high-impact distillations that significantly reduce context size. - **Think ahead:** Before distilling, ask: "Will I need the raw output for upcoming work?" If you researched a file you'll later edit, do NOT distill it. ## Format -- \`ids\`: Array of numeric IDs as strings from the \`\` list -- \`distillation\`: Array of strings, one per ID (positional: distillation[0] is for ids[0], etc.) +- `ids`: Array of numeric IDs as strings from the `` list +- `distillation`: Array of strings, one per ID (positional: distillation[0] is for ids[0], etc.) Each distillation string should capture the essential information you need to preserve - function signatures, logic, constraints, values, etc. Be as detailed as needed. @@ -33,15 +34,15 @@ Each distillation string should capture the essential information you need to pr Assistant: [Reads auth service and user types] I'll preserve the key details before distilling. [Uses distill with: - ids: ["10", "11"], - distillation: [ - "auth.ts: validateToken(token: string) -> User|null checks cache first (5min TTL) then OIDC. hashPassword uses bcrypt 12 rounds. Tokens must be 128+ chars.", - "user.ts: interface User { id: string; email: string; permissions: ('read'|'write'|'admin')[]; status: 'active'|'suspended' }" - ] +ids: ["10", "11"], +distillation: [ +"auth.ts: validateToken(token: string) -> User|null checks cache first (5min TTL) then OIDC. hashPassword uses bcrypt 12 rounds. Tokens must be 128+ chars.", +"user.ts: interface User { id: string; email: string; permissions: ('read'|'write'|'admin')[]; status: 'active'|'suspended' }" +] ] Assistant: [Reads 'auth.ts' to understand the login flow] I've understood the auth flow. I'll need to modify this file to add the new validation, so I'm keeping this read in context rather than distilling. -` + diff --git a/lib/prompts/index.ts b/lib/prompts/index.ts index c80115f4..7520a417 100644 --- a/lib/prompts/index.ts +++ b/lib/prompts/index.ts @@ -1,11 +1,9 @@ -// Tool specs -import { PRUNE_TOOL_SPEC } from "./prune-tool-spec" -import { DISTILL_TOOL_SPEC } from "./distill-tool-spec" -import { COMPRESS_TOOL_SPEC } from "./compress-tool-spec" - // Generated prompts (from .md files via scripts/generate-prompts.ts) -import { SYSTEM as SYSTEM_PROMPT } from "./system.generated" -import { NUDGE } from "./nudge.generated" +import { SYSTEM as SYSTEM_PROMPT } from "./_codegen/system.generated" +import { NUDGE } from "./_codegen/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" export interface ToolFlags { distill: boolean diff --git a/lib/prompts/prune-tool-spec.ts b/lib/prompts/prune.md similarity index 67% rename from lib/prompts/prune-tool-spec.ts rename to lib/prompts/prune.md index c2ea3cbb..7f899b4f 100644 --- a/lib/prompts/prune-tool-spec.ts +++ b/lib/prompts/prune.md @@ -1,11 +1,12 @@ -export const PRUNE_TOOL_SPEC = `Prunes tool outputs from context to manage conversation size and reduce noise. +Prunes tool outputs from context to manage conversation size and reduce noise. ## IMPORTANT: The Prunable List -A \`\` list is provided to you showing available tool outputs you can prune when there are tools available for pruning. Each line has the format \`ID: tool, parameter\` (e.g., \`20: read, /path/to/file.ts\`). You MUST only use numeric IDs that appear in this list to select which tools to prune. + +A `` list is provided to you showing available tool outputs you can prune when there are tools available for pruning. Each line has the format `ID: tool, parameter` (e.g., `20: read, /path/to/file.ts`). You MUST only use numeric IDs that appear in this list to select which tools to prune. ## When to Use This Tool -Use \`prune\` for removing individual tool outputs that are no longer needed: +Use `prune` for removing individual tool outputs that are no longer needed: - **Noise:** Irrelevant, unhelpful, or superseded outputs that provide no value. - **Wrong Files:** You read or accessed something that turned out to be irrelevant. @@ -17,12 +18,13 @@ Use \`prune\` for removing individual tool outputs that are no longer needed: - **If you'll need the output later:** Don't prune files you plan to edit or context you'll need for implementation. ## Best Practices + - **Strategic Batching:** Don't prune single small tool outputs (like short bash commands) unless they are pure noise. Wait until you have several items to perform high-impact prunes. - **Think ahead:** Before pruning, ask: "Will I need this output for upcoming work?" If yes, keep it. ## Format -- \`ids\`: Array of numeric IDs as strings from the \`\` list +- `ids`: Array of numeric IDs as strings from the `` list ## Example @@ -36,4 +38,4 @@ This file isn't relevant to the auth system. I'll remove it to clear the context Assistant: [Reads config.ts, then reads updated config.ts after changes] The first read is now outdated. I'll prune it and keep the updated version. [Uses prune with ids: ["20"]] -` + diff --git a/lib/prompts/system.md b/lib/prompts/system.md index a2ebd239..aaa5d41f 100644 --- a/lib/prompts/system.md +++ b/lib/prompts/system.md @@ -25,7 +25,7 @@ Make sure to match enough of the context with start and end strings so you're no 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. -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 high-quality and relevant context. +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 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. diff --git a/scripts/generate-prompts.ts b/scripts/generate-prompts.ts index 815a54db..171120ba 100644 --- a/scripts/generate-prompts.ts +++ b/scripts/generate-prompts.ts @@ -9,12 +9,16 @@ * .ts files with exported string constants that bundle correctly. */ -import { readFileSync, writeFileSync, readdirSync } from "node:fs" +import { readFileSync, writeFileSync, readdirSync, mkdirSync } from "node:fs" import { dirname, join, basename } from "node:path" import { fileURLToPath } from "node:url" const __dirname = dirname(fileURLToPath(import.meta.url)) const PROMPTS_DIR = join(__dirname, "..", "lib", "prompts") +const CODEGEN_DIR = join(PROMPTS_DIR, "_codegen") + +// Ensure _codegen directory exists +mkdirSync(CODEGEN_DIR, { recursive: true }) // Find all .md files in the prompts directory const mdFiles = readdirSync(PROMPTS_DIR).filter((f) => f.endsWith(".md")) @@ -23,7 +27,7 @@ for (const mdFile of mdFiles) { const mdPath = join(PROMPTS_DIR, mdFile) const baseName = basename(mdFile, ".md") const constName = baseName.toUpperCase().replace(/-/g, "_") - const tsPath = join(PROMPTS_DIR, `${baseName}.generated.ts`) + const tsPath = join(CODEGEN_DIR, `${baseName}.generated.ts`) const content = readFileSync(mdPath, "utf-8") From c7ffaf7f2ef78188fe49f0cd8575410d045e7653 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Tue, 3 Feb 2026 18:50:32 +0100 Subject: [PATCH 081/113] migration: cleanup old gen files --- scripts/generate-prompts.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/generate-prompts.ts b/scripts/generate-prompts.ts index 171120ba..e83ffe75 100644 --- a/scripts/generate-prompts.ts +++ b/scripts/generate-prompts.ts @@ -9,7 +9,7 @@ * .ts files with exported string constants that bundle correctly. */ -import { readFileSync, writeFileSync, readdirSync, mkdirSync } from "node:fs" +import { readFileSync, writeFileSync, readdirSync, mkdirSync, unlinkSync } from "node:fs" import { dirname, join, basename } from "node:path" import { fileURLToPath } from "node:url" @@ -20,6 +20,13 @@ const CODEGEN_DIR = join(PROMPTS_DIR, "_codegen") // Ensure _codegen directory exists mkdirSync(CODEGEN_DIR, { recursive: true }) +// MIGRATION - Clean up old generated files from the prompts directory root (they're now in _codegen/) +const oldGeneratedFiles = readdirSync(PROMPTS_DIR).filter((f) => f.endsWith(".generated.ts")) +for (const file of oldGeneratedFiles) { + unlinkSync(join(PROMPTS_DIR, file)) + console.log(`Cleaned up old: ${file}`) +} + // Find all .md files in the prompts directory const mdFiles = readdirSync(PROMPTS_DIR).filter((f) => f.endsWith(".md")) From ae98667fc79f94cc0742f9f90c8f10ce04c0cd1c Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Tue, 3 Feb 2026 23:54:44 +0100 Subject: [PATCH 082/113] fix: tool permission --- lib/tools/compress.ts | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/tools/compress.ts b/lib/tools/compress.ts index 84b9f83f..b1bcc233 100644 --- a/lib/tools/compress.ts +++ b/lib/tools/compress.ts @@ -29,6 +29,13 @@ export function createCompressTool(ctx: PruneToolContext): ReturnType Date: Wed, 4 Feb 2026 01:03:14 +0100 Subject: [PATCH 083/113] distill --- lib/prompts/distill.md | 60 +++++++++++++------------------------ lib/prompts/system.md | 2 +- lib/tools/distill.ts | 68 +++++++++++++++++++----------------------- 3 files changed, 51 insertions(+), 79 deletions(-) diff --git a/lib/prompts/distill.md b/lib/prompts/distill.md index f9d390a6..d5ec732a 100644 --- a/lib/prompts/distill.md +++ b/lib/prompts/distill.md @@ -1,48 +1,28 @@ -Distills key findings from tool outputs into preserved knowledge, then removes the raw outputs from context. +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. -## IMPORTANT: The Prunable List +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. -A `` list is provided to you showing available tool outputs you can distill from when there are tools available for pruning. Each line has the format `ID: tool, parameter` (e.g., `20: read, /path/to/file.ts`). You MUST only use numeric IDs that appear in this list to select which tools to distill. +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. -## When to Use This Tool +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. -Use `distill` when you have individual tool outputs with valuable information you want to **preserve in distilled form** before removing the raw content: +BE STRATEGIC! 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. -- **Large Outputs:** The raw output is too large but contains valuable technical details worth keeping. -- **Knowledge Preservation:** You have context that contains valuable information (signatures, logic, constraints) but also a lot of unnecessary detail. +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, crystallized, is sufficient. +PRESERVATION: Valuable technical details (signatures, logic, constraints) coexist with noise. Preserve the former; discard the latter. -## When NOT to Use This Tool +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. -- **If you need precise syntax:** If you'll edit a file or grep for exact strings, keep the raw output. -- **If uncertain:** Prefer keeping over re-fetching. +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. -## Best Practices - -- **Strategic Batching:** Wait until you have several items or a few large outputs to distill, rather than doing tiny, frequent distillations. Aim for high-impact distillations that significantly reduce context size. -- **Think ahead:** Before distilling, ask: "Will I need the raw output for upcoming work?" If you researched a file you'll later edit, do NOT distill it. - -## Format - -- `ids`: Array of numeric IDs as strings from the `` list -- `distillation`: Array of strings, one per ID (positional: distillation[0] is for ids[0], etc.) - -Each distillation string should capture the essential information you need to preserve - function signatures, logic, constraints, values, etc. Be as detailed as needed. - -## Example - - -Assistant: [Reads auth service and user types] -I'll preserve the key details before distilling. -[Uses distill with: -ids: ["10", "11"], -distillation: [ -"auth.ts: validateToken(token: string) -> User|null checks cache first (5min TTL) then OIDC. hashPassword uses bcrypt 12 rounds. Tokens must be 128+ chars.", -"user.ts: interface User { id: string; email: string; permissions: ('read'|'write'|'admin')[]; status: 'active'|'suspended' }" -] -] - - - -Assistant: [Reads 'auth.ts' to understand the login flow] -I've understood the auth flow. I'll need to modify this file to add the new validation, so I'm keeping this read in context rather than distilling. - +THE FORMAT OF DISTILL +`items`: 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/system.md b/lib/prompts/system.md index aaa5d41f..4b296313 100644 --- a/lib/prompts/system.md +++ b/lib/prompts/system.md @@ -33,7 +33,7 @@ Be respectful of the users's API usage, manage context methodically as you work -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. If no list is present, do NOT attempt to prune anything. +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/tools/distill.ts b/lib/tools/distill.ts index fd969392..0b745fc2 100644 --- a/lib/tools/distill.ts +++ b/lib/tools/distill.ts @@ -10,58 +10,50 @@ export function createDistillTool(ctx: PruneToolContext): ReturnType list"), - distillation: tool.schema - .array(tool.schema.string()) + items: 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( - "Required array of distillation strings, one per ID (positional: distillation[0] for ids[0], etc.)", + "Array of distillation entries, each pairing an ID with its distillation", ), }, async execute(args, toolCtx) { - if (!args.ids || !Array.isArray(args.ids) || args.ids.length === 0) { - ctx.logger.debug("Distill tool called without ids: " + JSON.stringify(args)) - throw new Error("Missing ids. You must provide at least one ID to distill.") + if (!args.items || !Array.isArray(args.items) || args.items.length === 0) { + ctx.logger.debug("Distill tool called without items: " + JSON.stringify(args)) + throw new Error("Missing items. Provide at least one { id, distillation } entry.") } - if (!args.ids.every((id) => typeof id === "string" && id.trim() !== "")) { - ctx.logger.debug("Distill 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.', - ) - } - - if ( - !args.distillation || - !Array.isArray(args.distillation) || - args.distillation.length === 0 - ) { - ctx.logger.debug( - "Distill tool called without distillation: " + JSON.stringify(args), - ) - throw new Error( - 'Missing distillation. You must provide an array of strings (e.g., ["summary 1", "summary 2"]).', - ) - } - - if (!args.distillation.every((d) => typeof d === "string")) { - ctx.logger.debug( - "Distill tool called with non-string distillation: " + JSON.stringify(args), - ) - throw new Error("Invalid distillation. All distillation entries must be strings.") + for (const item of args.items) { + if (!item.id || typeof item.id !== "string" || item.id.trim() === "") { + ctx.logger.debug("Distill item missing id: " + JSON.stringify(item)) + throw new Error( + "Each item must have an id (numeric string from ).", + ) + } + if (!item.distillation || typeof item.distillation !== "string") { + ctx.logger.debug("Distill item missing distillation: " + JSON.stringify(item)) + throw new Error("Each item must have a distillation string.") + } } - // ctx.logger.info("Distillation data received:") - // ctx.logger.info(JSON.stringify(args.distillation, null, 2)) + const ids = args.items.map((item) => item.id) + const distillations = args.items.map((item) => item.distillation) return executePruneOperation( ctx, toolCtx, - args.ids, + ids, "extraction" as PruneReason, "Distill", - args.distillation, + distillations, ) }, }) From fed45012cad144cc66974ac5c455321556205f13 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Wed, 4 Feb 2026 01:36:42 +0100 Subject: [PATCH 084/113] prune --- lib/prompts/prune.md | 47 +++++++++++--------------------------------- 1 file changed, 12 insertions(+), 35 deletions(-) diff --git a/lib/prompts/prune.md b/lib/prompts/prune.md index 7f899b4f..2bbab63e 100644 --- a/lib/prompts/prune.md +++ b/lib/prompts/prune.md @@ -1,41 +1,18 @@ -Prunes tool outputs from context to manage conversation size and reduce noise. +Use this tool to remove tool outputs from context entirely. No preservation - pure deletion. -## IMPORTANT: The Prunable List +THE PRUNABLE TOOLS LIST +A will show in context when outputs are available for pruning. 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. -A `` list is provided to you showing available tool outputs you can prune when there are tools available for pruning. Each line has the format `ID: tool, parameter` (e.g., `20: read, /path/to/file.ts`). You MUST only use numeric IDs that appear in this list to select which tools to prune. +THE WAYS OF PRUNE +`prune` is a blunt instrument for eliminating noise (irrelevant or unhelpful outputs that provide no value), or superseded information (older outputs replaced by newer, more accurate data), wrong target (you read or accessed something that turned out to be irrelevant). Use it judiciously to maintain a clean and relevant context. -## When to Use This Tool +BE STRATEGIC! Prune is most effective when batched. Don't prune a single tiny output - wait until you have several items (depending on context occupation of those noisy outputs). -Use `prune` for removing individual tool outputs that are no longer needed: +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. -- **Noise:** Irrelevant, unhelpful, or superseded outputs that provide no value. -- **Wrong Files:** You read or accessed something that turned out to be irrelevant. -- **Outdated Info:** Outputs that have been superseded by newer information. +Before pruning, ask: _"Will I need this output for upcoming work?"_ If yes, keep it. Pruning that forces re-fetching is a net loss. -## When NOT to Use This Tool - -- **If the output contains useful information:** Keep it in context rather than pruning. -- **If you'll need the output later:** Don't prune files you plan to edit or context you'll need for implementation. - -## Best Practices - -- **Strategic Batching:** Don't prune single small tool outputs (like short bash commands) unless they are pure noise. Wait until you have several items to perform high-impact prunes. -- **Think ahead:** Before pruning, ask: "Will I need this output for upcoming work?" If yes, keep it. - -## Format - -- `ids`: Array of numeric IDs as strings from the `` list - -## Example - - -Assistant: [Reads 'wrong_file.ts'] -This file isn't relevant to the auth system. I'll remove it to clear the context. -[Uses prune with ids: ["5"]] - - - -Assistant: [Reads config.ts, then reads updated config.ts after changes] -The first read is now outdated. I'll prune it and keep the updated version. -[Uses prune with ids: ["20"]] - +THE FORMAT OF PRUNE +`ids`: Array of numeric IDs (as strings) from the `` list From e3b6f8b4c9b92ae1736a0f1781eea30fb7374577 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Wed, 4 Feb 2026 01:54:19 +0100 Subject: [PATCH 085/113] compress: restructure schema with topic primary, content nested (startString/endString) --- lib/prompts/compress.md | 73 ++++++++++++++--------------------------- lib/prompts/distill.md | 2 +- lib/tools/compress.ts | 47 +++++++++++++------------- lib/tools/distill.ts | 32 +++++++++--------- 4 files changed, 66 insertions(+), 88 deletions(-) diff --git a/lib/prompts/compress.md b/lib/prompts/compress.md index 2362819f..fed81069 100644 --- a/lib/prompts/compress.md +++ b/lib/prompts/compress.md @@ -1,58 +1,35 @@ -Collapses a contiguous range of conversation into a single summary. +Use this tool to collapse a contiguous range of conversation into a preserved summary. -## When to Use This Tool +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. -Use `compress` when you want to condense an entire sequence of work into a brief summary: +Think of compression as phase transitions: raw exploration becomes refined understanding. The original context served its purpose; your summary now carries that understanding forward. -- **Phase Completion:** You completed a phase (research, tool calls, implementation) and want to collapse the entire sequence into a summary. -- **Exploration Done:** You explored multiple files or ran multiple commands and only need a summary of what you learned. -- **Failed Attempts:** You tried several unsuccessful approaches and want to condense them into a brief note. -- **Verbose Output:** A section of conversation has grown large but can be summarized without losing critical details. +THE SUMMARY +Your summary must be COMPLETE. Capture file paths, function signatures, decisions made, constraints discovered, key findings... EVERYTHING that maintains context integrity. This is not a brief note - it is a technical substitute so faithful that the original conversation adds no value. -## When NOT to Use This Tool +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. -- **If you need specific details:** If you'll need exact code, file contents, or error messages from the range, keep them. -- **For individual tool outputs:** Use `prune` or `distill` for single tool outputs. Compress targets conversation ranges. -- **If it's recent content:** You may still need recent work for the current phase. +WHEN TO COMPRESS +Compress when a phase of work is truly complete and the raw conversation is no longer needed: -## How It Works +Research concluded and findings are clear +Implementation finished and verified +Exploration exhausted and patterns understood -1. `startString` — A unique text string that marks the start of the range to compress -2. `endString` — A unique text string that marks the end of the range to compress -3. `topic` — A short label (3-5 words) describing the compressed content -4. `summary` — The replacement text that will be inserted +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 -Everything between startString and endString (inclusive) is removed and replaced with your summary. +Before compressing, ask: _"Am I certain this phase is complete?"_ Compression is irreversible. The summary replaces everything in the range. -**Important:** The compress will FAIL if `startString` or `endString` is not found in the conversation. The compress will also FAIL if either string is found multiple times. Provide a larger string with more surrounding context to uniquely identify the intended match. +BOUNDARY MATCHING +You specify boundaries by matching unique text strings in the conversation. CRITICAL: In code-centric conversations, strings repeat often. Provide sufficiently unique text to match exactly once. If a match fails (not found or found multiple times), the tool will error - extend your boundary string with more surrounding context in order to make SURE the tool does NOT error. -## Best Practices - -- **Choose unique strings:** Pick text that appears only once in the conversation. -- **Write concise topics:** Examples: "Auth System Exploration", "Token Logic Refactor" -- **Write comprehensive summaries:** Include key information like file names, function signatures, and important findings. -- **Timing:** Best used after finishing a work phase, not during active exploration. - -## Format - -- `input`: Array with four elements: [startString, endString, topic, summary] - -## Example - - -Conversation: [Asked about auth] -> [Read 5 files] -> [Analyzed patterns] -> [Found "JWT tokens with 24h expiry"] - -[Uses compress with: -input: [ -"Asked about authentication", -"JWT tokens with 24h expiry", -"Auth System Exploration", -"Auth: JWT 24h expiry, bcrypt passwords, refresh rotation. Files: auth.ts, tokens.ts, middleware/auth.ts" -] -] - - - -Assistant: [Just finished reading auth.ts] -I've read the auth file and now need to make edits based on it. I'm keeping this in context rather than compressing. - +THE FORMAT OF COMPRESS +`topic`: Short label (3-5 words) for display - e.g., "Auth System Exploration" + `content`: Object containing: + `startString`: Unique text string marking the beginning of the range + `endString`: Unique text string marking the end of the range + `summary`: Complete technical summary replacing all content in the range \ No newline at end of file diff --git a/lib/prompts/distill.md b/lib/prompts/distill.md index d5ec732a..8e7dee96 100644 --- a/lib/prompts/distill.md +++ b/lib/prompts/distill.md @@ -23,6 +23,6 @@ UNCERTAINTY REMAINS: If you might need to re-examine the original, defer. Distil 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 -`items`: Array of objects, each containing: +`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/tools/compress.ts b/lib/tools/compress.ts index b1bcc233..68ac5a56 100644 --- a/lib/tools/compress.ts +++ b/lib/tools/compress.ts @@ -19,11 +19,22 @@ export function createCompressTool(ctx: PruneToolContext): ReturnType).", + "Each target must have an id (numeric string from ).", ) } - if (!item.distillation || typeof item.distillation !== "string") { - ctx.logger.debug("Distill item missing distillation: " + JSON.stringify(item)) - throw new Error("Each item must have a distillation string.") + 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.items.map((item) => item.id) - const distillations = args.items.map((item) => item.distillation) + const ids = args.targets.map((t) => t.id) + const distillations = args.targets.map((t) => t.distillation) return executePruneOperation( ctx, From cf90194639ff33e9db1985b7dfe7f52b63429c07 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 3 Feb 2026 20:12:16 -0500 Subject: [PATCH 086/113] fix: sync package-lock.json with package.json --- package-lock.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 317b7cd1..d9abd5ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "@tarquinen/opencode-dcp", "version": "1.3.3-beta.0", - "license": "MIT", + "license": "AGPL-3.0-or-later", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", "@opencode-ai/sdk": "^1.1.48", @@ -16,7 +16,7 @@ "zod": "^4.3.6" }, "devDependencies": { - "@opencode-ai/plugin": "^1.1.48", + "@opencode-ai/plugin": "^1.1.49", "@types/node": "^25.1.0", "prettier": "^3.8.1", "tsx": "^4.21.0", @@ -494,13 +494,13 @@ } }, "node_modules/@opencode-ai/plugin": { - "version": "1.1.48", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.1.48.tgz", - "integrity": "sha512-KkaSMevXmz7tOwYDMJeWiXE5N8LmRP18qWI5Xhv3+c+FdGPL+l1hQrjSgyv3k7Co7qpCyW3kAUESBB7BzIOl2w==", + "version": "1.1.49", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.1.49.tgz", + "integrity": "sha512-+FEE730fLJtoHCta5MXixOIzI9Cjos700QDNnAx6mA8YjFzO+kABnyqLQrCgZ9wUPJgiKH9bnHxT7AdRjWsNPw==", "dev": true, "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.1.48", + "@opencode-ai/sdk": "1.1.49", "zod": "4.1.8" } }, @@ -515,9 +515,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.1.48", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.48.tgz", - "integrity": "sha512-j5/79X45fUPWVD2Ffm/qvwLclDCdPeV+TYMDrm9to0p4pmzhmeKevCsyiRdLg0o0HE3AFRUnOo2rdO9NetN79A==", + "version": "1.1.49", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.49.tgz", + "integrity": "sha512-F5ZkgiqOiV+z3U4zeBLvrmNZv5MwNFMTWM+HWhChD+/UEswIebQKk9UMz9lPX4fswexIJdFPwFI/TBdNyZfKMg==", "license": "MIT" }, "node_modules/@types/node": { From 8b79e95e1854c06518336c2bdcc78fd14fb12927 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 3 Feb 2026 20:13:11 -0500 Subject: [PATCH 087/113] style: fix formatting --- lib/prompts/compress.md | 8 ++++---- lib/prompts/distill.md | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/prompts/compress.md b/lib/prompts/compress.md index fed81069..8479067a 100644 --- a/lib/prompts/compress.md +++ b/lib/prompts/compress.md @@ -29,7 +29,7 @@ You specify boundaries by matching unique text strings in the conversation. CRIT THE FORMAT OF COMPRESS `topic`: Short label (3-5 words) for display - e.g., "Auth System Exploration" - `content`: Object containing: - `startString`: Unique text string marking the beginning of the range - `endString`: Unique text string marking the end of the range - `summary`: Complete technical summary replacing all content in the range \ No newline at end of file +`content`: Object containing: +`startString`: Unique text string marking the beginning of the range +`endString`: Unique text string marking the end of the range +`summary`: Complete technical summary replacing all content in the range diff --git a/lib/prompts/distill.md b/lib/prompts/distill.md index 8e7dee96..c5e6df18 100644 --- a/lib/prompts/distill.md +++ b/lib/prompts/distill.md @@ -24,5 +24,5 @@ Before distilling, ask yourself: _"Will I need the raw output for upcoming 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 +`id`: Numeric ID (as string) from the `` list +`distillation`: Complete technical substitute for that tool output From b8ace8305575bdb3d18f33adb050f72f7906a95f Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Wed, 4 Feb 2026 02:21:11 +0100 Subject: [PATCH 088/113] fix compress ask permission overwrite --- index.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/index.ts b/index.ts index 87b6feab..577a0b06 100644 --- a/index.ts +++ b/index.ts @@ -113,13 +113,15 @@ const plugin: Plugin = (async (ctx) => { `Added ${toolsToAdd.map((t) => `'${t}'`).join(" and ")} to experimental.primary_tools via config mutation`, ) - // Set compress permission to ask + // Set compress permission to ask (only if not already configured) if (config.tools.compress.enabled) { const permission = opencodeConfig.permission ?? {} - opencodeConfig.permission = { - ...permission, - compress: "ask", - } as typeof permission + if (!("compress" in permission)) { + opencodeConfig.permission = { + ...permission, + compress: "ask", + } as typeof permission + } } } }, From 1d8e0000930312932967e11e813daff384dd56fd Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:58:16 +0100 Subject: [PATCH 089/113] vocab mix --- lib/prompts/compress.md | 4 ++-- lib/prompts/distill.md | 4 ++-- lib/prompts/prune.md | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/prompts/compress.md b/lib/prompts/compress.md index 8479067a..a58af35c 100644 --- a/lib/prompts/compress.md +++ b/lib/prompts/compress.md @@ -6,7 +6,7 @@ THE PHILOSOPHY OF COMPRESS 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 COMPLETE. Capture file paths, function signatures, decisions made, constraints discovered, key findings... EVERYTHING that maintains context integrity. This is not a brief note - it is a technical substitute so faithful that the original conversation adds no value. +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. 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. @@ -22,7 +22,7 @@ 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: _"Am I certain this phase is complete?"_ Compression is irreversible. The summary replaces everything in the range. +Before compressing, ask: _"Is this chapter closed?"_ Compression is irreversible. The summary replaces everything in the range. BOUNDARY MATCHING You specify boundaries by matching unique text strings in the conversation. CRITICAL: In code-centric conversations, strings repeat often. Provide sufficiently unique text to match exactly once. If a match fails (not found or found multiple times), the tool will error - extend your boundary string with more surrounding context in order to make SURE the tool does NOT error. diff --git a/lib/prompts/distill.md b/lib/prompts/distill.md index c5e6df18..39a78cc9 100644 --- a/lib/prompts/distill.md +++ b/lib/prompts/distill.md @@ -8,12 +8,12 @@ THE PHILOSOPHY OF DISTILLATION 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. -BE STRATEGIC! 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. +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, crystallized, is sufficient. +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: diff --git a/lib/prompts/prune.md b/lib/prompts/prune.md index 2bbab63e..be18b009 100644 --- a/lib/prompts/prune.md +++ b/lib/prompts/prune.md @@ -1,18 +1,18 @@ Use this tool to remove tool outputs from context entirely. No preservation - pure deletion. THE PRUNABLE TOOLS LIST -A will show in context when outputs are available for pruning. 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. +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 a blunt instrument for eliminating noise (irrelevant or unhelpful outputs that provide no value), or superseded information (older outputs replaced by newer, more accurate data), wrong target (you read or accessed something that turned out to be irrelevant). Use it judiciously to maintain a clean and relevant context. +`prune` is surgical excision - 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. -BE STRATEGIC! Prune is most effective when batched. Don't prune a single tiny output - wait until you have several items (depending on context occupation of those noisy outputs). +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: _"Will I need this output for upcoming work?"_ If yes, keep it. Pruning that forces re-fetching is a net loss. +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 From 01cfafbb9befbb7b6bd9d486dc80665c307dfa8d Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:08:04 +0100 Subject: [PATCH 090/113] small compress change --- lib/prompts/compress.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/prompts/compress.md b/lib/prompts/compress.md index a58af35c..6a083297 100644 --- a/lib/prompts/compress.md +++ b/lib/prompts/compress.md @@ -10,8 +10,8 @@ Your summary must be EXHAUSTIVE. Capture file paths, function signatures, decisi 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. -WHEN TO COMPRESS -Compress when a phase of work is truly complete and the raw conversation is no longer needed: +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 From ff89f13cdb8d14a5b1451085e6a3f6f7154af027 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:11:16 +0100 Subject: [PATCH 091/113] system timing --- lib/prompts/system.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/prompts/system.md b/lib/prompts/system.md index 4b296313..5116a7f4 100644 --- a/lib/prompts/system.md +++ b/lib/prompts/system.md @@ -25,6 +25,9 @@ Make sure to match enough of the context with start and end strings so you're no 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. +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. + 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 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. From 451fb0063e7eea09d3dfcf18fdd0d72233455574 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:13:44 +0100 Subject: [PATCH 092/113] typos --- lib/prompts/system.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/prompts/system.md b/lib/prompts/system.md index 5116a7f4..4ea2b6b1 100644 --- a/lib/prompts/system.md +++ b/lib/prompts/system.md @@ -12,7 +12,7 @@ AVAILABLE TOOLS FOR CONTEXT MANAGEMENT THE COMPRESS TOOL -`compress` is sledge hammer 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. +`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. 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. @@ -32,7 +32,7 @@ EVALUATE YOUR CONTEXT AND MANAGE REGULARLY TO AVOID CONTEXT ROT. AVOID USING MAN 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. -Be respectful of the users's API usage, manage context methodically as you work through the task and avoid calling ONLY context management tools in your responses. +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. From daa8d465f2e0bfc982ff49bbd253cc04c1ffdd49 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 4 Feb 2026 11:42:07 -0500 Subject: [PATCH 093/113] Order DCP tool listings --- README.md | 8 ++++---- index.ts | 2 +- lib/config.ts | 40 ++++++++++++++++++++-------------------- lib/messages/inject.ts | 2 +- scripts/print.ts | 14 +++++++------- 5 files changed, 33 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index de290c47..97e1ea9c 100644 --- a/README.md +++ b/README.md @@ -107,10 +107,6 @@ DCP uses its own config file: > // Additional tools to protect from pruning > "protectedTools": [], > }, -> // Removes tool content from context without preservation (for completed tasks or noise) -> "prune": { -> "enabled": true, -> }, > // Distills key findings into preserved knowledge before removing raw content > "distill": { > "enabled": true, @@ -123,6 +119,10 @@ DCP uses its own config file: > // Show summary content as an ignored message notification > "showCompression": true, > }, +> // Removes tool content from context without preservation (for completed tasks or noise) +> "prune": { +> "enabled": true, +> }, > }, > // Automatic pruning strategies > "strategies": { diff --git a/index.ts b/index.ts index 577a0b06..8aafd5c5 100644 --- a/index.ts +++ b/index.ts @@ -99,9 +99,9 @@ const plugin: Plugin = (async (ctx) => { } const toolsToAdd: string[] = [] - if (config.tools.prune.enabled) toolsToAdd.push("prune") if (config.tools.distill.enabled) toolsToAdd.push("distill") if (config.tools.compress.enabled) toolsToAdd.push("compress") + if (config.tools.prune.enabled) toolsToAdd.push("prune") if (toolsToAdd.length > 0) { const existingPrimaryTools = opencodeConfig.experimental?.primary_tools ?? [] diff --git a/lib/config.ts b/lib/config.ts index bfac6dbd..e32faf40 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -31,9 +31,9 @@ export interface ToolSettings { export interface Tools { settings: ToolSettings - prune: PruneTool distill: DistillTool compress: CompressTool + prune: PruneTool } export interface Commands { @@ -76,9 +76,9 @@ const DEFAULT_PROTECTED_TOOLS = [ "task", "todowrite", "todoread", - "prune", "distill", "compress", + "prune", "batch", "plan_enter", "plan_exit", @@ -105,14 +105,14 @@ export const VALID_CONFIG_KEYS = new Set([ "tools.settings.nudgeEnabled", "tools.settings.nudgeFrequency", "tools.settings.protectedTools", - "tools.prune", - "tools.prune.enabled", "tools.distill", "tools.distill.enabled", "tools.distill.showDistillation", "tools.compress", "tools.compress.enabled", "tools.compress.showCompression", + "tools.prune", + "tools.prune.enabled", "strategies", // strategies.deduplication "strategies.deduplication", @@ -288,15 +288,6 @@ function validateConfigTypes(config: Record): ValidationError[] { }) } } - if (tools.prune) { - if (tools.prune.enabled !== undefined && typeof tools.prune.enabled !== "boolean") { - errors.push({ - key: "tools.prune.enabled", - expected: "boolean", - actual: typeof tools.prune.enabled, - }) - } - } if (tools.distill) { if (tools.distill.enabled !== undefined && typeof tools.distill.enabled !== "boolean") { errors.push({ @@ -338,6 +329,15 @@ function validateConfigTypes(config: Record): ValidationError[] { }) } } + if (tools.prune) { + if (tools.prune.enabled !== undefined && typeof tools.prune.enabled !== "boolean") { + errors.push({ + key: "tools.prune.enabled", + expected: "boolean", + actual: typeof tools.prune.enabled, + }) + } + } } // Strategies validators @@ -483,9 +483,6 @@ const defaultConfig: PluginConfig = { nudgeFrequency: 10, protectedTools: [...DEFAULT_PROTECTED_TOOLS], }, - prune: { - enabled: true, - }, distill: { enabled: true, showDistillation: false, @@ -494,6 +491,9 @@ const defaultConfig: PluginConfig = { enabled: true, showCompression: true, }, + prune: { + enabled: true, + }, }, strategies: { deduplication: { @@ -659,9 +659,6 @@ function mergeTools( ]), ], }, - prune: { - enabled: override.prune?.enabled ?? base.prune.enabled, - }, distill: { enabled: override.distill?.enabled ?? base.distill.enabled, showDistillation: override.distill?.showDistillation ?? base.distill.showDistillation, @@ -670,6 +667,9 @@ function mergeTools( enabled: override.compress?.enabled ?? base.compress.enabled, showCompression: override.compress?.showCompression ?? base.compress.showCompression, }, + prune: { + enabled: override.prune?.enabled ?? base.prune.enabled, + }, } } @@ -699,9 +699,9 @@ function deepCloneConfig(config: PluginConfig): PluginConfig { ...config.tools.settings, protectedTools: [...config.tools.settings.protectedTools], }, - prune: { ...config.tools.prune }, distill: { ...config.tools.distill }, compress: { ...config.tools.compress }, + prune: { ...config.tools.prune }, }, strategies: { deduplication: { diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 629e7e80..d9ef191c 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -36,9 +36,9 @@ export const wrapCooldownMessage = (flags: { compress: boolean }): string => { const enabledTools: string[] = [] - if (flags.prune) enabledTools.push("prune") 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) { diff --git a/scripts/print.ts b/scripts/print.ts index 8d795cd3..484bc023 100644 --- a/scripts/print.ts +++ b/scripts/print.ts @@ -10,9 +10,9 @@ import { const args = process.argv.slice(2) const flags: ToolFlags = { - prune: args.includes("-p") || args.includes("--prune"), distill: args.includes("-d") || args.includes("--distill"), compress: args.includes("-c") || args.includes("--compress"), + prune: args.includes("-p") || args.includes("--prune"), } // Default to all enabled if none specified @@ -34,7 +34,7 @@ if ( (!showSystem && !showNudge && !showPruneList && !showCompressContext && !showCooldown) ) { console.log(` -Usage: bun run dcp [TYPE] [-p] [-d] [-c] +Usage: bun run dcp [TYPE] [-d] [-c] [-p] Types: --system System prompt @@ -44,14 +44,14 @@ Types: --cooldown Cooldown message after pruning Tool flags (for --system and --nudge): - -p, --prune Enable prune tool -d, --distill Enable distill tool -c, --compress Enable compress tool + -p, --prune Enable prune tool If no tool flags specified, all are enabled. Examples: - bun run dcp --system -p -d -c # System prompt with all tools + bun run dcp --system -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 @@ -68,9 +68,9 @@ const header = (title: string) => { if (showSystem) { const enabled = [ - flags.prune && "prune", flags.distill && "distill", flags.compress && "compress", + flags.prune && "prune", ] .filter(Boolean) .join(", ") @@ -80,9 +80,9 @@ if (showSystem) { if (showNudge) { const enabled = [ - flags.prune && "prune", flags.distill && "distill", flags.compress && "compress", + flags.prune && "prune", ] .filter(Boolean) .join(", ") @@ -106,9 +106,9 @@ if (showCompressContext) { if (showCooldown) { const enabled = [ - flags.prune && "prune", flags.distill && "distill", flags.compress && "compress", + flags.prune && "prune", ] .filter(Boolean) .join(", ") From b2276c7fd2fc57853ac46438158f0edff104d821 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 4 Feb 2026 12:50:46 -0500 Subject: [PATCH 094/113] Implement contextLimit config and update system prompts --- README.md | 2 ++ dcp.schema.json | 13 +++++++++++++ lib/config.ts | 16 ++++++++++++++++ lib/messages/inject.ts | 10 +++++++++- lib/prompts/system.md | 3 +++ 5 files changed, 43 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 97e1ea9c..764247b4 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,8 @@ DCP uses its own config file: > // Nudge the LLM to use prune tools (every tool results) > "nudgeEnabled": true, > "nudgeFrequency": 10, +> // Encourages the model to stay within this context budget (not a hard limit); set to "model" to use full model capacity +> "contextLimit": 100000, > // Additional tools to protect from pruning > "protectedTools": [], > }, diff --git a/dcp.schema.json b/dcp.schema.json index d044098f..6a75c1ca 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -108,6 +108,19 @@ }, "default": [], "description": "Tool names that should be protected from automatic pruning" + }, + "contextLimit": { + "description": "Context limit used for the prunable-tools header ("model" uses the active model's context limit)", + "default": 100000, + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["model"] + } + ] } } }, diff --git a/lib/config.ts b/lib/config.ts index e32faf40..714daffb 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -27,6 +27,7 @@ export interface ToolSettings { nudgeEnabled: boolean nudgeFrequency: number protectedTools: string[] + contextLimit: number | "model" } export interface Tools { @@ -105,6 +106,7 @@ export const VALID_CONFIG_KEYS = new Set([ "tools.settings.nudgeEnabled", "tools.settings.nudgeFrequency", "tools.settings.protectedTools", + "tools.settings.contextLimit", "tools.distill", "tools.distill.enabled", "tools.distill.showDistillation", @@ -287,6 +289,18 @@ function validateConfigTypes(config: Record): ValidationError[] { actual: typeof tools.settings.protectedTools, }) } + if (tools.settings.contextLimit !== undefined) { + if ( + typeof tools.settings.contextLimit !== "number" && + tools.settings.contextLimit !== "model" + ) { + errors.push({ + key: "tools.settings.contextLimit", + expected: 'number | "model"', + actual: JSON.stringify(tools.settings.contextLimit), + }) + } + } } if (tools.distill) { if (tools.distill.enabled !== undefined && typeof tools.distill.enabled !== "boolean") { @@ -482,6 +496,7 @@ const defaultConfig: PluginConfig = { nudgeEnabled: true, nudgeFrequency: 10, protectedTools: [...DEFAULT_PROTECTED_TOOLS], + contextLimit: 100000, }, distill: { enabled: true, @@ -658,6 +673,7 @@ function mergeTools( ...(override.settings?.protectedTools ?? []), ]), ], + contextLimit: override.settings?.contextLimit ?? base.settings.contextLimit, }, distill: { enabled: override.distill?.enabled ?? base.distill.enabled, diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index d9ef191c..0470237c 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -132,9 +132,17 @@ const buildPrunableToolsList = ( return "" } + const configLimit = + config.tools.settings.contextLimit === "model" + ? state.modelContextLimit + : config.tools.settings.contextLimit + const contextInfo: ContextInfo = { used: getCurrentTokenUsage(messages), - limit: state.modelContextLimit, + limit: + state.modelContextLimit !== undefined && configLimit !== undefined + ? Math.min(state.modelContextLimit, configLimit) + : (configLimit ?? state.modelContextLimit), } return wrapPrunableTools(lines.join("\n"), contextInfo) diff --git a/lib/prompts/system.md b/lib/prompts/system.md index 4ea2b6b1..2f5d0f09 100644 --- a/lib/prompts/system.md +++ b/lib/prompts/system.md @@ -14,6 +14,8 @@ AVAILABLE TOOLS FOR CONTEXT MANAGEMENT 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. +When the context usage indicator is high (around 80% or above), prioritize using `compress` to reduce verbosity and keep room for future context. + 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. Make sure to match enough of the context with start and end strings so you're not faced with an error calling the tool. Be VERY CAREFUL AND CONSERVATIVE when using `compress`. @@ -37,6 +39,7 @@ Be respectful of the user's API usage, manage context methodically as you work t 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. +Aim to keep the context usage indicator below roughly 80% so there is room for future turns and tool outputs. 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. From 3877f318c3039a598094db399c3416a1feb28a14 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 4 Feb 2026 13:00:14 -0500 Subject: [PATCH 095/113] Fix schema syntax and format --- dcp.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dcp.schema.json b/dcp.schema.json index 6a75c1ca..11e4d3ec 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -110,7 +110,7 @@ "description": "Tool names that should be protected from automatic pruning" }, "contextLimit": { - "description": "Context limit used for the prunable-tools header ("model" uses the active model's context limit)", + "description": "Context limit used for the prunable-tools header (\"model\" uses the active model's context limit)", "default": 100000, "oneOf": [ { From b4f8765be2d95aa33863ef32e230483390bb1f52 Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Wed, 4 Feb 2026 12:39:04 -0600 Subject: [PATCH 096/113] fix: respect XDG base directory env vars for config and log paths Config loader now checks XDG_CONFIG_HOME before falling back to ~/.config/opencode. Logger now uses XDG_DATA_HOME (defaulting to ~/.local/share) for log storage, which is where runtime data belongs per the XDG Base Directory Specification. No behavior change for users with default (unset) XDG paths. --- lib/config.ts | 4 +++- lib/logger.ts | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index 714daffb..2eb12cdf 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -526,7 +526,9 @@ const defaultConfig: PluginConfig = { }, } -const GLOBAL_CONFIG_DIR = join(homedir(), ".config", "opencode") +const GLOBAL_CONFIG_DIR = process.env.XDG_CONFIG_HOME + ? join(process.env.XDG_CONFIG_HOME, "opencode") + : join(homedir(), ".config", "opencode") const GLOBAL_CONFIG_PATH_JSONC = join(GLOBAL_CONFIG_DIR, "dcp.jsonc") const GLOBAL_CONFIG_PATH_JSON = join(GLOBAL_CONFIG_DIR, "dcp.json") diff --git a/lib/logger.ts b/lib/logger.ts index 972a1fb1..79aff818 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -9,8 +9,9 @@ export class Logger { constructor(enabled: boolean) { this.enabled = enabled - const opencodeConfigDir = join(homedir(), ".config", "opencode") - this.logDir = join(opencodeConfigDir, "logs", "dcp") + const dataHome = + process.env.XDG_DATA_HOME || join(homedir(), ".local", "share") + this.logDir = join(dataHome, "opencode", "logs", "dcp") } private async ensureLogDir() { From 4ea85914d9f33bcf242156dda7f86a848413440e Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 4 Feb 2026 14:35:45 -0500 Subject: [PATCH 097/113] Add apply_patch and multiedit support to file path extraction --- lib/commands/sweep.ts | 12 ++++---- lib/messages/inject.ts | 9 ++++-- lib/messages/utils.ts | 21 ++++++++++++-- lib/protected-file-patterns.ts | 45 +++++++++++++++++++++++++----- lib/strategies/deduplication.ts | 6 ++-- lib/strategies/purge-errors.ts | 6 ++-- lib/strategies/supersede-writes.ts | 9 +++--- lib/tools/prune-shared.ts | 8 +++--- 8 files changed, 83 insertions(+), 33 deletions(-) diff --git a/lib/commands/sweep.ts b/lib/commands/sweep.ts index fa2b6516..d24cc464 100644 --- a/lib/commands/sweep.ts +++ b/lib/commands/sweep.ts @@ -16,7 +16,7 @@ import { getCurrentParams, calculateTokensSaved } from "../strategies/utils" import { buildToolIdList, isIgnoredUserMessage } from "../messages/utils" import { saveSessionState } from "../state/persistence" import { isMessageCompacted } from "../shared-utils" -import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" +import { getFilePathsFromParameters, isProtected } from "../protected-file-patterns" export interface SweepCommandContext { client: any @@ -172,9 +172,9 @@ export async function handleSweepCommand(ctx: SweepCommandContext): Promise { } return parameters.filePath } - if (tool === "write" && parameters.filePath) { + if ((tool === "write" || tool === "edit" || tool === "multiedit") && parameters.filePath) { return parameters.filePath } - if (tool === "edit" && 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") { diff --git a/lib/protected-file-patterns.ts b/lib/protected-file-patterns.ts index 3370e20b..ecd138f9 100644 --- a/lib/protected-file-patterns.ts +++ b/lib/protected-file-patterns.ts @@ -65,18 +65,49 @@ export function matchesGlob(inputPath: string, pattern: string): boolean { return new RegExp(regex).test(input) } -export function getFilePathFromParameters(parameters: unknown): string | undefined { +export function getFilePathsFromParameters(tool: string, parameters: unknown): string[] { if (typeof parameters !== "object" || parameters === null) { - return undefined + return [] } - const filePath = (parameters as Record).filePath - return typeof filePath === "string" && filePath.length > 0 ? filePath : undefined + const paths: string[] = [] + const params = parameters as Record + + // 1. apply_patch uses patchText with embedded paths + if (tool === "apply_patch" && typeof params.patchText === "string") { + const pathRegex = /\*\*\* (?:Add|Delete|Update) File: ([^\n\r]+)/g + let match + while ((match = pathRegex.exec(params.patchText)) !== null) { + paths.push(match[1].trim()) + } + } + + // 2. multiedit uses top-level filePath and nested edits array + if (tool === "multiedit") { + if (typeof params.filePath === "string") { + paths.push(params.filePath) + } + if (Array.isArray(params.edits)) { + for (const edit of params.edits) { + if (edit && typeof edit.filePath === "string") { + paths.push(edit.filePath) + } + } + } + } + + // 3. Default check for common filePath parameter (read, write, edit, etc) + if (typeof params.filePath === "string") { + paths.push(params.filePath) + } + + // Return unique non-empty paths + return [...new Set(paths)].filter((p) => p.length > 0) } -export function isProtectedFilePath(filePath: string | undefined, patterns: string[]): boolean { - if (!filePath) return false +export function isProtected(filePaths: string[], patterns: string[]): boolean { + if (!filePaths || filePaths.length === 0) return false if (!patterns || patterns.length === 0) return false - return patterns.some((pattern) => matchesGlob(filePath, pattern)) + return filePaths.some((path) => patterns.some((pattern) => matchesGlob(path, pattern))) } diff --git a/lib/strategies/deduplication.ts b/lib/strategies/deduplication.ts index 9d909dc5..9b1d04e8 100644 --- a/lib/strategies/deduplication.ts +++ b/lib/strategies/deduplication.ts @@ -2,7 +2,7 @@ import { PluginConfig } from "../config" import { Logger } from "../logger" import type { SessionState, WithParts } from "../state" import { buildToolIdList } from "../messages/utils" -import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" +import { getFilePathsFromParameters, isProtected } from "../protected-file-patterns" import { calculateTokensSaved } from "./utils" /** @@ -50,8 +50,8 @@ export const deduplicate = ( continue } - const filePath = getFilePathFromParameters(metadata.parameters) - if (isProtectedFilePath(filePath, config.protectedFilePatterns)) { + const filePaths = getFilePathsFromParameters(metadata.tool, metadata.parameters) + if (isProtected(filePaths, config.protectedFilePatterns)) { continue } diff --git a/lib/strategies/purge-errors.ts b/lib/strategies/purge-errors.ts index 48b4ad5e..1085fd6c 100644 --- a/lib/strategies/purge-errors.ts +++ b/lib/strategies/purge-errors.ts @@ -2,7 +2,7 @@ import { PluginConfig } from "../config" import { Logger } from "../logger" import type { SessionState, WithParts } from "../state" import { buildToolIdList } from "../messages/utils" -import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" +import { getFilePathsFromParameters, isProtected } from "../protected-file-patterns" import { calculateTokensSaved } from "./utils" /** @@ -52,8 +52,8 @@ export const purgeErrors = ( continue } - const filePath = getFilePathFromParameters(metadata.parameters) - if (isProtectedFilePath(filePath, config.protectedFilePatterns)) { + const filePaths = getFilePathsFromParameters(metadata.tool, metadata.parameters) + if (isProtected(filePaths, config.protectedFilePatterns)) { continue } diff --git a/lib/strategies/supersede-writes.ts b/lib/strategies/supersede-writes.ts index 5d940242..e4e48e00 100644 --- a/lib/strategies/supersede-writes.ts +++ b/lib/strategies/supersede-writes.ts @@ -2,7 +2,7 @@ import { PluginConfig } from "../config" import { Logger } from "../logger" import type { SessionState, WithParts } from "../state" import { buildToolIdList } from "../messages/utils" -import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" +import { getFilePathsFromParameters, isProtected } from "../protected-file-patterns" import { calculateTokensSaved } from "./utils" /** @@ -49,12 +49,13 @@ export const supersedeWrites = ( continue } - const filePath = getFilePathFromParameters(metadata.parameters) - if (!filePath) { + const filePaths = getFilePathsFromParameters(metadata.tool, metadata.parameters) + if (filePaths.length === 0) { continue } + const filePath = filePaths[0] - if (isProtectedFilePath(filePath, config.protectedFilePatterns)) { + if (isProtected(filePaths, config.protectedFilePatterns)) { continue } diff --git a/lib/tools/prune-shared.ts b/lib/tools/prune-shared.ts index 5236cbde..cd6c9abb 100644 --- a/lib/tools/prune-shared.ts +++ b/lib/tools/prune-shared.ts @@ -9,7 +9,7 @@ import { formatPruningResultForTool } from "../ui/utils" import { ensureSessionInitialized } from "../state" import { saveSessionState } from "../state/persistence" import { calculateTokensSaved, getCurrentParams } from "../strategies/utils" -import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" +import { getFilePathsFromParameters, isProtected } from "../protected-file-patterns" // Shared logic for executing prune operations. export async function executePruneOperation( @@ -91,13 +91,13 @@ export async function executePruneOperation( continue } - const filePath = getFilePathFromParameters(metadata.parameters) - if (isProtectedFilePath(filePath, config.protectedFilePatterns)) { + 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, - filePath, + filePaths, }) skippedIds.push(index.toString()) continue From e916cabc0fd60fc52c9d12e1c11111f47e136b33 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 4 Feb 2026 14:54:44 -0500 Subject: [PATCH 098/113] Set showCompression to false by default and sync schema --- README.md | 2 +- dcp.schema.json | 4 ++-- lib/config.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 764247b4..9ca3ba58 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ DCP uses its own config file: > "compress": { > "enabled": true, > // Show summary content as an ignored message notification -> "showCompression": true, +> "showCompression": false, > }, > // Removes tool content from context without preservation (for completed tasks or noise) > "prune": { diff --git a/dcp.schema.json b/dcp.schema.json index 11e4d3ec..32a1398f 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -153,7 +153,7 @@ }, "showCompression": { "type": "boolean", - "default": true, + "default": false, "description": "Show summary output in the UI" } } @@ -204,7 +204,7 @@ "properties": { "enabled": { "type": "boolean", - "default": false, + "default": true, "description": "Enable supersede writes strategy" } } diff --git a/lib/config.ts b/lib/config.ts index 714daffb..f786eb7b 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -504,7 +504,7 @@ const defaultConfig: PluginConfig = { }, compress: { enabled: true, - showCompression: true, + showCompression: false, }, prune: { enabled: true, From 9f3903f793d2e9cac231ff476d6cb6531610e613 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 4 Feb 2026 14:59:18 -0500 Subject: [PATCH 099/113] v1.4.0-beta.0 - Bump version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d9abd5ae..d4820653 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tarquinen/opencode-dcp", - "version": "1.3.3-beta.0", + "version": "1.4.0-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "1.3.3-beta.0", + "version": "1.4.0-beta.0", "license": "AGPL-3.0-or-later", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", diff --git a/package.json b/package.json index 235d3f0c..912eb337 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@tarquinen/opencode-dcp", - "version": "1.3.3-beta.0", + "version": "1.4.0-beta.0", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js", From 29191fe2923d2720c02ad1cd08ec23560fc206ad Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Wed, 4 Feb 2026 15:33:58 -0600 Subject: [PATCH 100/113] fix: respect XDG_DATA_HOME in state persistence storage path STORAGE_DIR in persistence.ts also hardcoded ~/.local/share instead of checking XDG_DATA_HOME. Same pattern as the logger fix. --- lib/state/persistence.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/state/persistence.ts b/lib/state/persistence.ts index e73f78f0..0c368380 100644 --- a/lib/state/persistence.ts +++ b/lib/state/persistence.ts @@ -25,7 +25,13 @@ export interface PersistedSessionState { lastUpdated: string } -const STORAGE_DIR = join(homedir(), ".local", "share", "opencode", "storage", "plugin", "dcp") +const STORAGE_DIR = join( + process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), + "opencode", + "storage", + "plugin", + "dcp", +) async function ensureStorageDir(): Promise { if (!existsSync(STORAGE_DIR)) { From 7971886f4a92b5f792b14ea901e37ee08acbfbf5 Mon Sep 17 00:00:00 2001 From: Spoon <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Thu, 5 Feb 2026 01:10:40 +0100 Subject: [PATCH 101/113] Replace compress enabled with permission enum (ask/allow/deny) --- README.md | 3 ++- dcp.schema.json | 9 +++++---- index.ts | 18 ++++++++---------- lib/config.ts | 26 +++++++++++++------------- lib/hooks.ts | 2 +- lib/messages/inject.ts | 6 +++--- 6 files changed, 32 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 9ca3ba58..90609410 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,8 @@ DCP uses its own config file: > }, > // Collapses a range of conversation content into a single summary > "compress": { -> "enabled": true, +> // Permission mode: "ask" (prompt), "allow" (no prompt), "deny" (tool not registered) +> "permission": "ask", > // Show summary content as an ignored message notification > "showCompression": false, > }, diff --git a/dcp.schema.json b/dcp.schema.json index 32a1398f..fcfe2e68 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -146,10 +146,11 @@ "description": "Configuration for the compress tool", "additionalProperties": false, "properties": { - "enabled": { - "type": "boolean", - "default": true, - "description": "Enable the compress tool" + "permission": { + "type": "string", + "enum": ["ask", "allow", "deny"], + "default": "ask", + "description": "Permission mode (deny disables the tool)" }, "showCompression": { "type": "boolean", diff --git a/index.ts b/index.ts index 8aafd5c5..def671bd 100644 --- a/index.ts +++ b/index.ts @@ -70,7 +70,7 @@ const plugin: Plugin = (async (ctx) => { workingDirectory: ctx.directory, }), }), - ...(config.tools.compress.enabled && { + ...(config.tools.compress.permission !== "deny" && { compress: createCompressTool({ client: ctx.client, state, @@ -100,7 +100,7 @@ const plugin: Plugin = (async (ctx) => { const toolsToAdd: string[] = [] if (config.tools.distill.enabled) toolsToAdd.push("distill") - if (config.tools.compress.enabled) toolsToAdd.push("compress") + if (config.tools.compress.permission !== "deny") toolsToAdd.push("compress") if (config.tools.prune.enabled) toolsToAdd.push("prune") if (toolsToAdd.length > 0) { @@ -113,15 +113,13 @@ const plugin: Plugin = (async (ctx) => { `Added ${toolsToAdd.map((t) => `'${t}'`).join(" and ")} to experimental.primary_tools via config mutation`, ) - // Set compress permission to ask (only if not already configured) - if (config.tools.compress.enabled) { + // Set compress permission from DCP config + if (config.tools.compress.permission !== "deny") { const permission = opencodeConfig.permission ?? {} - if (!("compress" in permission)) { - opencodeConfig.permission = { - ...permission, - compress: "ask", - } as typeof permission - } + opencodeConfig.permission = { + ...permission, + compress: config.tools.compress.permission, + } as typeof permission } } }, diff --git a/lib/config.ts b/lib/config.ts index f786eb7b..1e772293 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -19,7 +19,7 @@ export interface DistillTool { } export interface CompressTool { - enabled: boolean + permission: "ask" | "allow" | "deny" showCompression: boolean } @@ -111,7 +111,7 @@ export const VALID_CONFIG_KEYS = new Set([ "tools.distill.enabled", "tools.distill.showDistillation", "tools.compress", - "tools.compress.enabled", + "tools.compress.permission", "tools.compress.showCompression", "tools.prune", "tools.prune.enabled", @@ -322,15 +322,15 @@ function validateConfigTypes(config: Record): ValidationError[] { } } if (tools.compress) { - if ( - tools.compress.enabled !== undefined && - typeof tools.compress.enabled !== "boolean" - ) { - errors.push({ - key: "tools.compress.enabled", - expected: "boolean", - actual: typeof tools.compress.enabled, - }) + if (tools.compress.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), + }) + } } if ( tools.compress.showCompression !== undefined && @@ -503,7 +503,7 @@ const defaultConfig: PluginConfig = { showDistillation: false, }, compress: { - enabled: true, + permission: "ask", showCompression: false, }, prune: { @@ -680,7 +680,7 @@ function mergeTools( showDistillation: override.distill?.showDistillation ?? base.distill.showDistillation, }, compress: { - enabled: override.compress?.enabled ?? base.compress.enabled, + permission: override.compress?.permission ?? base.compress.permission, showCompression: override.compress?.showCompression ?? base.compress.showCompression, }, prune: { diff --git a/lib/hooks.ts b/lib/hooks.ts index 508b98f0..6e113705 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -45,7 +45,7 @@ export function createSystemPromptHandler( const flags = { prune: config.tools.prune.enabled, distill: config.tools.distill.enabled, - compress: config.tools.compress.enabled, + compress: config.tools.compress.permission !== "deny", } if (!flags.prune && !flags.distill && !flags.compress) { diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index daa00e72..b327b675 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -59,7 +59,7 @@ const getNudgeString = (config: PluginConfig): string => { const flags = { prune: config.tools.prune.enabled, distill: config.tools.distill.enabled, - compress: config.tools.compress.enabled, + compress: config.tools.compress.permission !== "deny", } if (!flags.prune && !flags.distill && !flags.compress) { @@ -73,7 +73,7 @@ const getCooldownMessage = (config: PluginConfig): string => { return wrapCooldownMessage({ prune: config.tools.prune.enabled, distill: config.tools.distill.enabled, - compress: config.tools.compress.enabled, + compress: config.tools.compress.permission !== "deny", }) } @@ -159,7 +159,7 @@ export const insertPruneToolContext = ( ): void => { const pruneEnabled = config.tools.prune.enabled const distillEnabled = config.tools.distill.enabled - const compressEnabled = config.tools.compress.enabled + const compressEnabled = config.tools.compress.permission !== "deny" if (!pruneEnabled && !distillEnabled && !compressEnabled) { return From 59d2f98565f00786aaa4fd4035820f2b9abd1bb2 Mon Sep 17 00:00:00 2001 From: Spoon <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Thu, 5 Feb 2026 02:44:26 +0100 Subject: [PATCH 102/113] distill and prune --- README.md | 6 ++++-- dcp.schema.json | 18 +++++++++-------- index.ts | 26 ++++++++++++------------ lib/config.ts | 46 ++++++++++++++++++++++++------------------ lib/hooks.ts | 4 ++-- lib/messages/inject.ts | 12 +++++------ 6 files changed, 61 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 90609410..63754b61 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,8 @@ DCP uses its own config file: > }, > // Distills key findings into preserved knowledge before removing raw content > "distill": { -> "enabled": true, +> // Permission mode: "allow" (no prompt), "ask" (prompt), "deny" (tool not registered) +> "permission": "allow", > // Show distillation content as an ignored message notification > "showDistillation": false, > }, @@ -124,7 +125,8 @@ DCP uses its own config file: > }, > // Removes tool content from context without preservation (for completed tasks or noise) > "prune": { -> "enabled": true, +> // Permission mode: "allow" (no prompt), "ask" (prompt), "deny" (tool not registered) +> "permission": "allow", > }, > }, > // Automatic pruning strategies diff --git a/dcp.schema.json b/dcp.schema.json index fcfe2e68..82c85733 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -129,10 +129,11 @@ "description": "Configuration for the distill tool", "additionalProperties": false, "properties": { - "enabled": { - "type": "boolean", - "default": true, - "description": "Enable the distill tool" + "permission": { + "type": "string", + "enum": ["ask", "allow", "deny"], + "default": "allow", + "description": "Permission mode (deny disables the tool)" }, "showDistillation": { "type": "boolean", @@ -164,10 +165,11 @@ "description": "Configuration for the prune tool", "additionalProperties": false, "properties": { - "enabled": { - "type": "boolean", - "default": true, - "description": "Enable the prune tool" + "permission": { + "type": "string", + "enum": ["ask", "allow", "deny"], + "default": "allow", + "description": "Permission mode (deny disables the tool)" } } } diff --git a/index.ts b/index.ts index def671bd..60c1b3ec 100644 --- a/index.ts +++ b/index.ts @@ -61,7 +61,7 @@ const plugin: Plugin = (async (ctx) => { ctx.directory, ), tool: { - ...(config.tools.distill.enabled && { + ...(config.tools.distill.permission !== "deny" && { distill: createDistillTool({ client: ctx.client, state, @@ -79,7 +79,7 @@ const plugin: Plugin = (async (ctx) => { workingDirectory: ctx.directory, }), }), - ...(config.tools.prune.enabled && { + ...(config.tools.prune.permission !== "deny" && { prune: createPruneTool({ client: ctx.client, state, @@ -99,9 +99,9 @@ const plugin: Plugin = (async (ctx) => { } const toolsToAdd: string[] = [] - if (config.tools.distill.enabled) toolsToAdd.push("distill") + if (config.tools.distill.permission !== "deny") toolsToAdd.push("distill") if (config.tools.compress.permission !== "deny") toolsToAdd.push("compress") - if (config.tools.prune.enabled) toolsToAdd.push("prune") + if (config.tools.prune.permission !== "deny") toolsToAdd.push("prune") if (toolsToAdd.length > 0) { const existingPrimaryTools = opencodeConfig.experimental?.primary_tools ?? [] @@ -112,16 +112,16 @@ const plugin: Plugin = (async (ctx) => { logger.info( `Added ${toolsToAdd.map((t) => `'${t}'`).join(" and ")} to experimental.primary_tools via config mutation`, ) - - // Set compress permission from DCP config - if (config.tools.compress.permission !== "deny") { - const permission = opencodeConfig.permission ?? {} - opencodeConfig.permission = { - ...permission, - compress: config.tools.compress.permission, - } as typeof permission - } } + + // Set tool permissions from DCP config + 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 }, } }) satisfies Plugin diff --git a/lib/config.ts b/lib/config.ts index 1e772293..89bacd6e 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -10,11 +10,11 @@ export interface Deduplication { } export interface PruneTool { - enabled: boolean + permission: "ask" | "allow" | "deny" } export interface DistillTool { - enabled: boolean + permission: "ask" | "allow" | "deny" showDistillation: boolean } @@ -108,13 +108,13 @@ export const VALID_CONFIG_KEYS = new Set([ "tools.settings.protectedTools", "tools.settings.contextLimit", "tools.distill", - "tools.distill.enabled", + "tools.distill.permission", "tools.distill.showDistillation", "tools.compress", "tools.compress.permission", "tools.compress.showCompression", "tools.prune", - "tools.prune.enabled", + "tools.prune.permission", "strategies", // strategies.deduplication "strategies.deduplication", @@ -303,12 +303,15 @@ function validateConfigTypes(config: Record): ValidationError[] { } } if (tools.distill) { - if (tools.distill.enabled !== undefined && typeof tools.distill.enabled !== "boolean") { - errors.push({ - key: "tools.distill.enabled", - expected: "boolean", - actual: typeof tools.distill.enabled, - }) + 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 && @@ -344,12 +347,15 @@ function validateConfigTypes(config: Record): ValidationError[] { } } if (tools.prune) { - if (tools.prune.enabled !== undefined && typeof tools.prune.enabled !== "boolean") { - errors.push({ - key: "tools.prune.enabled", - expected: "boolean", - actual: typeof tools.prune.enabled, - }) + 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), + }) + } } } } @@ -499,7 +505,7 @@ const defaultConfig: PluginConfig = { contextLimit: 100000, }, distill: { - enabled: true, + permission: "allow", showDistillation: false, }, compress: { @@ -507,7 +513,7 @@ const defaultConfig: PluginConfig = { showCompression: false, }, prune: { - enabled: true, + permission: "allow", }, }, strategies: { @@ -676,7 +682,7 @@ function mergeTools( contextLimit: override.settings?.contextLimit ?? base.settings.contextLimit, }, distill: { - enabled: override.distill?.enabled ?? base.distill.enabled, + permission: override.distill?.permission ?? base.distill.permission, showDistillation: override.distill?.showDistillation ?? base.distill.showDistillation, }, compress: { @@ -684,7 +690,7 @@ function mergeTools( showCompression: override.compress?.showCompression ?? base.compress.showCompression, }, prune: { - enabled: override.prune?.enabled ?? base.prune.enabled, + permission: override.prune?.permission ?? base.prune.permission, }, } } diff --git a/lib/hooks.ts b/lib/hooks.ts index 6e113705..b3ecf7ff 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -43,8 +43,8 @@ export function createSystemPromptHandler( } const flags = { - prune: config.tools.prune.enabled, - distill: config.tools.distill.enabled, + prune: config.tools.prune.permission !== "deny", + distill: config.tools.distill.permission !== "deny", compress: config.tools.compress.permission !== "deny", } diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index b327b675..28d9854c 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -57,8 +57,8 @@ Context management was just performed. Do NOT use the ${toolName} again. A fresh const getNudgeString = (config: PluginConfig): string => { const flags = { - prune: config.tools.prune.enabled, - distill: config.tools.distill.enabled, + prune: config.tools.prune.permission !== "deny", + distill: config.tools.distill.permission !== "deny", compress: config.tools.compress.permission !== "deny", } @@ -71,8 +71,8 @@ const getNudgeString = (config: PluginConfig): string => { const getCooldownMessage = (config: PluginConfig): string => { return wrapCooldownMessage({ - prune: config.tools.prune.enabled, - distill: config.tools.distill.enabled, + prune: config.tools.prune.permission !== "deny", + distill: config.tools.distill.permission !== "deny", compress: config.tools.compress.permission !== "deny", }) } @@ -157,8 +157,8 @@ export const insertPruneToolContext = ( logger: Logger, messages: WithParts[], ): void => { - const pruneEnabled = config.tools.prune.enabled - const distillEnabled = config.tools.distill.enabled + 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) { From f968fb9586c243da2150aa1c0ef7d908f4126915 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 4 Feb 2026 20:50:58 -0500 Subject: [PATCH 103/113] format --- lib/logger.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/logger.ts b/lib/logger.ts index 79aff818..27dfefd0 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -9,8 +9,7 @@ export class Logger { constructor(enabled: boolean) { this.enabled = enabled - const dataHome = - process.env.XDG_DATA_HOME || join(homedir(), ".local", "share") + const dataHome = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share") this.logDir = join(dataHome, "opencode", "logs", "dcp") } From 424249c116cba194e0dfa905cb2da9ea7435228d Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 5 Feb 2026 01:44:24 -0500 Subject: [PATCH 104/113] replace context header with token-triggered compress nudge --- README.md | 2 +- dcp.schema.json | 2 +- lib/messages/inject.ts | 57 +++++++++++++++++++++++------------ lib/messages/utils.ts | 22 -------------- lib/prompts/compress-nudge.md | 3 ++ lib/prompts/index.ts | 5 +++ lib/prompts/system.md | 3 -- 7 files changed, 47 insertions(+), 47 deletions(-) create mode 100644 lib/prompts/compress-nudge.md diff --git a/README.md b/README.md index 63754b61..b99a3b3e 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ DCP uses its own config file: > // Nudge the LLM to use prune tools (every tool results) > "nudgeEnabled": true, > "nudgeFrequency": 10, -> // Encourages the model to stay within this context budget (not a hard limit); set to "model" to use full model capacity +> // When session tokens exceed this limit, the model is encouraged to compress context. Set to "model" to use full model context limit. > "contextLimit": 100000, > // Additional tools to protect from pruning > "protectedTools": [], diff --git a/dcp.schema.json b/dcp.schema.json index 82c85733..28019dd0 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -110,7 +110,7 @@ "description": "Tool names that should be protected from automatic pruning" }, "contextLimit": { - "description": "Context limit used for the prunable-tools header (\"model\" uses the active model's context limit)", + "description": "When session tokens exceed this limit, a compress nudge is injected (\"model\" uses the active model's context limit)", "default": 100000, "oneOf": [ { diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 28d9854c..06a0bba7 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -2,24 +2,22 @@ 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 { renderNudge } from "../prompts" +import { renderNudge, renderCompressNudge } from "../prompts" import { extractParameterKey, buildToolIdList, createSyntheticTextPart, createSyntheticToolPart, isIgnoredUserMessage, - formatContextHeader, - type ContextInfo, } from "./utils" import { getFilePathsFromParameters, isProtected } from "../protected-file-patterns" import { getLastUserMessage, isMessageCompacted } from "../shared-utils" import { getCurrentTokenUsage } from "../strategies/utils" -export const wrapPrunableTools = (content: string, contextInfo?: ContextInfo): string => { - const contextHeader = formatContextHeader(contextInfo) +// XML wrappers +export const wrapPrunableTools = (content: string): string => { return ` -${contextHeader}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. +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} ` } @@ -55,6 +53,32 @@ Context management was just performed. Do NOT use the ${toolName} again. A fresh ` } +const resolveContextLimit = (config: PluginConfig, state: SessionState): number | undefined => { + const configLimit = config.tools.settings.contextLimit + if (configLimit === "model") { + return state.modelContextLimit + } + return configLimit +} + +const shouldInjectCompressNudge = ( + config: PluginConfig, + state: SessionState, + messages: WithParts[], +): boolean => { + if (config.tools.compress.permission === "deny") { + return false + } + + const contextLimit = resolveContextLimit(config, state) + 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", @@ -135,20 +159,7 @@ const buildPrunableToolsList = ( return "" } - const configLimit = - config.tools.settings.contextLimit === "model" - ? state.modelContextLimit - : config.tools.settings.contextLimit - - const contextInfo: ContextInfo = { - used: getCurrentTokenUsage(messages), - limit: - state.modelContextLimit !== undefined && configLimit !== undefined - ? Math.min(state.modelContextLimit, configLimit) - : (configLimit ?? state.modelContextLimit), - } - - return wrapPrunableTools(lines.join("\n"), contextInfo) + return wrapPrunableTools(lines.join("\n")) } export const insertPruneToolContext = ( @@ -194,6 +205,12 @@ export const insertPruneToolContext = ( logger.info("Inserting prune nudge message") contentParts.push(getNudgeString(config)) } + + // Add compress nudge if token usage exceeds contextLimit + if (shouldInjectCompressNudge(config, state, messages)) { + logger.info("Inserting compress nudge - token usage exceeds contextLimit") + contentParts.push(renderCompressNudge()) + } } if (contentParts.length === 0) { diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index d65523ab..18a1723c 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -3,31 +3,9 @@ import { isMessageCompacted } from "../shared-utils" import { Logger } from "../logger" import type { SessionState, WithParts } from "../state" import type { UserMessage } from "@opencode-ai/sdk/v2" -import { formatTokenCount } from "../ui/utils" export const COMPRESS_SUMMARY_PREFIX = "[Compressed conversation block]\n\n" -export interface ContextInfo { - used: number - limit: number | undefined -} - -export function formatContextHeader(contextInfo?: ContextInfo): string { - if (!contextInfo || contextInfo.used === 0) { - return "" - } - - const usedStr = formatTokenCount(contextInfo.used) - - if (contextInfo.limit) { - const limitStr = formatTokenCount(contextInfo.limit) - const percentage = Math.round((contextInfo.used / contextInfo.limit) * 100) - return `Context: ~${usedStr} / ${limitStr} (${percentage}% used)\n` - } - - return `Context: ~${usedStr}\n` -} - const generateUniqueId = (prefix: string): string => `${prefix}_${ulid()}` const isGeminiModel = (modelID: string): boolean => { diff --git a/lib/prompts/compress-nudge.md b/lib/prompts/compress-nudge.md new file mode 100644 index 00000000..895e7b83 --- /dev/null +++ b/lib/prompts/compress-nudge.md @@ -0,0 +1,3 @@ + +Your session context has exceeded the configured limit. Use the `compress` tool to consolidate completed work phases into summaries. Target conversation segments where exploration or implementation is finished - compress preserves understanding while freeing space for continued work. + diff --git a/lib/prompts/index.ts b/lib/prompts/index.ts index 7520a417..e764099a 100644 --- a/lib/prompts/index.ts +++ b/lib/prompts/index.ts @@ -1,6 +1,7 @@ // 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" @@ -33,6 +34,10 @@ export function renderNudge(flags: ToolFlags): string { return processConditionals(NUDGE, flags) } +export function renderCompressNudge(): string { + return COMPRESS_NUDGE +} + const PROMPTS: Record = { "prune-tool-spec": PRUNE_TOOL_SPEC, "distill-tool-spec": DISTILL_TOOL_SPEC, diff --git a/lib/prompts/system.md b/lib/prompts/system.md index 2f5d0f09..4ea2b6b1 100644 --- a/lib/prompts/system.md +++ b/lib/prompts/system.md @@ -14,8 +14,6 @@ AVAILABLE TOOLS FOR CONTEXT MANAGEMENT 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. -When the context usage indicator is high (around 80% or above), prioritize using `compress` to reduce verbosity and keep room for future context. - 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. Make sure to match enough of the context with start and end strings so you're not faced with an error calling the tool. Be VERY CAREFUL AND CONSERVATIVE when using `compress`. @@ -39,7 +37,6 @@ Be respectful of the user's API usage, manage context methodically as you work t 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. -Aim to keep the context usage indicator below roughly 80% so there is room for future turns and tool outputs. 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. From 6e12c5a37e4959e69edb73aed09413cb31a932b4 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 5 Feb 2026 03:15:25 -0500 Subject: [PATCH 105/113] revert: use XDG_CONFIG_HOME for logs instead of XDG_DATA_HOME --- lib/logger.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/logger.ts b/lib/logger.ts index 27dfefd0..05852abc 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -9,8 +9,8 @@ export class Logger { constructor(enabled: boolean) { this.enabled = enabled - const dataHome = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share") - this.logDir = join(dataHome, "opencode", "logs", "dcp") + const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config") + this.logDir = join(configHome, "opencode", "logs", "dcp") } private async ensureLogDir() { From e0ea460bf4f8007345fba61b1e619ec2d6771769 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 5 Feb 2026 03:24:47 -0500 Subject: [PATCH 106/113] fix: cache toolIdList to prevent prune ID mismatch --- lib/hooks.ts | 2 ++ lib/messages/inject.ts | 6 ++---- lib/messages/utils.ts | 1 + lib/state/state.ts | 2 ++ lib/state/types.ts | 1 + lib/strategies/deduplication.ts | 4 +--- lib/strategies/purge-errors.ts | 4 +--- lib/strategies/supersede-writes.ts | 4 +--- lib/tools/prune-shared.ts | 6 +++--- 9 files changed, 14 insertions(+), 16 deletions(-) diff --git a/lib/hooks.ts b/lib/hooks.ts index b3ecf7ff..83c74cc2 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -4,6 +4,7 @@ import type { PluginConfig } from "./config" import { syncToolCache } from "./state/tool-cache" import { deduplicate, supersedeWrites, purgeErrors } from "./strategies" import { prune, insertPruneToolContext } from "./messages" +import { buildToolIdList } from "./messages/utils" import { checkSession } from "./state" import { renderSystemPrompt } from "./prompts" import { handleStatsCommand } from "./commands/stats" @@ -70,6 +71,7 @@ export function createChatMessageTransformHandler( } syncToolCache(state, config, logger, output.messages) + buildToolIdList(state, output.messages, logger) deduplicate(state, logger, config, output.messages) supersedeWrites(state, logger, config, output.messages) diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 06a0bba7..81294eea 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -5,7 +5,6 @@ import type { UserMessage } from "@opencode-ai/sdk/v2" import { renderNudge, renderCompressNudge } from "../prompts" import { extractParameterKey, - buildToolIdList, createSyntheticTextPart, createSyntheticToolPart, isIgnoredUserMessage, @@ -110,10 +109,9 @@ const buildPrunableToolsList = ( state: SessionState, config: PluginConfig, logger: Logger, - messages: WithParts[], ): string => { const lines: string[] = [] - const toolIdList: string[] = buildToolIdList(state, messages, logger) + const toolIdList = state.toolIdList state.toolParameters.forEach((toolParameterEntry, toolCallId) => { if (state.prune.toolIds.has(toolCallId)) { @@ -184,7 +182,7 @@ export const insertPruneToolContext = ( contentParts.push(getCooldownMessage(config)) } else { if (pruneOrDistillEnabled) { - const prunableToolsList = buildPrunableToolsList(state, config, logger, messages) + const prunableToolsList = buildPrunableToolsList(state, config, logger) if (prunableToolsList) { // logger.debug("prunable-tools: \n" + prunableToolsList) contentParts.push(prunableToolsList) diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index 18a1723c..a57d626e 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -245,6 +245,7 @@ export function buildToolIdList( } } } + state.toolIdList = toolIds return toolIds } diff --git a/lib/state/state.ts b/lib/state/state.ts index 5a14f481..2b7ebb81 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -57,6 +57,7 @@ export function createSessionState(): SessionState { totalPruneTokens: 0, }, toolParameters: new Map(), + toolIdList: [], nudgeCounter: 0, lastToolPrune: false, lastCompaction: 0, @@ -79,6 +80,7 @@ export function resetSessionState(state: SessionState): void { totalPruneTokens: 0, } state.toolParameters.clear() + state.toolIdList = [] state.nudgeCounter = 0 state.lastToolPrune = false state.lastCompaction = 0 diff --git a/lib/state/types.ts b/lib/state/types.ts index ec42dd10..3aa41a88 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -38,6 +38,7 @@ export interface SessionState { compressSummaries: CompressSummary[] stats: SessionStats toolParameters: Map + toolIdList: string[] nudgeCounter: number lastToolPrune: boolean lastCompaction: number diff --git a/lib/strategies/deduplication.ts b/lib/strategies/deduplication.ts index 9b1d04e8..33c43a88 100644 --- a/lib/strategies/deduplication.ts +++ b/lib/strategies/deduplication.ts @@ -1,7 +1,6 @@ import { PluginConfig } from "../config" import { Logger } from "../logger" import type { SessionState, WithParts } from "../state" -import { buildToolIdList } from "../messages/utils" import { getFilePathsFromParameters, isProtected } from "../protected-file-patterns" import { calculateTokensSaved } from "./utils" @@ -20,8 +19,7 @@ export const deduplicate = ( return } - // Build list of all tool call IDs from messages (chronological order) - const allToolIds = buildToolIdList(state, messages, logger) + const allToolIds = state.toolIdList if (allToolIds.length === 0) { return } diff --git a/lib/strategies/purge-errors.ts b/lib/strategies/purge-errors.ts index 1085fd6c..65b43e35 100644 --- a/lib/strategies/purge-errors.ts +++ b/lib/strategies/purge-errors.ts @@ -1,7 +1,6 @@ import { PluginConfig } from "../config" import { Logger } from "../logger" import type { SessionState, WithParts } from "../state" -import { buildToolIdList } from "../messages/utils" import { getFilePathsFromParameters, isProtected } from "../protected-file-patterns" import { calculateTokensSaved } from "./utils" @@ -23,8 +22,7 @@ export const purgeErrors = ( return } - // Build list of all tool call IDs from messages (chronological order) - const allToolIds = buildToolIdList(state, messages, logger) + const allToolIds = state.toolIdList if (allToolIds.length === 0) { return } diff --git a/lib/strategies/supersede-writes.ts b/lib/strategies/supersede-writes.ts index e4e48e00..66c90251 100644 --- a/lib/strategies/supersede-writes.ts +++ b/lib/strategies/supersede-writes.ts @@ -1,7 +1,6 @@ import { PluginConfig } from "../config" import { Logger } from "../logger" import type { SessionState, WithParts } from "../state" -import { buildToolIdList } from "../messages/utils" import { getFilePathsFromParameters, isProtected } from "../protected-file-patterns" import { calculateTokensSaved } from "./utils" @@ -23,8 +22,7 @@ export const supersedeWrites = ( return } - // Build list of all tool call IDs from messages (chronological order) - const allToolIds = buildToolIdList(state, messages, logger) + const allToolIds = state.toolIdList if (allToolIds.length === 0) { return } diff --git a/lib/tools/prune-shared.ts b/lib/tools/prune-shared.ts index cd6c9abb..c1253f76 100644 --- a/lib/tools/prune-shared.ts +++ b/lib/tools/prune-shared.ts @@ -2,7 +2,6 @@ import type { SessionState, ToolParameterEntry, WithParts } from "../state" import type { PluginConfig } from "../config" import type { Logger } from "../logger" import type { PruneToolContext } from "./types" -import { buildToolIdList } from "../messages/utils" import { syncToolCache } from "../state/tool-cache" import { PruneReason, sendUnifiedNotification } from "../ui/notification" import { formatPruningResultForTool } from "../ui/utils" @@ -52,14 +51,15 @@ export async function executePruneOperation( await syncToolCache(state, config, logger, messages) const currentParams = getCurrentParams(state, messages, logger) - const toolIdList: string[] = buildToolIdList(state, messages, logger) + + const toolIdList = state.toolIdList const validNumericIds: number[] = [] const skippedIds: string[] = [] // Validate and filter IDs for (const index of numericToolIds) { - // Validate that all numeric IDs are within bounds + // 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()) From f4bebd742473e2e694236a69cfdf87c6b3131515 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 5 Feb 2026 03:34:49 -0500 Subject: [PATCH 107/113] v1.4.0-beta.1 - Bump version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d4820653..2fd79cc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tarquinen/opencode-dcp", - "version": "1.4.0-beta.0", + "version": "1.4.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "1.4.0-beta.0", + "version": "1.4.0-beta.1", "license": "AGPL-3.0-or-later", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", diff --git a/package.json b/package.json index 912eb337..c6df3862 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@tarquinen/opencode-dcp", - "version": "1.4.0-beta.0", + "version": "1.4.0-beta.1", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js", From 177c176954b5a0e7dc44054e62e88beaaa0b0eb3 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 5 Feb 2026 03:35:59 -0500 Subject: [PATCH 108/113] v1.4.1-beta.0 - Bump version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2fd79cc1..493eb374 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tarquinen/opencode-dcp", - "version": "1.4.0-beta.1", + "version": "1.4.1-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "1.4.0-beta.1", + "version": "1.4.1-beta.0", "license": "AGPL-3.0-or-later", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", diff --git a/package.json b/package.json index c6df3862..b08fde61 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@tarquinen/opencode-dcp", - "version": "1.4.0-beta.1", + "version": "1.4.1-beta.0", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js", From afeb5e3d81e90d9094672fa64443e8645586d874 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 5 Feb 2026 12:10:22 -0500 Subject: [PATCH 109/113] logic/prompts: prioritize and restyle compress nudge --- lib/messages/inject.ts | 12 ++++-------- lib/prompts/compress-nudge.md | 13 ++++++++++--- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 81294eea..f1b56025 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -195,20 +195,16 @@ export const insertPruneToolContext = ( contentParts.push(compressContext) } - // Add nudge if threshold reached - if ( + if (shouldInjectCompressNudge(config, state, messages)) { + logger.info("Inserting compress nudge - token usage exceeds contextLimit") + contentParts.push(renderCompressNudge()) + } else if ( config.tools.settings.nudgeEnabled && state.nudgeCounter >= config.tools.settings.nudgeFrequency ) { logger.info("Inserting prune nudge message") contentParts.push(getNudgeString(config)) } - - // Add compress nudge if token usage exceeds contextLimit - if (shouldInjectCompressNudge(config, state, messages)) { - logger.info("Inserting compress nudge - token usage exceeds contextLimit") - contentParts.push(renderCompressNudge()) - } } if (contentParts.length === 0) { diff --git a/lib/prompts/compress-nudge.md b/lib/prompts/compress-nudge.md index 895e7b83..b9ce567b 100644 --- a/lib/prompts/compress-nudge.md +++ b/lib/prompts/compress-nudge.md @@ -1,3 +1,10 @@ - -Your session context has exceeded the configured limit. Use the `compress` tool to consolidate completed work phases into summaries. Target conversation segments where exploration or implementation is finished - compress preserves understanding while freeing space for continued work. - + +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 + From e30d0fe9e4c921d6567b9570a9e68a8cc6601cd8 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 5 Feb 2026 14:37:36 -0500 Subject: [PATCH 110/113] v1.4.2-beta.0 - Bump version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 493eb374..16116939 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tarquinen/opencode-dcp", - "version": "1.4.1-beta.0", + "version": "1.4.2-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "1.4.1-beta.0", + "version": "1.4.2-beta.0", "license": "AGPL-3.0-or-later", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", diff --git a/package.json b/package.json index b08fde61..aca577cc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@tarquinen/opencode-dcp", - "version": "1.4.1-beta.0", + "version": "1.4.2-beta.0", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js", From e27f47d3b36180d375a57a5f893d3b3efb219109 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 5 Feb 2026 14:49:37 -0500 Subject: [PATCH 111/113] contextLimit readme cleanup --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b99a3b3e..3bd6d7bd 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,9 @@ DCP uses its own config file: > // Nudge the LLM to use prune tools (every tool results) > "nudgeEnabled": true, > "nudgeFrequency": 10, -> // When session tokens exceed this limit, the model is encouraged to compress context. Set to "model" to use full model context limit. +> // Token limit at which the model begins actively compressing session context. + // Best kept around 40% of the model's context window to stay in the "smart zone". + // Set to "model" to use the model's full context window size. > "contextLimit": 100000, > // Additional tools to protect from pruning > "protectedTools": [], @@ -150,10 +152,13 @@ DCP uses its own config file: > "protectedTools": [], > }, > }, +> > } +> > ``` > >
+> ``` ### Commands From 5731262d459e1411c06a36947d4d51ef46d9ac4b Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 5 Feb 2026 14:50:34 -0500 Subject: [PATCH 112/113] cleanup --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3bd6d7bd..24981f81 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,8 @@ DCP uses its own config file: > "nudgeEnabled": true, > "nudgeFrequency": 10, > // Token limit at which the model begins actively compressing session context. - // Best kept around 40% of the model's context window to stay in the "smart zone". - // Set to "model" to use the model's full context window size. +> // Best kept around 40% of the model's context window to stay in the "smart zone". +> // Set to "model" to use the model's full context window size. > "contextLimit": 100000, > // Additional tools to protect from pruning > "protectedTools": [], @@ -152,9 +152,7 @@ DCP uses its own config file: > "protectedTools": [], > }, > }, -> > } -> > ``` > >
From 67684bea08cabe9e09ac804f131c9a528153e477 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Thu, 5 Feb 2026 14:52:30 -0500 Subject: [PATCH 113/113] more --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 24981f81..eef2f56f 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,10 @@ DCP uses its own config file: > // Nudge the LLM to use prune tools (every tool results) > "nudgeEnabled": true, > "nudgeFrequency": 10, -> // Token limit at which the model begins actively compressing session context. -> // Best kept around 40% of the model's context window to stay in the "smart zone". -> // Set to "model" to use the model's full context window size. +> // Token limit at which the model begins actively +> // compressing session context. Best kept around 40% of +> // the model's context window to stay in the "smart zone". +> // Set to "model" to use the model's full context window. > "contextLimit": 100000, > // Additional tools to protect from pruning > "protectedTools": [], @@ -156,7 +157,6 @@ DCP uses its own config file: > ``` > >
-> ``` ### Commands