diff --git a/README.md b/README.md index 34d280b6..596c4bee 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,14 @@ DCP uses its own config file: > // Additional tools to protect from pruning via commands (e.g., /dcp sweep) > "protectedTools": [], > }, +> // Manual mode: disables autonomous context management, +> // tools only run when explicitly triggered via /dcp commands +> "manualMode": { +> "enabled": false, +> // When true, automatic strategies (deduplication, supersedeWrites, purgeErrors) +> // still run even in manual mode +> "automaticStrategies": true, +> }, > // Protect from pruning for message turns past tool invocation > "turnProtection": { > "enabled": false, @@ -172,6 +180,10 @@ DCP provides a `/dcp` slash command: - `/dcp context` — Shows a breakdown of your current session's token usage by category (system, user, assistant, tools, etc.) and how much has been saved through pruning. - `/dcp stats` — Shows cumulative pruning statistics across all sessions. - `/dcp sweep` — Prunes all tools since the last user message. Accepts an optional count: `/dcp sweep 10` prunes the last 10 tools. Respects `commands.protectedTools`. +- `/dcp manual [on|off]` — Toggle manual mode or set explicit state. When on, the AI will not autonomously use context management tools. +- `/dcp prune [focus]` — Trigger a single prune tool execution. Optional focus text directs the AI's pruning decisions. +- `/dcp distill [focus]` — Trigger a single distill tool execution. Optional focus text directs what to distill. +- `/dcp compress [focus]` — Trigger a single compress tool execution. Optional focus text directs what range to compress. ### Protected Tools diff --git a/dcp.schema.json b/dcp.schema.json index 60160a49..49d8fbe1 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -56,6 +56,27 @@ "protectedTools": [] } }, + "manualMode": { + "type": "object", + "description": "Manual mode behavior for context management tools", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Start new sessions with manual mode enabled" + }, + "automaticStrategies": { + "type": "boolean", + "default": true, + "description": "When manual mode is enabled, keep automatic deduplication/supersede/purge strategies running" + } + }, + "default": { + "enabled": false, + "automaticStrategies": true + } + }, "turnProtection": { "type": "object", "description": "Protect recent tool outputs from being pruned", diff --git a/lib/commands/context.ts b/lib/commands/context.ts index 15328692..879e696c 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, - prunedToolCount: state.prune.toolIds.size, - prunedMessageCount: state.prune.messageIds.size, + prunedToolCount: state.prune.tools.size, + prunedMessageCount: state.prune.messages.size, total: 0, } @@ -129,7 +129,7 @@ function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdo foundToolIds.add(toolPart.callID) } - const isPruned = toolPart.callID && state.prune.toolIds.has(toolPart.callID) + const isPruned = toolPart.callID && state.prune.tools.has(toolPart.callID) if (!isCompacted && !isPruned) { if (toolPart.state?.input) { const inputStr = diff --git a/lib/commands/help.ts b/lib/commands/help.ts index 32b9a195..f456d933 100644 --- a/lib/commands/help.ts +++ b/lib/commands/help.ts @@ -4,6 +4,7 @@ */ import type { Logger } from "../logger" +import type { PluginConfig } from "../config" import type { SessionState, WithParts } from "../state" import { sendIgnoredMessage } from "../ui/notification" import { getCurrentParams } from "../strategies/utils" @@ -11,21 +12,49 @@ import { getCurrentParams } from "../strategies/utils" export interface HelpCommandContext { client: any state: SessionState + config: PluginConfig logger: Logger sessionId: string messages: WithParts[] } -function formatHelpMessage(): string { +const BASE_COMMANDS: [string, string][] = [ + ["/dcp context", "Show token usage breakdown for current session"], + ["/dcp stats", "Show DCP pruning statistics"], + ["/dcp sweep [n]", "Prune tools since last user message, or last n tools"], + ["/dcp manual [on|off]", "Toggle manual mode or set explicit state"], +] + +const TOOL_COMMANDS: Record = { + prune: ["/dcp prune [focus]", "Trigger manual prune tool execution"], + distill: ["/dcp distill [focus]", "Trigger manual distill tool execution"], + compress: ["/dcp compress [focus]", "Trigger manual compress tool execution"], +} + +function getVisibleCommands(config: PluginConfig): [string, string][] { + const commands = [...BASE_COMMANDS] + for (const tool of ["prune", "distill", "compress"] as const) { + if (config.tools[tool].permission !== "deny") { + commands.push(TOOL_COMMANDS[tool]) + } + } + return commands +} + +function formatHelpMessage(manualMode: boolean, config: PluginConfig): string { + const commands = getVisibleCommands(config) + const colWidth = Math.max(...commands.map(([cmd]) => cmd.length)) + 4 const lines: string[] = [] - lines.push("╭───────────────────────────────────────────────────────────╮") - lines.push("│ DCP Commands │") - lines.push("╰───────────────────────────────────────────────────────────╯") + lines.push("╭─────────────────────────────────────────────────────────────────────────╮") + lines.push("│ DCP Commands │") + lines.push("╰─────────────────────────────────────────────────────────────────────────╯") + lines.push("") + lines.push(` ${"Manual mode:".padEnd(colWidth)}${manualMode ? "ON" : "OFF"}`) lines.push("") - lines.push(" /dcp context Show token usage breakdown for current session") - lines.push(" /dcp stats Show DCP pruning statistics") - lines.push(" /dcp sweep [n] Prune tools since last user message, or last n tools") + for (const [cmd, desc] of commands) { + lines.push(` ${cmd.padEnd(colWidth)}${desc}`) + } lines.push("") return lines.join("\n") @@ -34,7 +63,8 @@ function formatHelpMessage(): string { export async function handleHelpCommand(ctx: HelpCommandContext): Promise { const { client, state, logger, sessionId, messages } = ctx - const message = formatHelpMessage() + const { config } = ctx + const message = formatHelpMessage(state.manualMode, config) const params = getCurrentParams(state, messages, logger) await sendIgnoredMessage(client, sessionId, message, params, logger) diff --git a/lib/commands/manual.ts b/lib/commands/manual.ts new file mode 100644 index 00000000..2c5c1815 --- /dev/null +++ b/lib/commands/manual.ts @@ -0,0 +1,131 @@ +/** + * DCP Manual mode command handler. + * Handles toggling manual mode and triggering individual tool executions. + * + * Usage: + * /dcp manual [on|off] - Toggle manual mode or set explicit state + * /dcp prune [focus] - Trigger manual prune execution + * /dcp distill [focus] - Trigger manual distill execution + * /dcp compress [focus] - Trigger manual compress execution + */ + +import type { Logger } from "../logger" +import type { SessionState, WithParts } from "../state" +import type { PluginConfig } from "../config" +import { sendIgnoredMessage } from "../ui/notification" +import { getCurrentParams } from "../strategies/utils" +import { syncToolCache } from "../state/tool-cache" +import { buildToolIdList } from "../messages/utils" +import { buildPrunableToolsList } from "../messages/inject" + +const MANUAL_MODE_ON = + "Manual mode is now ON. Use /dcp prune, /dcp distill, or /dcp compress to trigger context tools manually." + +const MANUAL_MODE_OFF = "Manual mode is now OFF." + +const NO_PRUNABLE_TOOLS = "No prunable tool outputs are currently available for manual triggering." + +const PRUNE_TRIGGER_PROMPT = [ + "", + "Manual mode trigger received. You must now use the prune tool exactly once.", + "Find the most significant set of prunable tool outputs to remove safely.", + "Follow prune policy and avoid pruning outputs that may be needed later.", + "Return after prune with a brief explanation of what you pruned and why.", +].join("\n\n") + +const DISTILL_TRIGGER_PROMPT = [ + "", + "Manual mode trigger received. You must now use the distill tool.", + "Select the most information-dense prunable outputs and distill them into complete technical substitutes.", + "Be exhaustive and preserve all critical technical details.", + "Return after distill with a brief explanation of what was distilled and why.", +].join("\n\n") + +const COMPRESS_TRIGGER_PROMPT = [ + "", + "Manual mode trigger received. You must now use the compress tool.", + "Find the most significant completed section of the conversation that can be compressed into a high-fidelity technical summary.", + "Choose safe boundaries and preserve all critical implementation details.", + "Return after compress with a brief explanation of what range was compressed.", +].join("\n\n") + +function getTriggerPrompt( + tool: "prune" | "distill" | "compress", + context?: string, + userFocus?: string, +): string { + const base = + tool === "prune" + ? PRUNE_TRIGGER_PROMPT + : tool === "distill" + ? DISTILL_TRIGGER_PROMPT + : COMPRESS_TRIGGER_PROMPT + + const sections = [base] + if (userFocus && userFocus.trim().length > 0) { + sections.push(`Additional user focus:\n${userFocus.trim()}`) + } + if (context) { + sections.push(context) + } + + return sections.join("\n\n") +} + +export interface ManualCommandContext { + client: any + state: SessionState + config: PluginConfig + logger: Logger + sessionId: string + messages: WithParts[] +} + +export async function handleManualToggleCommand( + ctx: ManualCommandContext, + modeArg?: string, +): Promise { + const { client, state, logger, sessionId, messages } = ctx + + if (modeArg === "on") { + state.manualMode = true + } else if (modeArg === "off") { + state.manualMode = false + } else { + state.manualMode = !state.manualMode + } + + const params = getCurrentParams(state, messages, logger) + await sendIgnoredMessage( + client, + sessionId, + state.manualMode ? MANUAL_MODE_ON : MANUAL_MODE_OFF, + params, + logger, + ) + + logger.info("Manual mode toggled", { manualMode: state.manualMode }) +} + +export async function handleManualTriggerCommand( + ctx: ManualCommandContext, + tool: "prune" | "distill" | "compress", + userFocus?: string, +): Promise { + const { client, state, config, logger, sessionId, messages } = ctx + + if (tool === "prune" || tool === "distill") { + syncToolCache(state, config, logger, messages) + buildToolIdList(state, messages, logger) + const prunableToolsList = buildPrunableToolsList(state, config, logger) + if (!prunableToolsList) { + const params = getCurrentParams(state, messages, logger) + await sendIgnoredMessage(client, sessionId, NO_PRUNABLE_TOOLS, params, logger) + return null + } + + return getTriggerPrompt(tool, prunableToolsList, userFocus) + } + + return getTriggerPrompt("compress", undefined, userFocus) +} diff --git a/lib/commands/stats.ts b/lib/commands/stats.ts index a554309d..6ef49cd2 100644 --- a/lib/commands/stats.ts +++ b/lib/commands/stats.ts @@ -51,8 +51,8 @@ export async function handleStatsCommand(ctx: StatsCommandContext): Promise 0 @@ -136,9 +140,8 @@ export async function handleSweepCommand(ctx: SweepCommandContext): Promise { - if (state.prune.toolIds.has(id)) { + if (state.prune.tools.has(id)) { return false } const entry = state.toolParameters.get(id) @@ -211,13 +214,13 @@ export async function handleSweepCommand(ctx: SweepCommandContext): Promise): ValidationErro } } + // Manual mode validator + const manualMode = config.manualMode + if (manualMode !== undefined) { + if (typeof manualMode === "object") { + if (manualMode.enabled !== undefined && typeof manualMode.enabled !== "boolean") { + errors.push({ + key: "manualMode.enabled", + expected: "boolean", + actual: typeof manualMode.enabled, + }) + } + if ( + manualMode.automaticStrategies !== undefined && + typeof manualMode.automaticStrategies !== "boolean" + ) { + errors.push({ + key: "manualMode.automaticStrategies", + expected: "boolean", + actual: typeof manualMode.automaticStrategies, + }) + } + } else { + errors.push({ + key: "manualMode", + expected: "{ enabled: boolean, automaticStrategies: boolean }", + actual: typeof manualMode, + }) + } + } + // Tools validators const tools = config.tools if (tools) { @@ -529,6 +568,10 @@ const defaultConfig: PluginConfig = { enabled: true, protectedTools: [...DEFAULT_PROTECTED_TOOLS], }, + manualMode: { + enabled: false, + automaticStrategies: true, + }, turnProtection: { enabled: false, turns: 4, @@ -747,6 +790,18 @@ function mergeCommands( } } +function mergeManualMode( + base: PluginConfig["manualMode"], + override?: Partial, +): PluginConfig["manualMode"] { + if (override === undefined) return base + + return { + enabled: override.enabled ?? base.enabled, + automaticStrategies: override.automaticStrategies ?? base.automaticStrategies, + } +} + function deepCloneConfig(config: PluginConfig): PluginConfig { return { ...config, @@ -754,6 +809,10 @@ function deepCloneConfig(config: PluginConfig): PluginConfig { enabled: config.commands.enabled, protectedTools: [...config.commands.protectedTools], }, + manualMode: { + enabled: config.manualMode.enabled, + automaticStrategies: config.manualMode.automaticStrategies, + }, turnProtection: { ...config.turnProtection }, protectedFilePatterns: [...config.protectedFilePatterns], tools: { @@ -812,6 +871,7 @@ export function getConfig(ctx: PluginInput): PluginConfig { pruneNotificationType: result.data.pruneNotificationType ?? config.pruneNotificationType, commands: mergeCommands(config.commands, result.data.commands as any), + manualMode: mergeManualMode(config.manualMode, result.data.manualMode as any), turnProtection: { enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, turns: result.data.turnProtection?.turns ?? config.turnProtection.turns, @@ -857,6 +917,7 @@ export function getConfig(ctx: PluginInput): PluginConfig { pruneNotificationType: result.data.pruneNotificationType ?? config.pruneNotificationType, commands: mergeCommands(config.commands, result.data.commands as any), + manualMode: mergeManualMode(config.manualMode, result.data.manualMode as any), turnProtection: { enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, turns: result.data.turnProtection?.turns ?? config.turnProtection.turns, @@ -899,6 +960,7 @@ export function getConfig(ctx: PluginInput): PluginConfig { pruneNotificationType: result.data.pruneNotificationType ?? config.pruneNotificationType, commands: mergeCommands(config.commands, result.data.commands as any), + manualMode: mergeManualMode(config.manualMode, result.data.manualMode as any), turnProtection: { enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled, turns: result.data.turnProtection?.turns ?? config.turnProtection.turns, diff --git a/lib/hooks.ts b/lib/hooks.ts index 83c74cc2..54c1232d 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -4,14 +4,16 @@ 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 { buildToolIdList, isIgnoredUserMessage } from "./messages/utils" import { checkSession } from "./state" import { renderSystemPrompt } from "./prompts" import { handleStatsCommand } from "./commands/stats" import { handleContextCommand } from "./commands/context" import { handleHelpCommand } from "./commands/help" import { handleSweepCommand } from "./commands/sweep" +import { handleManualToggleCommand, handleManualTriggerCommand } from "./commands/manual" import { ensureSessionInitialized } from "./state/state" +import { getCurrentParams } from "./strategies/utils" const INTERNAL_AGENT_SIGNATURES = [ "You are a title generator", @@ -19,6 +21,42 @@ const INTERNAL_AGENT_SIGNATURES = [ "Summarize what was done in this conversation", ] +function applyPendingManualTriggerPrompt( + state: SessionState, + messages: WithParts[], + logger: Logger, +): void { + const pending = state.pendingManualTrigger + if (!pending) { + return + } + + if (!state.sessionId || pending.sessionId !== state.sessionId) { + state.pendingManualTrigger = null + return + } + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.info.role !== "user" || isIgnoredUserMessage(msg)) { + continue + } + + for (const part of msg.parts) { + if (part.type !== "text" || part.ignored || part.synthetic) { + continue + } + + part.text = pending.prompt + state.pendingManualTrigger = null + logger.debug("Applied pending manual trigger prompt", { sessionId: pending.sessionId }) + return + } + } + + state.pendingManualTrigger = null +} + export function createSystemPromptHandler( state: SessionState, logger: Logger, @@ -47,6 +85,7 @@ export function createSystemPromptHandler( prune: config.tools.prune.permission !== "deny", distill: config.tools.distill.permission !== "deny", compress: config.tools.compress.permission !== "deny", + manual: state.manualMode, } if (!flags.prune && !flags.distill && !flags.compress) { @@ -64,7 +103,7 @@ export function createChatMessageTransformHandler( config: PluginConfig, ) { return async (input: {}, output: { messages: WithParts[] }) => { - await checkSession(client, state, logger, output.messages) + await checkSession(client, state, logger, output.messages, config.manualMode.enabled) if (state.isSubAgent) { return @@ -78,9 +117,10 @@ export function createChatMessageTransformHandler( purgeErrors(state, logger, config, output.messages) prune(state, logger, config, output.messages) - insertPruneToolContext(state, config, logger, output.messages) + applyPendingManualTriggerPrompt(state, output.messages, logger) + if (state.sessionId) { await logger.saveContext(state.sessionId, output.messages) } @@ -96,7 +136,7 @@ export function createCommandExecuteHandler( ) { return async ( input: { command: string; sessionID: string; arguments: string }, - _output: { parts: any[] }, + output: { parts: any[] }, ) => { if (!config.commands.enabled) { return @@ -108,55 +148,76 @@ export function createCommandExecuteHandler( }) const messages = (messagesResponse.data || messagesResponse) as WithParts[] - await ensureSessionInitialized(client, state, input.sessionID, logger, messages) + await ensureSessionInitialized( + client, + state, + input.sessionID, + logger, + messages, + config.manualMode.enabled, + ) const args = (input.arguments || "").trim().split(/\s+/).filter(Boolean) const subcommand = args[0]?.toLowerCase() || "" - const _subArgs = args.slice(1) + const subArgs = args.slice(1) + + const commandCtx = { + client, + state, + config, + logger, + sessionId: input.sessionID, + messages, + } if (subcommand === "context") { - await handleContextCommand({ - client, - state, - logger, - sessionId: input.sessionID, - messages, - }) + await handleContextCommand(commandCtx) throw new Error("__DCP_CONTEXT_HANDLED__") } if (subcommand === "stats") { - await handleStatsCommand({ - client, - state, - logger, - sessionId: input.sessionID, - messages, - }) + await handleStatsCommand(commandCtx) throw new Error("__DCP_STATS_HANDLED__") } if (subcommand === "sweep") { await handleSweepCommand({ - client, - state, - config, - logger, - sessionId: input.sessionID, - messages, - args: _subArgs, + ...commandCtx, + args: subArgs, workingDirectory, }) throw new Error("__DCP_SWEEP_HANDLED__") } - await handleHelpCommand({ - client, - state, - logger, - sessionId: input.sessionID, - messages, - }) + if (subcommand === "manual") { + await handleManualToggleCommand(commandCtx, subArgs[0]?.toLowerCase()) + throw new Error("__DCP_MANUAL_HANDLED__") + } + + if ( + (subcommand === "prune" || subcommand === "distill" || subcommand === "compress") && + config.tools[subcommand].permission !== "deny" + ) { + const userFocus = subArgs.join(" ").trim() + const prompt = await handleManualTriggerCommand(commandCtx, subcommand, userFocus) + if (!prompt) { + throw new Error("__DCP_MANUAL_TRIGGER_BLOCKED__") + } + + state.pendingManualTrigger = { + sessionId: input.sessionID, + prompt, + } + const rawArgs = (input.arguments || "").trim() + output.parts.length = 0 + output.parts.push({ + type: "text", + text: rawArgs ? `/dcp ${rawArgs}` : `/dcp ${subcommand}`, + }) + return + } + + await handleHelpCommand(commandCtx) throw new Error("__DCP_HELP_HANDLED__") } } diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 002e7aa3..83256f30 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -131,6 +131,7 @@ const getNudgeString = (config: PluginConfig): string => { prune: config.tools.prune.permission !== "deny", distill: config.tools.distill.permission !== "deny", compress: config.tools.compress.permission !== "deny", + manual: false, } if (!flags.prune && !flags.distill && !flags.compress) { @@ -153,7 +154,7 @@ const buildCompressContext = (state: SessionState, messages: WithParts[]): strin return wrapCompressContext(messageCount) } -const buildPrunableToolsList = ( +export const buildPrunableToolsList = ( state: SessionState, config: PluginConfig, logger: Logger, @@ -162,7 +163,7 @@ const buildPrunableToolsList = ( const toolIdList = state.toolIdList state.toolParameters.forEach((toolParameterEntry, toolCallId) => { - if (state.prune.toolIds.has(toolCallId)) { + if (state.prune.tools.has(toolCallId)) { return } @@ -214,6 +215,10 @@ export const insertPruneToolContext = ( logger: Logger, messages: WithParts[], ): void => { + if (state.manualMode || state.pendingManualTrigger) { + return + } + const pruneEnabled = config.tools.prune.permission !== "deny" const distillEnabled = config.tools.distill.permission !== "deny" const compressEnabled = config.tools.compress.permission !== "deny" diff --git a/lib/messages/prune.ts b/lib/messages/prune.ts index 09169700..2d2ac95a 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.has(part.callID)) { + if (!state.prune.tools.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.has(part.callID)) { + if (!state.prune.tools.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.has(part.callID)) { + if (!state.prune.tools.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.has(part.callID)) { + if (!state.prune.tools.has(part.callID)) { continue } if (part.state.status !== "error") { @@ -158,7 +158,7 @@ const filterCompressedRanges = ( logger: Logger, messages: WithParts[], ): void => { - if (!state.prune.messageIds?.size) { + if (!state.prune.messages?.size) { return } @@ -193,7 +193,7 @@ const filterCompressedRanges = ( } // Skip messages that are in the prune list - if (state.prune.messageIds.has(msgId)) { + if (state.prune.messages.has(msgId)) { continue } diff --git a/lib/prompts/index.ts b/lib/prompts/index.ts index e764099a..d46f3eac 100644 --- a/lib/prompts/index.ts +++ b/lib/prompts/index.ts @@ -10,10 +10,11 @@ export interface ToolFlags { distill: boolean compress: boolean prune: boolean + manual: boolean } function processConditionals(template: string, flags: ToolFlags): string { - const tools = ["distill", "compress", "prune"] as const + const tools = ["distill", "compress", "prune", "manual"] as const let result = template // Strip comments: // ... // result = result.replace(/\/\/.*?\/\//g, "") diff --git a/lib/prompts/system.md b/lib/prompts/system.md index 4ea2b6b1..48ce2a4b 100644 --- a/lib/prompts/system.md +++ b/lib/prompts/system.md @@ -35,6 +35,16 @@ The session is your responsibility, and effective context management is CRITICAL 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. + +Manual mode is enabled. Do NOT use distill, compress, or prune unless the user has explicitly triggered it through a manual marker. + +Only use the prune tool after seeing `` in the current user instruction context. +Only use the distill tool after seeing `` in the current user instruction context. +Only use the compress tool after seeing `` in the current user instruction context. + +After completing a manually triggered context-management action, STOP IMMEDIATELY. Do NOT continue with any task execution. End your response right after the tool use completes and wait for the next user input. + + This chat environment injects context information on your behalf in the form of a list to help you manage context effectively. Carefully read the list and use it to inform your management decisions. The list is automatically updated after each turn to reflect the current state of manageable tools and context usage. If no list is present, do NOT attempt to prune anything. There may be tools in session context that do not appear in the list, this is expected, remember that you can ONLY prune what you see in list. diff --git a/lib/shared-utils.ts b/lib/shared-utils.ts index 0baab713..6d1b5405 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.has(msg.info.id)) { + if (state.prune.messages.has(msg.info.id)) { return true } return false diff --git a/lib/state/persistence.ts b/lib/state/persistence.ts index 0c368380..725ffc71 100644 --- a/lib/state/persistence.ts +++ b/lib/state/persistence.ts @@ -11,10 +11,14 @@ import { join } from "path" import type { SessionState, SessionStats, CompressSummary } from "./types" import type { Logger } from "../logger" -/** Prune state as stored on disk (arrays for JSON compatibility) */ +/** Prune state as stored on disk */ export interface PersistedPrune { - toolIds: string[] - messageIds: string[] + // New format: tool/message IDs with token counts + tools?: Record + messages?: Record + // Legacy format: plain ID arrays (backward compatibility) + toolIds?: string[] + messageIds?: string[] } export interface PersistedSessionState { @@ -58,8 +62,8 @@ export async function saveSessionState( const state: PersistedSessionState = { sessionName: sessionName, prune: { - toolIds: [...sessionState.prune.toolIds], - messageIds: [...sessionState.prune.messageIds], + tools: Object.fromEntries(sessionState.prune.tools), + messages: Object.fromEntries(sessionState.prune.messages), }, compressSummaries: sessionState.compressSummaries, stats: sessionState.stats, @@ -96,7 +100,9 @@ export async function loadSessionState( const content = await fs.readFile(filePath, "utf-8") const state = JSON.parse(content) as PersistedSessionState - if (!state || !state.prune || !Array.isArray(state.prune.toolIds) || !state.stats) { + const hasNewFormat = state?.prune?.tools && typeof state.prune.tools === "object" + const hasLegacyFormat = Array.isArray(state?.prune?.toolIds) + if (!state || !state.prune || (!hasNewFormat && !hasLegacyFormat) || !state.stats) { logger.warn("Invalid session state file, ignoring", { sessionId: sessionId, }) @@ -166,10 +172,14 @@ export async function loadAllSessionStats(logger: Logger): Promise => { const lastUserMessage = getLastUserMessage(messages) if (!lastUserMessage) { @@ -25,7 +27,14 @@ export const checkSession = async ( if (state.sessionId === null || state.sessionId !== lastSessionId) { logger.info(`Session changed: ${state.sessionId} -> ${lastSessionId}`) try { - await ensureSessionInitialized(client, state, lastSessionId, logger, messages) + await ensureSessionInitialized( + client, + state, + lastSessionId, + logger, + messages, + manualModeDefault, + ) } catch (err: any) { logger.error("Failed to initialize session state", { error: err.message }) } @@ -47,9 +56,11 @@ export function createSessionState(): SessionState { return { sessionId: null, isSubAgent: false, + manualMode: false, + pendingManualTrigger: null, prune: { - toolIds: new Set(), - messageIds: new Set(), + tools: new Map(), + messages: new Map(), }, compressSummaries: [], stats: { @@ -70,9 +81,11 @@ export function createSessionState(): SessionState { export function resetSessionState(state: SessionState): void { state.sessionId = null state.isSubAgent = false + state.manualMode = false + state.pendingManualTrigger = null state.prune = { - toolIds: new Set(), - messageIds: new Set(), + tools: new Map(), + messages: new Map(), } state.compressSummaries = [] state.stats = { @@ -95,20 +108,22 @@ export async function ensureSessionInitialized( sessionId: string, logger: Logger, messages: WithParts[], + manualModeDefault: boolean, ): Promise { if (state.sessionId === sessionId) { return } - logger.info("session ID = " + sessionId) - logger.info("Initializing session state", { sessionId: sessionId }) + // logger.info("session ID = " + sessionId) + // logger.info("Initializing session state", { sessionId: sessionId }) resetSessionState(state) + state.manualMode = manualModeDefault state.sessionId = sessionId const isSubAgent = await isSubAgentSession(client, sessionId) state.isSubAgent = isSubAgent - logger.info("isSubAgent = " + isSubAgent) + // logger.info("isSubAgent = " + isSubAgent) state.lastCompaction = findLastCompactionTimestamp(messages) state.currentTurn = countTurns(state, messages) @@ -118,10 +133,8 @@ export async function ensureSessionInitialized( return } - state.prune = { - toolIds: new Set(persisted.prune.toolIds || []), - messageIds: new Set(persisted.prune.messageIds || []), - } + state.prune.tools = loadPruneMap(persisted.prune.tools, persisted.prune.toolIds) + state.prune.messages = loadPruneMap(persisted.prune.messages, persisted.prune.messageIds) state.compressSummaries = persisted.compressSummaries || [] state.stats = { pruneTokenCounter: persisted.stats?.pruneTokenCounter || 0, diff --git a/lib/state/tool-cache.ts b/lib/state/tool-cache.ts index a11d9bdd..c903b48a 100644 --- a/lib/state/tool-cache.ts +++ b/lib/state/tool-cache.ts @@ -7,14 +7,14 @@ import { countToolTokens } from "../strategies/utils" const MAX_TOOL_CACHE_SIZE = 1000 /** - * Sync tool parameters from OpenCode's session.messages() API. + * Sync tool parameters from session messages. */ -export async function syncToolCache( +export function syncToolCache( state: SessionState, config: PluginConfig, logger: Logger, messages: WithParts[], -): Promise { +): void { try { logger.info("Syncing tool parameters from OpenCode messages") diff --git a/lib/state/types.ts b/lib/state/types.ts index 3aa41a88..b9942289 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -27,13 +27,20 @@ export interface CompressSummary { } export interface Prune { - toolIds: Set - messageIds: Set + tools: Map + messages: Map +} + +export interface PendingManualTrigger { + sessionId: string + prompt: string } export interface SessionState { sessionId: string | null isSubAgent: boolean + manualMode: boolean + pendingManualTrigger: PendingManualTrigger | null prune: Prune compressSummaries: CompressSummary[] stats: SessionStats diff --git a/lib/state/utils.ts b/lib/state/utils.ts index 343a3574..1550f014 100644 --- a/lib/state/utils.ts +++ b/lib/state/utils.ts @@ -36,10 +36,19 @@ export function countTurns(state: SessionState, messages: WithParts[]): number { return turnCount } +export function loadPruneMap( + obj?: Record, + legacyArr?: string[], +): Map { + if (obj) return new Map(Object.entries(obj)) + if (legacyArr) return new Map(legacyArr.map((id) => [id, 0])) + return new Map() +} + export function resetOnCompaction(state: SessionState): void { state.toolParameters.clear() - state.prune.toolIds = new Set() - state.prune.messageIds = new Set() + state.prune.tools = new Map() + state.prune.messages = new Map() state.compressSummaries = [] state.nudgeCounter = 0 state.lastToolPrune = false diff --git a/lib/strategies/deduplication.ts b/lib/strategies/deduplication.ts index 33c43a88..aff2d019 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 { getFilePathsFromParameters, isProtected } from "../protected-file-patterns" -import { calculateTokensSaved } from "./utils" +import { getTotalToolTokens } from "./utils" /** * Deduplication strategy - prunes older tool calls that have identical @@ -15,6 +15,10 @@ export const deduplicate = ( config: PluginConfig, messages: WithParts[], ): void => { + if (state.manualMode && !config.manualMode.automaticStrategies) { + return + } + if (!config.strategies.deduplication.enabled) { return } @@ -25,7 +29,7 @@ export const deduplicate = ( } // Filter out IDs already pruned - const unprunedIds = allToolIds.filter((id) => !state.prune.toolIds.has(id)) + const unprunedIds = allToolIds.filter((id) => !state.prune.tools.has(id)) if (unprunedIds.length === 0) { return @@ -57,7 +61,10 @@ export const deduplicate = ( if (!signatureMap.has(signature)) { signatureMap.set(signature, []) } - signatureMap.get(signature)!.push(id) + const ids = signatureMap.get(signature) + if (ids) { + ids.push(id) + } } // Find duplicates - keep only the most recent (last) in each group @@ -71,11 +78,12 @@ export const deduplicate = ( } } - state.stats.totalPruneTokens += calculateTokensSaved(state, messages, newPruneIds) + state.stats.totalPruneTokens += getTotalToolTokens(state, newPruneIds) if (newPruneIds.length > 0) { for (const id of newPruneIds) { - state.prune.toolIds.add(id) + const entry = state.toolParameters.get(id) + state.prune.tools.set(id, entry?.tokenCount ?? 0) } 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 65b43e35..b8a140b9 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 { getFilePathsFromParameters, isProtected } from "../protected-file-patterns" -import { calculateTokensSaved } from "./utils" +import { getTotalToolTokens } from "./utils" /** * Purge Errors strategy - prunes tool inputs for tools that errored @@ -18,6 +18,10 @@ export const purgeErrors = ( config: PluginConfig, messages: WithParts[], ): void => { + if (state.manualMode && !config.manualMode.automaticStrategies) { + return + } + if (!config.strategies.purgeErrors.enabled) { return } @@ -28,7 +32,7 @@ export const purgeErrors = ( } // Filter out IDs already pruned - const unprunedIds = allToolIds.filter((id) => !state.prune.toolIds.has(id)) + const unprunedIds = allToolIds.filter((id) => !state.prune.tools.has(id)) if (unprunedIds.length === 0) { return @@ -68,9 +72,10 @@ export const purgeErrors = ( } if (newPruneIds.length > 0) { - state.stats.totalPruneTokens += calculateTokensSaved(state, messages, newPruneIds) + state.stats.totalPruneTokens += getTotalToolTokens(state, newPruneIds) for (const id of newPruneIds) { - state.prune.toolIds.add(id) + const entry = state.toolParameters.get(id) + state.prune.tools.set(id, entry?.tokenCount ?? 0) } 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 66c90251..e61b1d0a 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 { getFilePathsFromParameters, isProtected } from "../protected-file-patterns" -import { calculateTokensSaved } from "./utils" +import { getTotalToolTokens } from "./utils" /** * Supersede Writes strategy - prunes write tool inputs for files that have @@ -18,6 +18,10 @@ export const supersedeWrites = ( config: PluginConfig, messages: WithParts[], ): void => { + if (state.manualMode && !config.manualMode.automaticStrategies) { + return + } + if (!config.strategies.supersedeWrites.enabled) { return } @@ -28,7 +32,7 @@ export const supersedeWrites = ( } // Filter out IDs already pruned - const unprunedIds = allToolIds.filter((id) => !state.prune.toolIds.has(id)) + const unprunedIds = allToolIds.filter((id) => !state.prune.tools.has(id)) if (unprunedIds.length === 0) { return } @@ -61,12 +65,18 @@ export const supersedeWrites = ( if (!writesByFile.has(filePath)) { writesByFile.set(filePath, []) } - writesByFile.get(filePath)!.push({ id, index: i }) + const writes = writesByFile.get(filePath) + if (writes) { + writes.push({ id, index: i }) + } } else if (metadata.tool === "read") { if (!readsByFile.has(filePath)) { readsByFile.set(filePath, []) } - readsByFile.get(filePath)!.push(i) + const reads = readsByFile.get(filePath) + if (reads) { + reads.push(i) + } } } @@ -82,7 +92,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 (state.prune.toolIds.has(write.id)) { + if (state.prune.tools.has(write.id)) { continue } @@ -95,9 +105,10 @@ export const supersedeWrites = ( } if (newPruneIds.length > 0) { - state.stats.totalPruneTokens += calculateTokensSaved(state, messages, newPruneIds) + state.stats.totalPruneTokens += getTotalToolTokens(state, newPruneIds) for (const id of newPruneIds) { - state.prune.toolIds.add(id) + const entry = state.toolParameters.get(id) + state.prune.tools.set(id, entry?.tokenCount ?? 0) } logger.debug(`Marked ${newPruneIds.length} superseded write tool calls for pruning`) } diff --git a/lib/strategies/utils.ts b/lib/strategies/utils.ts index d89bb730..a5b31b97 100644 --- a/lib/strategies/utils.ts +++ b/lib/strategies/utils.ts @@ -2,7 +2,7 @@ import { SessionState, WithParts } from "../state" 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" +import { getLastUserMessage } from "../shared-utils" /** * Get current token usage from the last assistant message. @@ -113,27 +113,23 @@ export function countToolTokens(part: any): number { return estimateTokensBatch(contents) } -export const calculateTokensSaved = ( - state: SessionState, - messages: WithParts[], - pruneToolIds: string[], -): number => { - try { - const contents: string[] = [] - 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 !== "tool" || !pruneToolIds.includes(part.callID)) { - continue - } - contents.push(...extractToolContent(part)) - } +export function getTotalToolTokens(state: SessionState, toolIds: string[]): number { + let total = 0 + for (const id of toolIds) { + const entry = state.toolParameters.get(id) + total += entry?.tokenCount ?? 0 + } + return total +} + +export function countMessageTextTokens(msg: WithParts): number { + const texts: string[] = [] + const parts = Array.isArray(msg.parts) ? msg.parts : [] + for (const part of parts) { + if (part.type === "text") { + texts.push(part.text) } - return estimateTokensBatch(contents) - } catch (error: any) { - return 0 } + if (texts.length === 0) return 0 + return estimateTokensBatch(texts) } diff --git a/lib/tools/compress.ts b/lib/tools/compress.ts index 4a40a18b..5c019aed 100644 --- a/lib/tools/compress.ts +++ b/lib/tools/compress.ts @@ -4,13 +4,8 @@ import type { PruneToolContext } from "./types" import { ensureSessionInitialized } from "../state" import { saveSessionState } from "../state/persistence" import { loadPrompt } from "../prompts" -import { estimateTokensBatch, getCurrentParams } from "../strategies/utils" -import { - collectContentInRange, - findStringInMessages, - collectToolIdsInRange, - collectMessageIdsInRange, -} from "./utils" +import { getCurrentParams, getTotalToolTokens, countMessageTextTokens } from "../strategies/utils" +import { findStringInMessages, collectToolIdsInRange, collectMessageIdsInRange } from "./utils" import { sendCompressNotification } from "../ui/notification" const COMPRESS_TOOL_DESCRIPTION = loadPrompt("compress-tool-spec") @@ -78,7 +73,14 @@ export function createCompressTool(ctx: PruneToolContext): ReturnType containedMessageIds.includes(s.anchorMessageId), ) if (removedSummaries.length > 0) { - // logger.info("Removing subsumed compress summaries", { - // count: removedSummaries.length, - // anchorIds: removedSummaries.map((s) => s.anchorMessageId), - // }) state.compressSummaries = state.compressSummaries.filter( (s) => !containedMessageIds.includes(s.anchorMessageId), ) @@ -141,12 +132,22 @@ export function createCompressTool(ctx: PruneToolContext): ReturnType !state.prune.tools.has(id)) + const toolTokens = getTotalToolTokens(state, newToolIds) + for (const id of newToolIds) { + const entry = state.toolParameters.get(id) + state.prune.tools.set(id, entry?.tokenCount ?? 0) + } + const estimatedCompressedTokens = textTokens + toolTokens state.stats.pruneTokenCounter += estimatedCompressedTokens diff --git a/lib/tools/prune-shared.ts b/lib/tools/prune-shared.ts index c1253f76..6555c4ae 100644 --- a/lib/tools/prune-shared.ts +++ b/lib/tools/prune-shared.ts @@ -7,8 +7,9 @@ import { PruneReason, sendUnifiedNotification } from "../ui/notification" import { formatPruningResultForTool } from "../ui/utils" import { ensureSessionInitialized } from "../state" import { saveSessionState } from "../state/persistence" -import { calculateTokensSaved, getCurrentParams } from "../strategies/utils" +import { getTotalToolTokens, getCurrentParams } from "../strategies/utils" import { getFilePathsFromParameters, isProtected } from "../protected-file-patterns" +import { buildToolIdList } from "../messages/utils" // Shared logic for executing prune operations. export async function executePruneOperation( @@ -47,8 +48,18 @@ export async function executePruneOperation( }) const messages: WithParts[] = messagesResponse.data || messagesResponse - await ensureSessionInitialized(ctx.client, state, sessionId, logger, messages) - await syncToolCache(state, config, logger, messages) + // These 3 are probably not needed as they should always be set in the message + // transform handler, but in case something causes state to reset, this is a safety net + await ensureSessionInitialized( + ctx.client, + state, + sessionId, + logger, + messages, + config.manualMode.enabled, + ) + syncToolCache(state, config, logger, messages) + buildToolIdList(state, messages, logger) const currentParams = getCurrentParams(state, messages, logger) @@ -116,7 +127,8 @@ export async function executePruneOperation( const pruneToolIds: string[] = validNumericIds.map((index) => toolIdList[index]) for (const id of pruneToolIds) { - state.prune.toolIds.add(id) + const entry = state.toolParameters.get(id) + state.prune.tools.set(id, entry?.tokenCount ?? 0) } const toolMetadata = new Map() @@ -129,7 +141,7 @@ export async function executePruneOperation( } } - state.stats.pruneTokenCounter += calculateTokensSaved(state, messages, pruneToolIds) + state.stats.pruneTokenCounter += getTotalToolTokens(state, pruneToolIds) await sendUnifiedNotification( client, diff --git a/lib/tools/utils.ts b/lib/tools/utils.ts index 5cea7c45..5eeca104 100644 --- a/lib/tools/utils.ts +++ b/lib/tools/utils.ts @@ -286,43 +286,3 @@ export function collectMessageIdsInRange( return messageIds } - -export function collectContentInRange( - messages: WithParts[], - startIndex: number, - endIndex: number, -): string[] { - const contents: string[] = [] - for (let i = startIndex; i <= endIndex; i++) { - const msg = messages[i] - const parts = Array.isArray(msg.parts) ? msg.parts : [] - for (const part of parts) { - if (part.type === "text") { - contents.push(part.text) - } else if (part.type === "tool") { - const toolState = part.state as any - if (toolState?.input) { - contents.push( - typeof toolState.input === "string" - ? toolState.input - : JSON.stringify(toolState.input), - ) - } - if (toolState?.status === "completed" && toolState?.output) { - contents.push( - typeof toolState.output === "string" - ? toolState.output - : JSON.stringify(toolState.output), - ) - } else if (toolState?.status === "error" && toolState?.error) { - contents.push( - typeof toolState.error === "string" - ? toolState.error - : JSON.stringify(toolState.error), - ) - } - } - } - } - return contents -} diff --git a/package-lock.json b/package-lock.json index fb0b67d4..cdfc8d52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tarquinen/opencode-dcp", - "version": "2.0.2", + "version": "2.0.3-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "2.0.2", + "version": "2.0.3-beta.0", "license": "AGPL-3.0-or-later", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", diff --git a/package.json b/package.json index 4a348f55..95e5a758 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@tarquinen/opencode-dcp", - "version": "2.0.2", + "version": "2.0.3-beta.0", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js",