Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <turns> message turns
"turnProtection": {
"enabled": false,
Expand Down Expand Up @@ -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

Expand Down
25 changes: 22 additions & 3 deletions dcp.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 8 additions & 2 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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: "",
Expand All @@ -91,7 +98,6 @@ const plugin: Plugin = (async (ctx) => {
)
}
},
"command.execute.before": createCommandExecuteHandler(ctx.client, state, logger, config),
}
}) satisfies Plugin

Expand Down
10 changes: 3 additions & 7 deletions lib/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
258 changes: 258 additions & 0 deletions lib/commands/sweep.ts
Original file line number Diff line number Diff line change
@@ -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<string, ToolParameterEntry>,
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<void> {
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<string, ToolParameterEntry> = 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,
})),
})
}
Loading