Skip to content

Commit 867a988

Browse files
authored
Merge pull request #428 from Opencode-DCP/dev
merge dev into master
2 parents 670fab7 + 1768536 commit 867a988

24 files changed

Lines changed: 303 additions & 172 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
Automatically reduces token usage in OpenCode by managing conversation context.
77

8-
![DCP in action](assets/images/dcp-demo5.png)
8+
![DCP in action](assets/images/dcp-demo9.png)
99

1010
## Installation
1111

assets/images/dcp-demo6.png

76.6 KB
Loading

assets/images/dcp-demo7.png

66.3 KB
Loading

assets/images/dcp-demo8.png

64.9 KB
Loading

assets/images/dcp-demo9.png

87.4 KB
Loading

index.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import type { Plugin } from "@opencode-ai/plugin"
22
import { getConfig } from "./lib/config"
3+
import {
4+
compressDisabledByOpencode,
5+
hasExplicitToolPermission,
6+
type HostPermissionSnapshot,
7+
} from "./lib/host-permissions"
38
import { Logger } from "./lib/logger"
49
import { createSessionState } from "./lib/state"
510
import { createCompressTool } from "./lib/tools"
@@ -22,6 +27,10 @@ const plugin: Plugin = (async (ctx) => {
2227
const logger = new Logger(config.debug)
2328
const state = createSessionState()
2429
const prompts = new PromptStore(logger, ctx.directory, config.experimental.customPrompts)
30+
const hostPermissions: HostPermissionSnapshot = {
31+
global: undefined,
32+
agents: {},
33+
}
2534

2635
if (isSecureMode()) {
2736
configureClientAuth(ctx.client)
@@ -46,6 +55,7 @@ const plugin: Plugin = (async (ctx) => {
4655
logger,
4756
config,
4857
prompts,
58+
hostPermissions,
4959
) as any,
5060
"chat.message": async (
5161
input: {
@@ -57,8 +67,6 @@ const plugin: Plugin = (async (ctx) => {
5767
},
5868
_output: any,
5969
) => {
60-
// Cache variant from real user messages (not synthetic)
61-
// This avoids scanning all messages to find variant
6270
state.variant = input.variant
6371
logger.debug("Cached variant from chat.message hook", { variant: input.variant })
6472
},
@@ -69,6 +77,7 @@ const plugin: Plugin = (async (ctx) => {
6977
logger,
7078
config,
7179
ctx.directory,
80+
hostPermissions,
7281
),
7382
tool: {
7483
...(config.compress.permission !== "deny" && {
@@ -91,6 +100,13 @@ const plugin: Plugin = (async (ctx) => {
91100
}
92101
}
93102

103+
if (
104+
config.compress.permission !== "deny" &&
105+
compressDisabledByOpencode(opencodeConfig.permission)
106+
) {
107+
config.compress.permission = "deny"
108+
}
109+
94110
const toolsToAdd: string[] = []
95111
if (config.compress.permission !== "deny" && !config.experimental.allowSubAgents) {
96112
toolsToAdd.push("compress")
@@ -104,12 +120,21 @@ const plugin: Plugin = (async (ctx) => {
104120
}
105121
}
106122

107-
// Set tool permissions from DCP config
108-
const permission = opencodeConfig.permission ?? {}
109-
opencodeConfig.permission = {
110-
...permission,
111-
compress: config.compress.permission,
112-
} as typeof permission
123+
if (!hasExplicitToolPermission(opencodeConfig.permission, "compress")) {
124+
const permission = opencodeConfig.permission ?? {}
125+
opencodeConfig.permission = {
126+
...permission,
127+
compress: config.compress.permission,
128+
} as typeof permission
129+
}
130+
131+
hostPermissions.global = opencodeConfig.permission
132+
hostPermissions.agents = Object.fromEntries(
133+
Object.entries(opencodeConfig.agent ?? {}).map(([name, agent]) => [
134+
name,
135+
agent?.permission,
136+
]),
137+
)
113138
},
114139
}
115140
}) satisfies Plugin

lib/commands/help.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import type { Logger } from "../logger"
77
import type { PluginConfig } from "../config"
88
import type { SessionState, WithParts } from "../state"
9+
import { compressPermission } from "../shared-utils"
910
import { sendIgnoredMessage } from "../ui/notification"
1011
import { getCurrentParams } from "../strategies/utils"
1112

@@ -31,10 +32,10 @@ const TOOL_COMMANDS: Record<string, [string, string]> = {
3132
recompress: ["/dcp recompress <n>", "Re-apply a user-decompressed compression"],
3233
}
3334

34-
function getVisibleCommands(config: PluginConfig): [string, string][] {
35+
function getVisibleCommands(state: SessionState, config: PluginConfig): [string, string][] {
3536
const commands = [...BASE_COMMANDS]
3637

37-
if (config.compress.permission !== "deny") {
38+
if (compressPermission(state, config) !== "deny") {
3839
commands.push(TOOL_COMMANDS.compress)
3940
commands.push(TOOL_COMMANDS.decompress)
4041
commands.push(TOOL_COMMANDS.recompress)
@@ -43,16 +44,16 @@ function getVisibleCommands(config: PluginConfig): [string, string][] {
4344
return commands
4445
}
4546

46-
function formatHelpMessage(manualMode: boolean, config: PluginConfig): string {
47-
const commands = getVisibleCommands(config)
47+
function formatHelpMessage(state: SessionState, config: PluginConfig): string {
48+
const commands = getVisibleCommands(state, config)
4849
const colWidth = Math.max(...commands.map(([cmd]) => cmd.length)) + 4
4950
const lines: string[] = []
5051

5152
lines.push("╭─────────────────────────────────────────────────────────────────────────╮")
5253
lines.push("│ DCP Commands │")
5354
lines.push("╰─────────────────────────────────────────────────────────────────────────╯")
5455
lines.push("")
55-
lines.push(` ${"Manual mode:".padEnd(colWidth)}${manualMode ? "ON" : "OFF"}`)
56+
lines.push(` ${"Manual mode:".padEnd(colWidth)}${state.manualMode ? "ON" : "OFF"}`)
5657
lines.push("")
5758
for (const [cmd, desc] of commands) {
5859
lines.push(` ${cmd.padEnd(colWidth)}${desc}`)
@@ -66,7 +67,7 @@ export async function handleHelpCommand(ctx: HelpCommandContext): Promise<void>
6667
const { client, state, logger, sessionId, messages } = ctx
6768

6869
const { config } = ctx
69-
const message = formatHelpMessage(!!state.manualMode, config)
70+
const message = formatHelpMessage(state, config)
7071

7172
const params = getCurrentParams(state, messages, logger)
7273
await sendIgnoredMessage(client, sessionId, message, params, logger)

lib/hooks.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import { handleSweepCommand } from "./commands/sweep"
2121
import { handleManualToggleCommand, handleManualTriggerCommand } from "./commands/manual"
2222
import { handleDecompressCommand } from "./commands/decompress"
2323
import { handleRecompressCommand } from "./commands/recompress"
24+
import { type HostPermissionSnapshot } from "./host-permissions"
25+
import { compressPermission, syncCompressPermissionState } from "./shared-utils"
2426
import { ensureSessionInitialized } from "./state/state"
2527
import { cacheSystemPromptTokens } from "./ui/utils"
2628
import type { PromptStore } from "./prompts/store"
@@ -32,9 +34,7 @@ const INTERNAL_AGENT_SIGNATURES = [
3234
]
3335

3436
const DCP_MESSAGE_ID_TAG_REGEX = /<dcp-message-id>(?:m\d+|b\d+)<\/dcp-message-id>/g
35-
const TURN_NUDGE_BLOCK_REGEX = /<instruction\s+name=turn_nudge\b[^>]*>[\s\S]*?<\/instruction>/g
36-
const ITERATION_NUDGE_BLOCK_REGEX =
37-
/<instruction\s+name=iteration_nudge\b[^>]*>[\s\S]*?<\/instruction>/g
37+
const DCP_SYSTEM_REMINDER_REGEX = /<dcp-system-reminder\b[^>]*>[\s\S]*?<\/dcp-system-reminder>/g
3838

3939
function applyManualPrompt(state: SessionState, messages: WithParts[], logger: Logger): void {
4040
const pending = state.pendingManualTrigger
@@ -93,7 +93,12 @@ export function createSystemPromptHandler(
9393
return
9494
}
9595

96-
if (config.compress.permission === "deny") {
96+
const effectivePermission =
97+
input.sessionID && state.sessionId === input.sessionID
98+
? compressPermission(state, config)
99+
: config.compress.permission
100+
101+
if (effectivePermission === "deny") {
97102
return
98103
}
99104

@@ -118,10 +123,13 @@ export function createChatMessageTransformHandler(
118123
logger: Logger,
119124
config: PluginConfig,
120125
prompts: PromptStore,
126+
hostPermissions: HostPermissionSnapshot,
121127
) {
122128
return async (input: {}, output: { messages: WithParts[] }) => {
123129
await checkSession(client, state, logger, output.messages, config.manualMode.enabled)
124130

131+
syncCompressPermissionState(state, config, hostPermissions, output.messages)
132+
125133
if (state.isSubAgent && !config.experimental.allowSubAgents) {
126134
return
127135
}
@@ -158,6 +166,7 @@ export function createCommandExecuteHandler(
158166
logger: Logger,
159167
config: PluginConfig,
160168
workingDirectory: string,
169+
hostPermissions: HostPermissionSnapshot,
161170
) {
162171
return async (
163172
input: { command: string; sessionID: string; arguments: string },
@@ -182,6 +191,10 @@ export function createCommandExecuteHandler(
182191
config.manualMode.enabled,
183192
)
184193

194+
syncCompressPermissionState(state, config, hostPermissions, messages)
195+
196+
const effectivePermission = compressPermission(state, config)
197+
185198
const args = (input.arguments || "").trim().split(/\s+/).filter(Boolean)
186199
const subcommand = args[0]?.toLowerCase() || ""
187200
const subArgs = args.slice(1)
@@ -219,7 +232,7 @@ export function createCommandExecuteHandler(
219232
throw new Error("__DCP_MANUAL_HANDLED__")
220233
}
221234

222-
if (subcommand === "compress" && config.compress.permission !== "deny") {
235+
if (subcommand === "compress" && effectivePermission !== "deny") {
223236
const userFocus = subArgs.join(" ").trim()
224237
const prompt = await handleManualTriggerCommand(commandCtx, "compress", userFocus)
225238
if (!prompt) {
@@ -240,15 +253,15 @@ export function createCommandExecuteHandler(
240253
return
241254
}
242255

243-
if (subcommand === "decompress" && config.compress.permission !== "deny") {
256+
if (subcommand === "decompress" && effectivePermission !== "deny") {
244257
await handleDecompressCommand({
245258
...commandCtx,
246259
args: subArgs,
247260
})
248261
throw new Error("__DCP_DECOMPRESS_HANDLED__")
249262
}
250263

251-
if (subcommand === "recompress" && config.compress.permission !== "deny") {
264+
if (subcommand === "recompress" && effectivePermission !== "deny") {
252265
await handleRecompressCommand({
253266
...commandCtx,
254267
args: subArgs,
@@ -268,8 +281,7 @@ export function createTextCompleteHandler() {
268281
output: { text: string },
269282
) => {
270283
output.text = output.text
271-
.replace(TURN_NUDGE_BLOCK_REGEX, "")
272-
.replace(ITERATION_NUDGE_BLOCK_REGEX, "")
284+
.replace(DCP_SYSTEM_REMINDER_REGEX, "")
273285
.replace(DCP_MESSAGE_ID_TAG_REGEX, "")
274286
}
275287
}

lib/host-permissions.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
export type PermissionAction = "ask" | "allow" | "deny"
2+
3+
export type PermissionValue = PermissionAction | Record<string, PermissionAction>
4+
5+
export type PermissionConfig = Record<string, PermissionValue> | undefined
6+
7+
export interface HostPermissionSnapshot {
8+
global: PermissionConfig
9+
agents: Record<string, PermissionConfig>
10+
}
11+
12+
type PermissionRule = {
13+
permission: string
14+
pattern: string
15+
action: PermissionAction
16+
}
17+
18+
const wildcardMatch = (value: string, pattern: string): boolean => {
19+
const normalizedValue = value.replaceAll("\\", "/")
20+
let escaped = pattern
21+
.replaceAll("\\", "/")
22+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
23+
.replace(/\*/g, ".*")
24+
.replace(/\?/g, ".")
25+
26+
if (escaped.endsWith(" .*")) {
27+
escaped = escaped.slice(0, -3) + "( .*)?"
28+
}
29+
30+
const flags = process.platform === "win32" ? "si" : "s"
31+
return new RegExp(`^${escaped}$`, flags).test(normalizedValue)
32+
}
33+
34+
const getPermissionRules = (permissionConfigs: PermissionConfig[]): PermissionRule[] => {
35+
const rules: PermissionRule[] = []
36+
for (const permissionConfig of permissionConfigs) {
37+
if (!permissionConfig) {
38+
continue
39+
}
40+
41+
for (const [permission, value] of Object.entries(permissionConfig)) {
42+
if (value === "ask" || value === "allow" || value === "deny") {
43+
rules.push({ permission, pattern: "*", action: value })
44+
continue
45+
}
46+
47+
for (const [pattern, action] of Object.entries(value)) {
48+
if (action === "ask" || action === "allow" || action === "deny") {
49+
rules.push({ permission, pattern, action })
50+
}
51+
}
52+
}
53+
}
54+
return rules
55+
}
56+
57+
export const compressDisabledByOpencode = (...permissionConfigs: PermissionConfig[]): boolean => {
58+
const match = getPermissionRules(permissionConfigs).findLast((rule) =>
59+
wildcardMatch("compress", rule.permission),
60+
)
61+
62+
return match?.pattern === "*" && match.action === "deny"
63+
}
64+
65+
export const resolveEffectiveCompressPermission = (
66+
basePermission: PermissionAction,
67+
hostPermissions: HostPermissionSnapshot,
68+
agentName?: string,
69+
): PermissionAction => {
70+
if (basePermission === "deny") {
71+
return "deny"
72+
}
73+
74+
return compressDisabledByOpencode(
75+
hostPermissions.global,
76+
agentName ? hostPermissions.agents[agentName] : undefined,
77+
)
78+
? "deny"
79+
: basePermission
80+
}
81+
82+
export const hasExplicitToolPermission = (
83+
permissionConfig: PermissionConfig,
84+
tool: string,
85+
): boolean => {
86+
return permissionConfig ? Object.hasOwn(permissionConfig, tool) : false
87+
}

lib/messages/inject/inject.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { Logger } from "../../logger"
33
import type { PluginConfig } from "../../config"
44
import type { RuntimePrompts } from "../../prompts/store"
55
import { formatMessageIdTag } from "../../message-ids"
6-
import { getLastUserMessage } from "../../shared-utils"
6+
import { compressPermission, getLastUserMessage } from "../../shared-utils"
77
import { saveSessionState } from "../../state/persistence"
88
import {
99
appendIdToTool,
@@ -30,7 +30,7 @@ export const injectCompressNudges = (
3030
messages: WithParts[],
3131
prompts: RuntimePrompts,
3232
): void => {
33-
if (config.compress.permission === "deny") {
33+
if (compressPermission(state, config) === "deny") {
3434
return
3535
}
3636

@@ -139,7 +139,7 @@ export const injectMessageIds = (
139139
config: PluginConfig,
140140
messages: WithParts[],
141141
): void => {
142-
if (config.compress.permission === "deny") {
142+
if (compressPermission(state, config) === "deny") {
143143
return
144144
}
145145

0 commit comments

Comments
 (0)