Skip to content

Commit f821b1d

Browse files
authored
Merge pull request #517 from Opencode-DCP/protectTags
feat: add protect tags
2 parents 93e550f + f7aaeae commit f821b1d

12 files changed

Lines changed: 245 additions & 4 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ Each level overrides the previous, so project settings take priority over global
146146
"nudgeForce": "soft",
147147
// Tool names whose completed outputs are appended to the compression
148148
"protectedTools": [],
149+
// Preserve text wrapped in <protect>...</protect> when compressed
150+
"protectTags": false,
149151
// Preserve your messages during compression.
150152
// Warning: large copy-pasted prompts will never be compressed away
151153
"protectUserMessages": false,

dcp.schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,11 @@
237237
"default": [],
238238
"description": "Tool names or wildcard patterns whose completed outputs should be appended to the compression summary. Supports glob wildcards: * matches any characters, ? matches a single character (e.g., \"mcp_*\", \"my_tool_?\")"
239239
},
240+
"protectTags": {
241+
"type": "boolean",
242+
"default": false,
243+
"description": "Preserve text wrapped in <protect>...</protect> when compressed"
244+
},
240245
"protectUserMessages": {
241246
"type": "boolean",
242247
"default": false,
@@ -254,6 +259,7 @@
254259
"iterationNudgeThreshold": 15,
255260
"nudgeForce": "soft",
256261
"protectedTools": [],
262+
"protectTags": false,
257263
"protectUserMessages": false
258264
}
259265
},

lib/compress/message.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { countTokens } from "../token-utils"
44
import { MESSAGE_FORMAT_EXTENSION } from "../prompts/extensions/tool"
55
import { formatIssues, formatResult, resolveMessages, validateArgs } from "./message-utils"
66
import { finalizeSession, prepareSession, type NotificationEntry } from "./pipeline"
7-
import { appendProtectedTools } from "./protected-content"
7+
import { appendProtectedPromptInfo, appendProtectedTools } from "./protected-content"
88
import {
99
allocateBlockId,
1010
allocateRunId,
@@ -77,11 +77,19 @@ export function createCompressMessageTool(ctx: ToolContext): ReturnType<typeof t
7777
}> = []
7878

7979
for (const plan of plans) {
80+
const summaryWithPromptInfo = appendProtectedPromptInfo(
81+
plan.entry.summary,
82+
plan.selection,
83+
searchContext,
84+
ctx.state,
85+
ctx.config.compress.protectTags,
86+
)
87+
8088
const summaryWithTools = await appendProtectedTools(
8189
ctx.client,
8290
ctx.state,
8391
ctx.config.experimental.allowSubAgents,
84-
plan.entry.summary,
92+
summaryWithPromptInfo,
8593
plan.selection,
8694
searchContext,
8795
ctx.config.compress.protectedTools,

lib/compress/protected-content.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,58 @@ export function appendProtectedUserMessages(
5353
return summary + heading + body
5454
}
5555

56+
export function appendProtectedPromptInfo(
57+
summary: string,
58+
selection: SelectionResolution,
59+
searchContext: SearchContext,
60+
state: SessionState,
61+
enabled: boolean,
62+
): string {
63+
if (!enabled) return summary
64+
65+
const protectedTexts: string[] = []
66+
67+
for (const messageId of selection.messageIds) {
68+
const existingCompressionEntry = state.prune.messages.byMessageId.get(messageId)
69+
if (existingCompressionEntry && existingCompressionEntry.activeBlockIds.length > 0) {
70+
continue
71+
}
72+
73+
const message = searchContext.rawMessagesById.get(messageId)
74+
if (!message) continue
75+
76+
const parts = Array.isArray(message.parts) ? message.parts : []
77+
for (const part of parts) {
78+
if (part.type !== "text" || typeof part.text !== "string") continue
79+
80+
protectedTexts.push(...extractProtectedPromptInfo(part.text))
81+
}
82+
}
83+
84+
if (protectedTexts.length === 0) {
85+
return summary
86+
}
87+
88+
const heading =
89+
"\n\nThe following protected prompt information was included in this conversation verbatim:"
90+
const body = protectedTexts.map((text) => `\n${text}`).join("")
91+
return summary + heading + body
92+
}
93+
94+
export function extractProtectedPromptInfo(text: string): string[] {
95+
const protectedTexts: string[] = []
96+
const protectTagRegex = /<protect>([\s\S]*?)<\/protect>/gi
97+
98+
for (const match of text.matchAll(protectTagRegex)) {
99+
const protectedText = match[1]?.trim()
100+
if (protectedText) {
101+
protectedTexts.push(protectedText)
102+
}
103+
}
104+
105+
return protectedTexts
106+
}
107+
56108
export async function appendProtectedTools(
57109
client: any,
58110
state: SessionState,

lib/compress/range.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import type { ToolContext } from "./types"
33
import { countTokens } from "../token-utils"
44
import { RANGE_FORMAT_EXTENSION } from "../prompts/extensions/tool"
55
import { finalizeSession, prepareSession, type NotificationEntry } from "./pipeline"
6-
import { appendProtectedTools, appendProtectedUserMessages } from "./protected-content"
6+
import {
7+
appendProtectedPromptInfo,
8+
appendProtectedTools,
9+
appendProtectedUserMessages,
10+
} from "./protected-content"
711
import {
812
appendMissingBlockSummaries,
913
injectBlockPlaceholders,
@@ -108,11 +112,19 @@ export function createCompressRangeTool(ctx: ToolContext): ReturnType<typeof too
108112
ctx.config.compress.protectUserMessages,
109113
)
110114

115+
const summaryWithPromptInfo = appendProtectedPromptInfo(
116+
summaryWithUsers,
117+
plan.selection,
118+
searchContext,
119+
ctx.state,
120+
ctx.config.compress.protectTags,
121+
)
122+
111123
const summaryWithTools = await appendProtectedTools(
112124
ctx.client,
113125
ctx.state,
114126
ctx.config.experimental.allowSubAgents,
115-
summaryWithUsers,
127+
summaryWithPromptInfo,
116128
plan.selection,
117129
searchContext,
118130
ctx.config.compress.protectedTools,

lib/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface CompressConfig {
2525
iterationNudgeThreshold: number
2626
nudgeForce: "strong" | "soft"
2727
protectedTools: string[]
28+
protectTags: boolean
2829
protectUserMessages: boolean
2930
}
3031

@@ -123,6 +124,7 @@ export const VALID_CONFIG_KEYS = new Set([
123124
"compress.iterationNudgeThreshold",
124125
"compress.nudgeForce",
125126
"compress.protectedTools",
127+
"compress.protectTags",
126128
"compress.protectUserMessages",
127129
"strategies",
128130
"strategies.deduplication",
@@ -422,6 +424,14 @@ export function validateConfigTypes(config: Record<string, any>): ValidationErro
422424
})
423425
}
424426

427+
if (compress.protectTags !== undefined && typeof compress.protectTags !== "boolean") {
428+
errors.push({
429+
key: "compress.protectTags",
430+
expected: "boolean",
431+
actual: typeof compress.protectTags,
432+
})
433+
}
434+
425435
if (
426436
compress.protectUserMessages !== undefined &&
427437
typeof compress.protectUserMessages !== "boolean"
@@ -677,6 +687,7 @@ const defaultConfig: PluginConfig = {
677687
iterationNudgeThreshold: 15,
678688
nudgeForce: "soft",
679689
protectedTools: [...COMPRESS_DEFAULT_PROTECTED_TOOLS],
690+
protectTags: false,
680691
protectUserMessages: false,
681692
},
682693
strategies: {
@@ -842,6 +853,7 @@ function mergeCompress(
842853
iterationNudgeThreshold: override.iterationNudgeThreshold ?? base.iterationNudgeThreshold,
843854
nudgeForce: override.nudgeForce ?? base.nudgeForce,
844855
protectedTools: [...new Set([...base.protectedTools, ...(override.protectedTools ?? [])])],
856+
protectTags: override.protectTags ?? base.protectTags,
845857
protectUserMessages: override.protectUserMessages ?? base.protectUserMessages,
846858
}
847859
}

tests/compress-message.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ function buildConfig(): PluginConfig {
5050
iterationNudgeThreshold: 15,
5151
nudgeForce: "soft",
5252
protectedTools: ["task"],
53+
protectTags: false,
5354
protectUserMessages: false,
5455
},
5556
strategies: {
@@ -226,6 +227,64 @@ test("compress message mode batches individual message summaries", async () => {
226227
assert.match(blocks[1]?.summary || "", /task output body/)
227228
})
228229

230+
test("compress message mode appends protected prompt info", async () => {
231+
const sessionID = `ses_message_protect_tag_${Date.now()}`
232+
const rawMessages = buildMessages(sessionID)
233+
const assistant = rawMessages.find((message) => message.info.id === "msg-assistant-1")
234+
const part = assistant?.parts[0]
235+
if (part?.type === "text") {
236+
part.text = "I mapped the code path. <protect>Always preserve release checklist.</protect>"
237+
}
238+
239+
const state = createSessionState()
240+
const logger = new Logger(false)
241+
const config = buildConfig()
242+
config.compress.protectTags = true
243+
const tool = createCompressMessageTool({
244+
client: {
245+
session: {
246+
messages: async () => ({ data: rawMessages }),
247+
get: async () => ({ data: { parentID: null } }),
248+
},
249+
},
250+
state,
251+
logger,
252+
config,
253+
prompts: {
254+
reload() {},
255+
getRuntimePrompts() {
256+
return { compressMessage: "", compressRange: "" }
257+
},
258+
},
259+
} as any)
260+
261+
await tool.execute(
262+
{
263+
topic: "Protected note",
264+
content: [
265+
{
266+
messageId: "m0002",
267+
topic: "Code path note",
268+
summary: "Captured the assistant's code-path findings.",
269+
},
270+
],
271+
},
272+
{
273+
ask: async () => {},
274+
metadata: () => {},
275+
sessionID,
276+
messageID: "msg-compress-protect-tag",
277+
},
278+
)
279+
280+
const block = Array.from(state.prune.messages.blocksById.values())[0]
281+
assert.match(
282+
block?.summary || "",
283+
/The following protected prompt information was included in this conversation verbatim:/,
284+
)
285+
assert.match(block?.summary || "", /Always preserve release checklist\./)
286+
})
287+
229288
test("compress message mode stores call id for later duration attachment", async () => {
230289
const sessionID = `ses_message_compress_duration_${Date.now()}`
231290
const rawMessages = buildMessages(sessionID)

tests/compress-range.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ function buildConfig(): PluginConfig {
5050
iterationNudgeThreshold: 15,
5151
nudgeForce: "soft",
5252
protectedTools: [],
53+
protectTags: false,
5354
protectUserMessages: false,
5455
},
5556
strategies: {
@@ -178,6 +179,91 @@ test("compress range rebuilds subagent message refs after session state was rese
178179
assert.equal(state.prune.messages.blocksById.size, 1)
179180
})
180181

182+
test("compress range mode appends protected prompt info", async () => {
183+
const sessionID = `ses_range_protect_tag_${Date.now()}`
184+
const rawMessages: WithParts[] = [
185+
{
186+
info: {
187+
id: "msg-user-1",
188+
role: "user",
189+
sessionID,
190+
agent: "assistant",
191+
model: {
192+
providerID: "anthropic",
193+
modelID: "claude-test",
194+
},
195+
time: { created: 1 },
196+
} as WithParts["info"],
197+
parts: [
198+
textPart(
199+
"msg-user-1",
200+
sessionID,
201+
"part-user-1",
202+
"Investigate the release. <protect>Keep the npm publish token note.</protect>",
203+
),
204+
],
205+
},
206+
{
207+
info: {
208+
id: "msg-assistant-1",
209+
role: "assistant",
210+
sessionID,
211+
agent: "assistant",
212+
time: { created: 2 },
213+
} as WithParts["info"],
214+
parts: [textPart("msg-assistant-1", sessionID, "part-assistant-1", "I checked it")],
215+
},
216+
]
217+
218+
const state = createSessionState()
219+
const logger = new Logger(false)
220+
const config = buildConfig()
221+
config.compress.protectTags = true
222+
const tool = createCompressRangeTool({
223+
client: {
224+
session: {
225+
messages: async () => ({ data: rawMessages }),
226+
get: async () => ({ data: { parentID: null } }),
227+
},
228+
},
229+
state,
230+
logger,
231+
config,
232+
prompts: {
233+
reload() {},
234+
getRuntimePrompts() {
235+
return { compressRange: "", compressMessage: "" }
236+
},
237+
},
238+
} as any)
239+
240+
await tool.execute(
241+
{
242+
topic: "Protected range",
243+
content: [
244+
{
245+
startId: "m0001",
246+
endId: "m0002",
247+
summary: "Captured release investigation.",
248+
},
249+
],
250+
},
251+
{
252+
ask: async () => {},
253+
metadata: () => {},
254+
sessionID,
255+
messageID: "msg-compress-range-protect-tag",
256+
},
257+
)
258+
259+
const block = Array.from(state.prune.messages.blocksById.values())[0]
260+
assert.match(
261+
block?.summary || "",
262+
/The following protected prompt information was included in this conversation verbatim:/,
263+
)
264+
assert.match(block?.summary || "", /Keep the npm publish token note\./)
265+
})
266+
181267
test("compress range mode batches multiple ranges into one notification", async () => {
182268
const sessionID = `ses_range_compress_batch_${Date.now()}`
183269
const rawMessages = buildMessages(sessionID)

tests/compression-groups.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ function buildConfig(mode: "message" | "range"): PluginConfig {
5353
iterationNudgeThreshold: 15,
5454
nudgeForce: "soft",
5555
protectedTools: ["task"],
56+
protectTags: false,
5657
protectUserMessages: false,
5758
},
5859
strategies: {

tests/hooks-permission.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ function buildConfig(permission: "allow" | "ask" | "deny" = "allow"): PluginConf
4848
iterationNudgeThreshold: 15,
4949
nudgeForce: "soft",
5050
protectedTools: ["task"],
51+
protectTags: false,
5152
protectUserMessages: false,
5253
},
5354
strategies: {

0 commit comments

Comments
 (0)