Skip to content

Commit 3313f76

Browse files
authored
Merge pull request #370 from Opencode-DCP/dev
merge dev into master
2 parents 21eb26c + d7e3d30 commit 3313f76

28 files changed

+573
-205
lines changed

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,14 @@ DCP uses its own config file:
8989
> // Additional tools to protect from pruning via commands (e.g., /dcp sweep)
9090
> "protectedTools": [],
9191
> },
92+
> // Manual mode: disables autonomous context management,
93+
> // tools only run when explicitly triggered via /dcp commands
94+
> "manualMode": {
95+
> "enabled": false,
96+
> // When true, automatic strategies (deduplication, supersedeWrites, purgeErrors)
97+
> // still run even in manual mode
98+
> "automaticStrategies": true,
99+
> },
92100
> // Protect from pruning for <turns> message turns past tool invocation
93101
> "turnProtection": {
94102
> "enabled": false,
@@ -172,6 +180,10 @@ DCP provides a `/dcp` slash command:
172180
- `/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.
173181
- `/dcp stats` — Shows cumulative pruning statistics across all sessions.
174182
- `/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`.
183+
- `/dcp manual [on|off]` — Toggle manual mode or set explicit state. When on, the AI will not autonomously use context management tools.
184+
- `/dcp prune [focus]` — Trigger a single prune tool execution. Optional focus text directs the AI's pruning decisions.
185+
- `/dcp distill [focus]` — Trigger a single distill tool execution. Optional focus text directs what to distill.
186+
- `/dcp compress [focus]` — Trigger a single compress tool execution. Optional focus text directs what range to compress.
175187
176188
### Protected Tools
177189

dcp.schema.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,27 @@
5656
"protectedTools": []
5757
}
5858
},
59+
"manualMode": {
60+
"type": "object",
61+
"description": "Manual mode behavior for context management tools",
62+
"additionalProperties": false,
63+
"properties": {
64+
"enabled": {
65+
"type": "boolean",
66+
"default": false,
67+
"description": "Start new sessions with manual mode enabled"
68+
},
69+
"automaticStrategies": {
70+
"type": "boolean",
71+
"default": true,
72+
"description": "When manual mode is enabled, keep automatic deduplication/supersede/purge strategies running"
73+
}
74+
},
75+
"default": {
76+
"enabled": false,
77+
"automaticStrategies": true
78+
}
79+
},
5980
"turnProtection": {
6081
"type": "object",
6182
"description": "Protect recent tool outputs from being pruned",

lib/commands/context.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdo
7474
tools: 0,
7575
toolCount: 0,
7676
prunedTokens: state.stats.totalPruneTokens,
77-
prunedToolCount: state.prune.toolIds.size,
78-
prunedMessageCount: state.prune.messageIds.size,
77+
prunedToolCount: state.prune.tools.size,
78+
prunedMessageCount: state.prune.messages.size,
7979
total: 0,
8080
}
8181

@@ -129,7 +129,7 @@ function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdo
129129
foundToolIds.add(toolPart.callID)
130130
}
131131

132-
const isPruned = toolPart.callID && state.prune.toolIds.has(toolPart.callID)
132+
const isPruned = toolPart.callID && state.prune.tools.has(toolPart.callID)
133133
if (!isCompacted && !isPruned) {
134134
if (toolPart.state?.input) {
135135
const inputStr =

lib/commands/help.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,57 @@
44
*/
55

66
import type { Logger } from "../logger"
7+
import type { PluginConfig } from "../config"
78
import type { SessionState, WithParts } from "../state"
89
import { sendIgnoredMessage } from "../ui/notification"
910
import { getCurrentParams } from "../strategies/utils"
1011

1112
export interface HelpCommandContext {
1213
client: any
1314
state: SessionState
15+
config: PluginConfig
1416
logger: Logger
1517
sessionId: string
1618
messages: WithParts[]
1719
}
1820

19-
function formatHelpMessage(): string {
21+
const BASE_COMMANDS: [string, string][] = [
22+
["/dcp context", "Show token usage breakdown for current session"],
23+
["/dcp stats", "Show DCP pruning statistics"],
24+
["/dcp sweep [n]", "Prune tools since last user message, or last n tools"],
25+
["/dcp manual [on|off]", "Toggle manual mode or set explicit state"],
26+
]
27+
28+
const TOOL_COMMANDS: Record<string, [string, string]> = {
29+
prune: ["/dcp prune [focus]", "Trigger manual prune tool execution"],
30+
distill: ["/dcp distill [focus]", "Trigger manual distill tool execution"],
31+
compress: ["/dcp compress [focus]", "Trigger manual compress tool execution"],
32+
}
33+
34+
function getVisibleCommands(config: PluginConfig): [string, string][] {
35+
const commands = [...BASE_COMMANDS]
36+
for (const tool of ["prune", "distill", "compress"] as const) {
37+
if (config.tools[tool].permission !== "deny") {
38+
commands.push(TOOL_COMMANDS[tool])
39+
}
40+
}
41+
return commands
42+
}
43+
44+
function formatHelpMessage(manualMode: boolean, config: PluginConfig): string {
45+
const commands = getVisibleCommands(config)
46+
const colWidth = Math.max(...commands.map(([cmd]) => cmd.length)) + 4
2047
const lines: string[] = []
2148

22-
lines.push("╭───────────────────────────────────────────────────────────╮")
23-
lines.push("│ DCP Commands │")
24-
lines.push("╰───────────────────────────────────────────────────────────╯")
49+
lines.push("╭─────────────────────────────────────────────────────────────────────────╮")
50+
lines.push("│ DCP Commands │")
51+
lines.push("╰─────────────────────────────────────────────────────────────────────────╯")
52+
lines.push("")
53+
lines.push(` ${"Manual mode:".padEnd(colWidth)}${manualMode ? "ON" : "OFF"}`)
2554
lines.push("")
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")
55+
for (const [cmd, desc] of commands) {
56+
lines.push(` ${cmd.padEnd(colWidth)}${desc}`)
57+
}
2958
lines.push("")
3059

3160
return lines.join("\n")
@@ -34,7 +63,8 @@ function formatHelpMessage(): string {
3463
export async function handleHelpCommand(ctx: HelpCommandContext): Promise<void> {
3564
const { client, state, logger, sessionId, messages } = ctx
3665

37-
const message = formatHelpMessage()
66+
const { config } = ctx
67+
const message = formatHelpMessage(state.manualMode, config)
3868

3969
const params = getCurrentParams(state, messages, logger)
4070
await sendIgnoredMessage(client, sessionId, message, params, logger)

lib/commands/manual.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* DCP Manual mode command handler.
3+
* Handles toggling manual mode and triggering individual tool executions.
4+
*
5+
* Usage:
6+
* /dcp manual [on|off] - Toggle manual mode or set explicit state
7+
* /dcp prune [focus] - Trigger manual prune execution
8+
* /dcp distill [focus] - Trigger manual distill execution
9+
* /dcp compress [focus] - Trigger manual compress execution
10+
*/
11+
12+
import type { Logger } from "../logger"
13+
import type { SessionState, WithParts } from "../state"
14+
import type { PluginConfig } from "../config"
15+
import { sendIgnoredMessage } from "../ui/notification"
16+
import { getCurrentParams } from "../strategies/utils"
17+
import { syncToolCache } from "../state/tool-cache"
18+
import { buildToolIdList } from "../messages/utils"
19+
import { buildPrunableToolsList } from "../messages/inject"
20+
21+
const MANUAL_MODE_ON =
22+
"Manual mode is now ON. Use /dcp prune, /dcp distill, or /dcp compress to trigger context tools manually."
23+
24+
const MANUAL_MODE_OFF = "Manual mode is now OFF."
25+
26+
const NO_PRUNABLE_TOOLS = "No prunable tool outputs are currently available for manual triggering."
27+
28+
const PRUNE_TRIGGER_PROMPT = [
29+
"<prune triggered manually>",
30+
"Manual mode trigger received. You must now use the prune tool exactly once.",
31+
"Find the most significant set of prunable tool outputs to remove safely.",
32+
"Follow prune policy and avoid pruning outputs that may be needed later.",
33+
"Return after prune with a brief explanation of what you pruned and why.",
34+
].join("\n\n")
35+
36+
const DISTILL_TRIGGER_PROMPT = [
37+
"<distill triggered manually>",
38+
"Manual mode trigger received. You must now use the distill tool.",
39+
"Select the most information-dense prunable outputs and distill them into complete technical substitutes.",
40+
"Be exhaustive and preserve all critical technical details.",
41+
"Return after distill with a brief explanation of what was distilled and why.",
42+
].join("\n\n")
43+
44+
const COMPRESS_TRIGGER_PROMPT = [
45+
"<compress triggered manually>",
46+
"Manual mode trigger received. You must now use the compress tool.",
47+
"Find the most significant completed section of the conversation that can be compressed into a high-fidelity technical summary.",
48+
"Choose safe boundaries and preserve all critical implementation details.",
49+
"Return after compress with a brief explanation of what range was compressed.",
50+
].join("\n\n")
51+
52+
function getTriggerPrompt(
53+
tool: "prune" | "distill" | "compress",
54+
context?: string,
55+
userFocus?: string,
56+
): string {
57+
const base =
58+
tool === "prune"
59+
? PRUNE_TRIGGER_PROMPT
60+
: tool === "distill"
61+
? DISTILL_TRIGGER_PROMPT
62+
: COMPRESS_TRIGGER_PROMPT
63+
64+
const sections = [base]
65+
if (userFocus && userFocus.trim().length > 0) {
66+
sections.push(`Additional user focus:\n${userFocus.trim()}`)
67+
}
68+
if (context) {
69+
sections.push(context)
70+
}
71+
72+
return sections.join("\n\n")
73+
}
74+
75+
export interface ManualCommandContext {
76+
client: any
77+
state: SessionState
78+
config: PluginConfig
79+
logger: Logger
80+
sessionId: string
81+
messages: WithParts[]
82+
}
83+
84+
export async function handleManualToggleCommand(
85+
ctx: ManualCommandContext,
86+
modeArg?: string,
87+
): Promise<void> {
88+
const { client, state, logger, sessionId, messages } = ctx
89+
90+
if (modeArg === "on") {
91+
state.manualMode = true
92+
} else if (modeArg === "off") {
93+
state.manualMode = false
94+
} else {
95+
state.manualMode = !state.manualMode
96+
}
97+
98+
const params = getCurrentParams(state, messages, logger)
99+
await sendIgnoredMessage(
100+
client,
101+
sessionId,
102+
state.manualMode ? MANUAL_MODE_ON : MANUAL_MODE_OFF,
103+
params,
104+
logger,
105+
)
106+
107+
logger.info("Manual mode toggled", { manualMode: state.manualMode })
108+
}
109+
110+
export async function handleManualTriggerCommand(
111+
ctx: ManualCommandContext,
112+
tool: "prune" | "distill" | "compress",
113+
userFocus?: string,
114+
): Promise<string | null> {
115+
const { client, state, config, logger, sessionId, messages } = ctx
116+
117+
if (tool === "prune" || tool === "distill") {
118+
syncToolCache(state, config, logger, messages)
119+
buildToolIdList(state, messages, logger)
120+
const prunableToolsList = buildPrunableToolsList(state, config, logger)
121+
if (!prunableToolsList) {
122+
const params = getCurrentParams(state, messages, logger)
123+
await sendIgnoredMessage(client, sessionId, NO_PRUNABLE_TOOLS, params, logger)
124+
return null
125+
}
126+
127+
return getTriggerPrompt(tool, prunableToolsList, userFocus)
128+
}
129+
130+
return getTriggerPrompt("compress", undefined, userFocus)
131+
}

lib/commands/stats.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ export async function handleStatsCommand(ctx: StatsCommandContext): Promise<void
5151

5252
// Session stats from in-memory state
5353
const sessionTokens = state.stats.totalPruneTokens
54-
const sessionTools = state.prune.toolIds.size
55-
const sessionMessages = state.prune.messageIds.size
54+
const sessionTools = state.prune.tools.size
55+
const sessionMessages = state.prune.messages.size
5656

5757
// All-time stats from storage files
5858
const allTime = await loadAllSessionStats(logger)

lib/commands/sweep.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@ import type { SessionState, WithParts, ToolParameterEntry } from "../state"
1212
import type { PluginConfig } from "../config"
1313
import { sendIgnoredMessage } from "../ui/notification"
1414
import { formatPrunedItemsList } from "../ui/utils"
15-
import { getCurrentParams, calculateTokensSaved } from "../strategies/utils"
15+
import { getCurrentParams, getTotalToolTokens } from "../strategies/utils"
1616
import { buildToolIdList, isIgnoredUserMessage } from "../messages/utils"
1717
import { saveSessionState } from "../state/persistence"
1818
import { isMessageCompacted } from "../shared-utils"
1919
import { getFilePathsFromParameters, isProtected } from "../protected-file-patterns"
20+
import { syncToolCache } from "../state/tool-cache"
2021

2122
export interface SweepCommandContext {
2223
client: any
@@ -126,6 +127,9 @@ export async function handleSweepCommand(ctx: SweepCommandContext): Promise<void
126127
const params = getCurrentParams(state, messages, logger)
127128
const protectedTools = config.commands.protectedTools
128129

130+
syncToolCache(state, config, logger, messages)
131+
buildToolIdList(state, messages, logger)
132+
129133
// Parse optional numeric argument
130134
const numArg = args[0] ? parseInt(args[0], 10) : null
131135
const isLastNMode = numArg !== null && !isNaN(numArg) && numArg > 0
@@ -136,9 +140,8 @@ export async function handleSweepCommand(ctx: SweepCommandContext): Promise<void
136140
if (isLastNMode) {
137141
// Mode: Sweep last N tools
138142
mode = "last-n"
139-
const allToolIds = buildToolIdList(state, messages, logger)
140-
const startIndex = Math.max(0, allToolIds.length - numArg!)
141-
toolIdsToSweep = allToolIds.slice(startIndex)
143+
const startIndex = Math.max(0, state.toolIdList.length - numArg!)
144+
toolIdsToSweep = state.toolIdList.slice(startIndex)
142145
logger.info(`Sweep command: last ${numArg} mode, found ${toolIdsToSweep.length} tools`)
143146
} else {
144147
// Mode: Sweep since last user message
@@ -161,7 +164,7 @@ export async function handleSweepCommand(ctx: SweepCommandContext): Promise<void
161164

162165
// Filter out already-pruned tools, protected tools, and protected file paths
163166
const newToolIds = toolIdsToSweep.filter((id) => {
164-
if (state.prune.toolIds.has(id)) {
167+
if (state.prune.tools.has(id)) {
165168
return false
166169
}
167170
const entry = state.toolParameters.get(id)
@@ -211,13 +214,13 @@ export async function handleSweepCommand(ctx: SweepCommandContext): Promise<void
211214
return
212215
}
213216

217+
const tokensSaved = getTotalToolTokens(state, newToolIds)
218+
214219
// Add to prune list
215220
for (const id of newToolIds) {
216-
state.prune.toolIds.add(id)
221+
const entry = state.toolParameters.get(id)
222+
state.prune.tools.set(id, entry?.tokenCount ?? 0)
217223
}
218-
219-
// Calculate tokens saved
220-
const tokensSaved = calculateTokensSaved(state, messages, newToolIds)
221224
state.stats.pruneTokenCounter += tokensSaved
222225
state.stats.totalPruneTokens += state.stats.pruneTokenCounter
223226
state.stats.pruneTokenCounter = 0

0 commit comments

Comments
 (0)