Skip to content

Commit db8541a

Browse files
authored
Merge pull request #300 from Opencode-DCP/dev
merge dev into master
2 parents 1b65ba9 + 91fc53d commit db8541a

File tree

7 files changed

+365
-27
lines changed

7 files changed

+365
-27
lines changed

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,12 @@ DCP uses its own config file:
7171
"debug": false,
7272
// Notification display: "off", "minimal", or "detailed"
7373
"pruneNotification": "detailed",
74-
// Enable or disable slash commands (/dcp)
75-
"commands": true,
74+
// Slash commands configuration
75+
"commands": {
76+
"enabled": true,
77+
// Additional tools to protect from pruning via commands (e.g., /dcp sweep)
78+
"protectedTools": [],
79+
},
7680
// Protect from pruning for <turns> message turns
7781
"turnProtection": {
7882
"enabled": false,
@@ -135,6 +139,7 @@ DCP provides a `/dcp` slash command:
135139
- `/dcp` — Shows available DCP commands
136140
- `/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.
137141
- `/dcp stats` — Shows cumulative pruning statistics across all sessions.
142+
- `/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`.
138143

139144
### Turn Protection
140145

dcp.schema.json

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,28 @@
2727
"description": "Level of notification shown when pruning occurs"
2828
},
2929
"commands": {
30-
"type": "boolean",
31-
"default": true,
32-
"description": "Enable DCP slash commands (/dcp)"
30+
"type": "object",
31+
"description": "Configuration for DCP slash commands (/dcp)",
32+
"additionalProperties": false,
33+
"properties": {
34+
"enabled": {
35+
"type": "boolean",
36+
"default": true,
37+
"description": "Enable DCP slash commands (/dcp)"
38+
},
39+
"protectedTools": {
40+
"type": "array",
41+
"items": {
42+
"type": "string"
43+
},
44+
"default": [],
45+
"description": "Additional tool names to protect from pruning via commands (e.g., /dcp sweep)"
46+
}
47+
},
48+
"default": {
49+
"enabled": true,
50+
"protectedTools": []
51+
}
3352
},
3453
"turnProtection": {
3554
"type": "object",

index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ const plugin: Plugin = (async (ctx) => {
4747
state.variant = input.variant
4848
logger.debug("Cached variant from chat.message hook", { variant: input.variant })
4949
},
50+
"command.execute.before": createCommandExecuteHandler(
51+
ctx.client,
52+
state,
53+
logger,
54+
config,
55+
ctx.directory,
56+
),
5057
tool: {
5158
...(config.tools.discard.enabled && {
5259
discard: createDiscardTool({
@@ -68,7 +75,7 @@ const plugin: Plugin = (async (ctx) => {
6875
}),
6976
},
7077
config: async (opencodeConfig) => {
71-
if (config.commands) {
78+
if (config.commands.enabled) {
7279
opencodeConfig.command ??= {}
7380
opencodeConfig.command["dcp"] = {
7481
template: "",
@@ -91,7 +98,6 @@ const plugin: Plugin = (async (ctx) => {
9198
)
9299
}
93100
},
94-
"command.execute.before": createCommandExecuteHandler(ctx.client, state, logger, config),
95101
}
96102
}) satisfies Plugin
97103

lib/commands/help.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,9 @@ function formatHelpMessage(): string {
2323
lines.push("│ DCP Commands │")
2424
lines.push("╰───────────────────────────────────────────────────────────╯")
2525
lines.push("")
26-
lines.push("Available commands:")
27-
lines.push(" context - Show token usage breakdown for current session")
28-
lines.push(" stats - Show DCP pruning statistics")
29-
lines.push("")
30-
lines.push("Examples:")
31-
lines.push(" /dcp context")
32-
lines.push(" /dcp stats")
26+
lines.push(" /dcp context Show token usage breakdown for current session")
27+
lines.push(" /dcp stats Show DCP pruning statistics")
28+
lines.push(" /dcp sweep [n] Prune tools since last user message, or last n tools")
3329
lines.push("")
3430

3531
return lines.join("\n")

lib/commands/sweep.ts

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
/**
2+
* DCP Sweep command handler.
3+
* Prunes tool outputs since the last user message, or the last N tools.
4+
*
5+
* Usage:
6+
* /dcp sweep - Prune all tools since the previous user message
7+
* /dcp sweep 10 - Prune the last 10 tools
8+
*/
9+
10+
import type { Logger } from "../logger"
11+
import type { SessionState, WithParts, ToolParameterEntry } from "../state"
12+
import type { PluginConfig } from "../config"
13+
import { sendIgnoredMessage } from "../ui/notification"
14+
import { formatPrunedItemsList } from "../ui/utils"
15+
import { getCurrentParams, calculateTokensSaved } from "../strategies/utils"
16+
import { buildToolIdList, isIgnoredUserMessage } from "../messages/utils"
17+
import { saveSessionState } from "../state/persistence"
18+
import { isMessageCompacted } from "../shared-utils"
19+
import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns"
20+
21+
export interface SweepCommandContext {
22+
client: any
23+
state: SessionState
24+
config: PluginConfig
25+
logger: Logger
26+
sessionId: string
27+
messages: WithParts[]
28+
args: string[]
29+
workingDirectory: string
30+
}
31+
32+
function findLastUserMessageIndex(messages: WithParts[]): number {
33+
for (let i = messages.length - 1; i >= 0; i--) {
34+
const msg = messages[i]
35+
if (msg.info.role === "user" && !isIgnoredUserMessage(msg)) {
36+
return i
37+
}
38+
}
39+
40+
return -1
41+
}
42+
43+
function collectToolIdsAfterIndex(
44+
state: SessionState,
45+
messages: WithParts[],
46+
afterIndex: number,
47+
): string[] {
48+
const toolIds: string[] = []
49+
50+
for (let i = afterIndex + 1; i < messages.length; i++) {
51+
const msg = messages[i]
52+
if (isMessageCompacted(state, msg)) {
53+
continue
54+
}
55+
if (msg.parts) {
56+
for (const part of msg.parts) {
57+
if (part.type === "tool" && part.callID && part.tool) {
58+
toolIds.push(part.callID)
59+
}
60+
}
61+
}
62+
}
63+
64+
return toolIds
65+
}
66+
67+
function formatNoUserMessage(): string {
68+
const lines: string[] = []
69+
70+
lines.push("╭───────────────────────────────────────────────────────────╮")
71+
lines.push("│ DCP Sweep │")
72+
lines.push("╰───────────────────────────────────────────────────────────╯")
73+
lines.push("")
74+
lines.push("Nothing swept: no user message found.")
75+
76+
return lines.join("\n")
77+
}
78+
79+
function formatSweepMessage(
80+
toolCount: number,
81+
tokensSaved: number,
82+
mode: "since-user" | "last-n",
83+
toolIds: string[],
84+
toolMetadata: Map<string, ToolParameterEntry>,
85+
workingDirectory?: string,
86+
skippedProtected?: number,
87+
): string {
88+
const lines: string[] = []
89+
90+
lines.push("╭───────────────────────────────────────────────────────────╮")
91+
lines.push("│ DCP Sweep │")
92+
lines.push("╰───────────────────────────────────────────────────────────╯")
93+
lines.push("")
94+
95+
if (toolCount === 0) {
96+
if (mode === "since-user") {
97+
lines.push("No tools found since the previous user message.")
98+
} else {
99+
lines.push(`No tools found to sweep.`)
100+
}
101+
if (skippedProtected && skippedProtected > 0) {
102+
lines.push(`(${skippedProtected} protected tool(s) skipped)`)
103+
}
104+
} else {
105+
if (mode === "since-user") {
106+
lines.push(`Swept ${toolCount} tool(s) since the previous user message.`)
107+
} else {
108+
lines.push(`Swept the last ${toolCount} tool(s).`)
109+
}
110+
lines.push(`Tokens saved: ~${tokensSaved.toLocaleString()}`)
111+
if (skippedProtected && skippedProtected > 0) {
112+
lines.push(`(${skippedProtected} protected tool(s) skipped)`)
113+
}
114+
lines.push("")
115+
const itemLines = formatPrunedItemsList(toolIds, toolMetadata, workingDirectory)
116+
lines.push(...itemLines)
117+
}
118+
119+
return lines.join("\n")
120+
}
121+
122+
export async function handleSweepCommand(ctx: SweepCommandContext): Promise<void> {
123+
const { client, state, config, logger, sessionId, messages, args, workingDirectory } = ctx
124+
125+
const params = getCurrentParams(state, messages, logger)
126+
const protectedTools = config.commands.protectedTools
127+
128+
// Parse optional numeric argument
129+
const numArg = args[0] ? parseInt(args[0], 10) : null
130+
const isLastNMode = numArg !== null && !isNaN(numArg) && numArg > 0
131+
132+
let toolIdsToSweep: string[]
133+
let mode: "since-user" | "last-n"
134+
135+
if (isLastNMode) {
136+
// Mode: Sweep last N tools
137+
mode = "last-n"
138+
const allToolIds = buildToolIdList(state, messages, logger)
139+
const startIndex = Math.max(0, allToolIds.length - numArg!)
140+
toolIdsToSweep = allToolIds.slice(startIndex)
141+
logger.info(`Sweep command: last ${numArg} mode, found ${toolIdsToSweep.length} tools`)
142+
} else {
143+
// Mode: Sweep since last user message
144+
mode = "since-user"
145+
const lastUserMsgIndex = findLastUserMessageIndex(messages)
146+
147+
if (lastUserMsgIndex === -1) {
148+
// No user message found - show message and return
149+
const message = formatNoUserMessage()
150+
await sendIgnoredMessage(client, sessionId, message, params, logger)
151+
logger.info("Sweep command: no user message found")
152+
return
153+
} else {
154+
toolIdsToSweep = collectToolIdsAfterIndex(state, messages, lastUserMsgIndex)
155+
logger.info(
156+
`Sweep command: found last user at index ${lastUserMsgIndex}, sweeping ${toolIdsToSweep.length} tools`,
157+
)
158+
}
159+
}
160+
161+
// Filter out already-pruned tools, protected tools, and protected file paths
162+
const existingPrunedSet = new Set(state.prune.toolIds)
163+
const newToolIds = toolIdsToSweep.filter((id) => {
164+
if (existingPrunedSet.has(id)) {
165+
return false
166+
}
167+
const entry = state.toolParameters.get(id)
168+
if (!entry) {
169+
return true
170+
}
171+
if (protectedTools.includes(entry.tool)) {
172+
logger.debug(`Sweep: skipping protected tool ${entry.tool} (${id})`)
173+
return false
174+
}
175+
const filePath = getFilePathFromParameters(entry.parameters)
176+
if (isProtectedFilePath(filePath, config.protectedFilePatterns)) {
177+
logger.debug(`Sweep: skipping protected file path ${filePath} (${id})`)
178+
return false
179+
}
180+
return true
181+
})
182+
183+
// Count how many were skipped due to protection
184+
const skippedProtected = toolIdsToSweep.filter((id) => {
185+
const entry = state.toolParameters.get(id)
186+
if (!entry) {
187+
return false
188+
}
189+
if (protectedTools.includes(entry.tool)) {
190+
return true
191+
}
192+
const filePath = getFilePathFromParameters(entry.parameters)
193+
if (isProtectedFilePath(filePath, config.protectedFilePatterns)) {
194+
return true
195+
}
196+
return false
197+
}).length
198+
199+
if (newToolIds.length === 0) {
200+
const message = formatSweepMessage(
201+
0,
202+
0,
203+
mode,
204+
[],
205+
new Map(),
206+
workingDirectory,
207+
skippedProtected,
208+
)
209+
await sendIgnoredMessage(client, sessionId, message, params, logger)
210+
logger.info("Sweep command: no new tools to sweep", { skippedProtected })
211+
return
212+
}
213+
214+
// Add to prune list
215+
state.prune.toolIds.push(...newToolIds)
216+
217+
// Calculate tokens saved
218+
const tokensSaved = calculateTokensSaved(state, messages, newToolIds)
219+
state.stats.pruneTokenCounter += tokensSaved
220+
state.stats.totalPruneTokens += state.stats.pruneTokenCounter
221+
state.stats.pruneTokenCounter = 0
222+
223+
// Collect metadata for logging
224+
const toolMetadata: Map<string, ToolParameterEntry> = new Map()
225+
for (const id of newToolIds) {
226+
const entry = state.toolParameters.get(id)
227+
if (entry) {
228+
toolMetadata.set(id, entry)
229+
}
230+
}
231+
232+
// Persist state
233+
saveSessionState(state, logger).catch((err) =>
234+
logger.error("Failed to persist state after sweep", { error: err.message }),
235+
)
236+
237+
const message = formatSweepMessage(
238+
newToolIds.length,
239+
tokensSaved,
240+
mode,
241+
newToolIds,
242+
toolMetadata,
243+
workingDirectory,
244+
skippedProtected,
245+
)
246+
await sendIgnoredMessage(client, sessionId, message, params, logger)
247+
248+
logger.info("Sweep command completed", {
249+
toolsSwept: newToolIds.length,
250+
tokensSaved,
251+
skippedProtected,
252+
mode,
253+
tools: Array.from(toolMetadata.entries()).map(([id, entry]) => ({
254+
id,
255+
tool: entry.tool,
256+
})),
257+
})
258+
}

0 commit comments

Comments
 (0)