diff --git a/README.md b/README.md index c4b09bc..c5d2245 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,12 @@ DCP uses its own config file: "debug": false, // Notification display: "off", "minimal", or "detailed" "pruneNotification": "detailed", - // Enable or disable slash commands (/dcp) - "commands": true, + // 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, @@ -135,6 +139,7 @@ DCP provides a `/dcp` slash command: - `/dcp` — Shows available DCP commands - `/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`. ### Turn Protection diff --git a/dcp.schema.json b/dcp.schema.json index 6e0b01a..91db1b3 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -27,9 +27,28 @@ "description": "Level of notification shown when pruning occurs" }, "commands": { - "type": "boolean", - "default": true, - "description": "Enable DCP slash commands (/dcp)" + "type": "object", + "description": "Configuration for DCP slash commands (/dcp)", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable DCP slash commands (/dcp)" + }, + "protectedTools": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Additional tool names to protect from pruning via commands (e.g., /dcp sweep)" + } + }, + "default": { + "enabled": true, + "protectedTools": [] + } }, "turnProtection": { "type": "object", diff --git a/index.ts b/index.ts index 6b39ed5..0c7ae2a 100644 --- a/index.ts +++ b/index.ts @@ -47,6 +47,13 @@ const plugin: Plugin = (async (ctx) => { state.variant = input.variant logger.debug("Cached variant from chat.message hook", { variant: input.variant }) }, + "command.execute.before": createCommandExecuteHandler( + ctx.client, + state, + logger, + config, + ctx.directory, + ), tool: { ...(config.tools.discard.enabled && { discard: createDiscardTool({ @@ -68,7 +75,7 @@ const plugin: Plugin = (async (ctx) => { }), }, config: async (opencodeConfig) => { - if (config.commands) { + if (config.commands.enabled) { opencodeConfig.command ??= {} opencodeConfig.command["dcp"] = { template: "", @@ -91,7 +98,6 @@ const plugin: Plugin = (async (ctx) => { ) } }, - "command.execute.before": createCommandExecuteHandler(ctx.client, state, logger, config), } }) satisfies Plugin diff --git a/lib/commands/help.ts b/lib/commands/help.ts index 56d4316..32b9a19 100644 --- a/lib/commands/help.ts +++ b/lib/commands/help.ts @@ -23,13 +23,9 @@ function formatHelpMessage(): string { lines.push("│ DCP Commands │") lines.push("╰───────────────────────────────────────────────────────────╯") lines.push("") - lines.push("Available commands:") - lines.push(" context - Show token usage breakdown for current session") - lines.push(" stats - Show DCP pruning statistics") - lines.push("") - lines.push("Examples:") - lines.push(" /dcp context") - lines.push(" /dcp stats") + 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") lines.push("") return lines.join("\n") diff --git a/lib/commands/sweep.ts b/lib/commands/sweep.ts new file mode 100644 index 0000000..961976a --- /dev/null +++ b/lib/commands/sweep.ts @@ -0,0 +1,258 @@ +/** + * DCP Sweep command handler. + * Prunes tool outputs since the last user message, or the last N tools. + * + * Usage: + * /dcp sweep - Prune all tools since the previous user message + * /dcp sweep 10 - Prune the last 10 tools + */ + +import type { Logger } from "../logger" +import type { SessionState, WithParts, ToolParameterEntry } from "../state" +import type { PluginConfig } from "../config" +import { sendIgnoredMessage } from "../ui/notification" +import { formatPrunedItemsList } from "../ui/utils" +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" + +export interface SweepCommandContext { + client: any + state: SessionState + config: PluginConfig + logger: Logger + sessionId: string + messages: WithParts[] + args: string[] + workingDirectory: string +} + +function findLastUserMessageIndex(messages: WithParts[]): number { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.info.role === "user" && !isIgnoredUserMessage(msg)) { + return i + } + } + + return -1 +} + +function collectToolIdsAfterIndex( + state: SessionState, + messages: WithParts[], + afterIndex: number, +): string[] { + const toolIds: string[] = [] + + for (let i = afterIndex + 1; i < messages.length; i++) { + const msg = messages[i] + if (isMessageCompacted(state, msg)) { + continue + } + if (msg.parts) { + for (const part of msg.parts) { + if (part.type === "tool" && part.callID && part.tool) { + toolIds.push(part.callID) + } + } + } + } + + return toolIds +} + +function formatNoUserMessage(): string { + const lines: string[] = [] + + lines.push("╭───────────────────────────────────────────────────────────╮") + lines.push("│ DCP Sweep │") + lines.push("╰───────────────────────────────────────────────────────────╯") + lines.push("") + lines.push("Nothing swept: no user message found.") + + return lines.join("\n") +} + +function formatSweepMessage( + toolCount: number, + tokensSaved: number, + mode: "since-user" | "last-n", + toolIds: string[], + toolMetadata: Map, + workingDirectory?: string, + skippedProtected?: number, +): string { + const lines: string[] = [] + + lines.push("╭───────────────────────────────────────────────────────────╮") + lines.push("│ DCP Sweep │") + lines.push("╰───────────────────────────────────────────────────────────╯") + lines.push("") + + if (toolCount === 0) { + if (mode === "since-user") { + lines.push("No tools found since the previous user message.") + } else { + lines.push(`No tools found to sweep.`) + } + if (skippedProtected && skippedProtected > 0) { + lines.push(`(${skippedProtected} protected tool(s) skipped)`) + } + } else { + if (mode === "since-user") { + lines.push(`Swept ${toolCount} tool(s) since the previous user message.`) + } else { + lines.push(`Swept the last ${toolCount} tool(s).`) + } + lines.push(`Tokens saved: ~${tokensSaved.toLocaleString()}`) + if (skippedProtected && skippedProtected > 0) { + lines.push(`(${skippedProtected} protected tool(s) skipped)`) + } + lines.push("") + const itemLines = formatPrunedItemsList(toolIds, toolMetadata, workingDirectory) + lines.push(...itemLines) + } + + return lines.join("\n") +} + +export async function handleSweepCommand(ctx: SweepCommandContext): Promise { + const { client, state, config, logger, sessionId, messages, args, workingDirectory } = ctx + + const params = getCurrentParams(state, messages, logger) + const protectedTools = config.commands.protectedTools + + // Parse optional numeric argument + const numArg = args[0] ? parseInt(args[0], 10) : null + const isLastNMode = numArg !== null && !isNaN(numArg) && numArg > 0 + + let toolIdsToSweep: string[] + let mode: "since-user" | "last-n" + + if (isLastNMode) { + // Mode: Sweep last N tools + mode = "last-n" + const allToolIds = buildToolIdList(state, messages, logger) + const startIndex = Math.max(0, allToolIds.length - numArg!) + toolIdsToSweep = allToolIds.slice(startIndex) + logger.info(`Sweep command: last ${numArg} mode, found ${toolIdsToSweep.length} tools`) + } else { + // Mode: Sweep since last user message + mode = "since-user" + const lastUserMsgIndex = findLastUserMessageIndex(messages) + + if (lastUserMsgIndex === -1) { + // No user message found - show message and return + const message = formatNoUserMessage() + await sendIgnoredMessage(client, sessionId, message, params, logger) + logger.info("Sweep command: no user message found") + return + } else { + toolIdsToSweep = collectToolIdsAfterIndex(state, messages, lastUserMsgIndex) + logger.info( + `Sweep command: found last user at index ${lastUserMsgIndex}, sweeping ${toolIdsToSweep.length} tools`, + ) + } + } + + // Filter out already-pruned tools, protected tools, and protected file paths + const existingPrunedSet = new Set(state.prune.toolIds) + const newToolIds = toolIdsToSweep.filter((id) => { + if (existingPrunedSet.has(id)) { + return false + } + const entry = state.toolParameters.get(id) + if (!entry) { + return true + } + if (protectedTools.includes(entry.tool)) { + logger.debug(`Sweep: skipping protected tool ${entry.tool} (${id})`) + return false + } + const filePath = getFilePathFromParameters(entry.parameters) + if (isProtectedFilePath(filePath, config.protectedFilePatterns)) { + logger.debug(`Sweep: skipping protected file path ${filePath} (${id})`) + return false + } + return true + }) + + // Count how many were skipped due to protection + const skippedProtected = toolIdsToSweep.filter((id) => { + const entry = state.toolParameters.get(id) + if (!entry) { + return false + } + if (protectedTools.includes(entry.tool)) { + return true + } + const filePath = getFilePathFromParameters(entry.parameters) + if (isProtectedFilePath(filePath, config.protectedFilePatterns)) { + return true + } + return false + }).length + + if (newToolIds.length === 0) { + const message = formatSweepMessage( + 0, + 0, + mode, + [], + new Map(), + workingDirectory, + skippedProtected, + ) + await sendIgnoredMessage(client, sessionId, message, params, logger) + logger.info("Sweep command: no new tools to sweep", { skippedProtected }) + return + } + + // Add to prune list + state.prune.toolIds.push(...newToolIds) + + // Calculate tokens saved + const tokensSaved = calculateTokensSaved(state, messages, newToolIds) + state.stats.pruneTokenCounter += tokensSaved + state.stats.totalPruneTokens += state.stats.pruneTokenCounter + state.stats.pruneTokenCounter = 0 + + // Collect metadata for logging + const toolMetadata: Map = new Map() + for (const id of newToolIds) { + const entry = state.toolParameters.get(id) + if (entry) { + toolMetadata.set(id, entry) + } + } + + // Persist state + saveSessionState(state, logger).catch((err) => + logger.error("Failed to persist state after sweep", { error: err.message }), + ) + + const message = formatSweepMessage( + newToolIds.length, + tokensSaved, + mode, + newToolIds, + toolMetadata, + workingDirectory, + skippedProtected, + ) + await sendIgnoredMessage(client, sessionId, message, params, logger) + + logger.info("Sweep command completed", { + toolsSwept: newToolIds.length, + tokensSaved, + skippedProtected, + mode, + tools: Array.from(toolMetadata.entries()).map(([id, entry]) => ({ + id, + tool: entry.tool, + })), + }) +} diff --git a/lib/config.ts b/lib/config.ts index 1547dad..0bda681 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -30,6 +30,11 @@ export interface Tools { extract: ExtractTool } +export interface Commands { + enabled: boolean + protectedTools: string[] +} + export interface SupersedeWrites { enabled: boolean } @@ -49,7 +54,7 @@ export interface PluginConfig { enabled: boolean debug: boolean pruneNotification: "off" | "minimal" | "detailed" - commands: boolean + commands: Commands turnProtection: TurnProtection protectedFilePatterns: string[] tools: Tools @@ -86,6 +91,8 @@ export const VALID_CONFIG_KEYS = new Set([ "turnProtection.turns", "protectedFilePatterns", "commands", + "commands.enabled", + "commands.protectedTools", "tools", "tools.settings", "tools.settings.nudgeEnabled", @@ -200,12 +207,29 @@ function validateConfigTypes(config: Record): ValidationError[] { // Commands validator const commands = config.commands - if (commands !== undefined && typeof commands !== "boolean") { - errors.push({ - key: "commands", - expected: "boolean", - actual: typeof commands, - }) + if (commands !== undefined) { + if (typeof commands === "object") { + if (commands.enabled !== undefined && typeof commands.enabled !== "boolean") { + errors.push({ + key: "commands.enabled", + expected: "boolean", + actual: typeof commands.enabled, + }) + } + if (commands.protectedTools !== undefined && !Array.isArray(commands.protectedTools)) { + errors.push({ + key: "commands.protectedTools", + expected: "string[]", + actual: typeof commands.protectedTools, + }) + } + } else { + errors.push({ + key: "commands", + expected: "{ enabled: boolean, protectedTools: string[] }", + actual: typeof commands, + }) + } } // Tools validators @@ -400,7 +424,10 @@ const defaultConfig: PluginConfig = { enabled: true, debug: false, pruneNotification: "detailed", - commands: true, + commands: { + enabled: true, + protectedTools: [...DEFAULT_PROTECTED_TOOLS], + }, turnProtection: { enabled: false, turns: 4, @@ -511,8 +538,12 @@ function createDefaultConfig(): void { "debug": false, // Notification display: "off", "minimal", or "detailed" "pruneNotification": "detailed", - // Enable or disable slash commands (/dcp) - "commands": true, + // Slash commands (/dcp) configuration + "commands": { + "enabled": true, + // Additional tools to protect from pruning via commands + "protectedTools": [] + }, // Protect from pruning for message turns "turnProtection": { "enabled": false, @@ -657,13 +688,20 @@ function mergeCommands( override?: Partial, ): PluginConfig["commands"] { if (override === undefined) return base - return override as boolean + + return { + enabled: override.enabled ?? base.enabled, + protectedTools: [...new Set([...base.protectedTools, ...(override.protectedTools ?? [])])], + } } function deepCloneConfig(config: PluginConfig): PluginConfig { return { ...config, - commands: config.commands, + commands: { + enabled: config.commands.enabled, + protectedTools: [...config.commands.protectedTools], + }, turnProtection: { ...config.turnProtection }, protectedFilePatterns: [...config.protectedFilePatterns], tools: { diff --git a/lib/hooks.ts b/lib/hooks.ts index 0533e37..aaf4388 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -9,6 +9,7 @@ import { loadPrompt } from "./prompts" import { handleStatsCommand } from "./commands/stats" import { handleContextCommand } from "./commands/context" import { handleHelpCommand } from "./commands/help" +import { handleSweepCommand } from "./commands/sweep" const INTERNAL_AGENT_SIGNATURES = [ "You are a title generator", @@ -85,12 +86,13 @@ export function createCommandExecuteHandler( state: SessionState, logger: Logger, config: PluginConfig, + workingDirectory: string, ) { return async ( input: { command: string; sessionID: string; arguments: string }, _output: { parts: any[] }, ) => { - if (!config.commands) { + if (!config.commands.enabled) { return } @@ -126,6 +128,20 @@ export function createCommandExecuteHandler( throw new Error("__DCP_STATS_HANDLED__") } + if (subcommand === "sweep") { + await handleSweepCommand({ + client, + state, + config, + logger, + sessionId: input.sessionID, + messages, + args: _subArgs, + workingDirectory, + }) + throw new Error("__DCP_SWEEP_HANDLED__") + } + await handleHelpCommand({ client, state,