Skip to content

Commit d7e3d30

Browse files
authored
Merge pull request #371 from Opencode-DCP/feat/manual-mode-controls
feat: add manual mode controls for context tools
2 parents d7a4ad2 + 7d7ee95 commit d7e3d30

File tree

18 files changed

+433
-53
lines changed

18 files changed

+433
-53
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/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/config.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ export interface Commands {
4343
protectedTools: string[]
4444
}
4545

46+
export interface ManualModeConfig {
47+
enabled: boolean
48+
automaticStrategies: boolean
49+
}
50+
4651
export interface SupersedeWrites {
4752
enabled: boolean
4853
}
@@ -64,6 +69,7 @@ export interface PluginConfig {
6469
pruneNotification: "off" | "minimal" | "detailed"
6570
pruneNotificationType: "chat" | "toast"
6671
commands: Commands
72+
manualMode: ManualModeConfig
6773
turnProtection: TurnProtection
6874
protectedFilePatterns: string[]
6975
tools: Tools
@@ -102,6 +108,9 @@ export const VALID_CONFIG_KEYS = new Set([
102108
"commands",
103109
"commands.enabled",
104110
"commands.protectedTools",
111+
"manualMode",
112+
"manualMode.enabled",
113+
"manualMode.automaticStrategies",
105114
"tools",
106115
"tools.settings",
107116
"tools.settings.nudgeEnabled",
@@ -263,6 +272,36 @@ export function validateConfigTypes(config: Record<string, any>): ValidationErro
263272
}
264273
}
265274

275+
// Manual mode validator
276+
const manualMode = config.manualMode
277+
if (manualMode !== undefined) {
278+
if (typeof manualMode === "object") {
279+
if (manualMode.enabled !== undefined && typeof manualMode.enabled !== "boolean") {
280+
errors.push({
281+
key: "manualMode.enabled",
282+
expected: "boolean",
283+
actual: typeof manualMode.enabled,
284+
})
285+
}
286+
if (
287+
manualMode.automaticStrategies !== undefined &&
288+
typeof manualMode.automaticStrategies !== "boolean"
289+
) {
290+
errors.push({
291+
key: "manualMode.automaticStrategies",
292+
expected: "boolean",
293+
actual: typeof manualMode.automaticStrategies,
294+
})
295+
}
296+
} else {
297+
errors.push({
298+
key: "manualMode",
299+
expected: "{ enabled: boolean, automaticStrategies: boolean }",
300+
actual: typeof manualMode,
301+
})
302+
}
303+
}
304+
266305
// Tools validators
267306
const tools = config.tools
268307
if (tools) {
@@ -529,6 +568,10 @@ const defaultConfig: PluginConfig = {
529568
enabled: true,
530569
protectedTools: [...DEFAULT_PROTECTED_TOOLS],
531570
},
571+
manualMode: {
572+
enabled: false,
573+
automaticStrategies: true,
574+
},
532575
turnProtection: {
533576
enabled: false,
534577
turns: 4,
@@ -747,13 +790,29 @@ function mergeCommands(
747790
}
748791
}
749792

793+
function mergeManualMode(
794+
base: PluginConfig["manualMode"],
795+
override?: Partial<PluginConfig["manualMode"]>,
796+
): PluginConfig["manualMode"] {
797+
if (override === undefined) return base
798+
799+
return {
800+
enabled: override.enabled ?? base.enabled,
801+
automaticStrategies: override.automaticStrategies ?? base.automaticStrategies,
802+
}
803+
}
804+
750805
function deepCloneConfig(config: PluginConfig): PluginConfig {
751806
return {
752807
...config,
753808
commands: {
754809
enabled: config.commands.enabled,
755810
protectedTools: [...config.commands.protectedTools],
756811
},
812+
manualMode: {
813+
enabled: config.manualMode.enabled,
814+
automaticStrategies: config.manualMode.automaticStrategies,
815+
},
757816
turnProtection: { ...config.turnProtection },
758817
protectedFilePatterns: [...config.protectedFilePatterns],
759818
tools: {
@@ -812,6 +871,7 @@ export function getConfig(ctx: PluginInput): PluginConfig {
812871
pruneNotificationType:
813872
result.data.pruneNotificationType ?? config.pruneNotificationType,
814873
commands: mergeCommands(config.commands, result.data.commands as any),
874+
manualMode: mergeManualMode(config.manualMode, result.data.manualMode as any),
815875
turnProtection: {
816876
enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled,
817877
turns: result.data.turnProtection?.turns ?? config.turnProtection.turns,
@@ -857,6 +917,7 @@ export function getConfig(ctx: PluginInput): PluginConfig {
857917
pruneNotificationType:
858918
result.data.pruneNotificationType ?? config.pruneNotificationType,
859919
commands: mergeCommands(config.commands, result.data.commands as any),
920+
manualMode: mergeManualMode(config.manualMode, result.data.manualMode as any),
860921
turnProtection: {
861922
enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled,
862923
turns: result.data.turnProtection?.turns ?? config.turnProtection.turns,
@@ -899,6 +960,7 @@ export function getConfig(ctx: PluginInput): PluginConfig {
899960
pruneNotificationType:
900961
result.data.pruneNotificationType ?? config.pruneNotificationType,
901962
commands: mergeCommands(config.commands, result.data.commands as any),
963+
manualMode: mergeManualMode(config.manualMode, result.data.manualMode as any),
902964
turnProtection: {
903965
enabled: result.data.turnProtection?.enabled ?? config.turnProtection.enabled,
904966
turns: result.data.turnProtection?.turns ?? config.turnProtection.turns,

0 commit comments

Comments
 (0)