Skip to content

Commit 6986af0

Browse files
authored
Merge pull request #518 from Opencode-DCP/dev
merge dev into main
2 parents dab7d46 + 7cca5f3 commit 6986af0

14 files changed

Lines changed: 326 additions & 8 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: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,60 @@ 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+
if (message.info.role !== "user") continue
76+
if (isIgnoredUserMessage(message)) continue
77+
78+
const parts = Array.isArray(message.parts) ? message.parts : []
79+
for (const part of parts) {
80+
if (part.type !== "text" || typeof part.text !== "string") continue
81+
82+
protectedTexts.push(...extractProtectedPromptInfo(part.text))
83+
}
84+
}
85+
86+
if (protectedTexts.length === 0) {
87+
return summary
88+
}
89+
90+
const heading =
91+
"\n\nThe following protected prompt information was included in this conversation verbatim:"
92+
const body = protectedTexts.map((text) => `\n${text}`).join("")
93+
return summary + heading + body
94+
}
95+
96+
export function extractProtectedPromptInfo(text: string): string[] {
97+
const protectedTexts: string[] = []
98+
const protectTagRegex = /<protect>([\s\S]*?)<\/protect>/gi
99+
100+
for (const match of text.matchAll(protectTagRegex)) {
101+
const protectedText = match[1]?.trim()
102+
if (protectedText) {
103+
protectedTexts.push(protectedText)
104+
}
105+
}
106+
107+
return protectedTexts
108+
}
109+
56110
export async function appendProtectedTools(
57111
client: any,
58112
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
}

lib/update.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,26 @@ export async function updateRemoveDir(packageDir: string, name: string) {
8888

8989
const wrapperDir = dirname(nodeModulesDir)
9090
const wrapperPkg = await readPackageJson(join(wrapperDir, "package.json"))
91-
const spec = wrapperPkg?.dependencies?.[name]
91+
const spec = wrapperSpec(wrapperDir, name) ?? wrapperPkg?.dependencies?.[name]
9292
if (!spec || !isAutoUpdatableSpec(spec)) return undefined
9393

9494
return wrapperDir
9595
}
9696

97+
function wrapperSpec(wrapperDir: string, name: string) {
98+
if (name.startsWith("@")) {
99+
const [scope, pkg] = name.split("/")
100+
if (!scope || !pkg || basename(dirname(wrapperDir)) !== scope) return undefined
101+
const prefix = `${pkg}@`
102+
const base = basename(wrapperDir)
103+
return base.startsWith(prefix) ? base.slice(prefix.length) : undefined
104+
}
105+
106+
const prefix = `${name}@`
107+
const base = basename(wrapperDir)
108+
return base.startsWith(prefix) ? base.slice(prefix.length) : undefined
109+
}
110+
97111
export function isAutoUpdatableSpec(spec: string) {
98112
const value = spec.trim()
99113
if (!value) return false

tests/compress-message.test.ts

Lines changed: 118 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,123 @@ 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 user = rawMessages.find((message) => message.info.id === "msg-user-1")
234+
const part = user?.parts[0]
235+
if (part?.type === "text") {
236+
part.text = "Investigate the issue. <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: "m0001",
267+
topic: "User request note",
268+
summary: "Captured the user's investigation request.",
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+
288+
test("compress message mode ignores protect tags on ignored user messages", async () => {
289+
const sessionID = `ses_message_ignored_protect_tag_${Date.now()}`
290+
const rawMessages = buildMessages(sessionID)
291+
const user = rawMessages.find((message) => message.info.id === "msg-user-1")
292+
const part = user?.parts[0] as any
293+
if (part?.type === "text") {
294+
part.text = "Ignored notification. <protect>Do not preserve ignored note.</protect>"
295+
part.ignored = true
296+
}
297+
298+
const state = createSessionState()
299+
const logger = new Logger(false)
300+
const config = buildConfig()
301+
config.compress.protectTags = true
302+
const tool = createCompressMessageTool({
303+
client: {
304+
session: {
305+
messages: async () => ({ data: rawMessages }),
306+
get: async () => ({ data: { parentID: null } }),
307+
},
308+
},
309+
state,
310+
logger,
311+
config,
312+
prompts: {
313+
reload() {},
314+
getRuntimePrompts() {
315+
return { compressMessage: "", compressRange: "" }
316+
},
317+
},
318+
} as any)
319+
320+
await tool.execute(
321+
{
322+
topic: "Ignored protected note",
323+
content: [
324+
{
325+
messageId: "m0001",
326+
topic: "Ignored note",
327+
summary: "Captured the ignored user message.",
328+
},
329+
],
330+
},
331+
{
332+
ask: async () => {},
333+
metadata: () => {},
334+
sessionID,
335+
messageID: "msg-compress-ignored-protect-tag",
336+
},
337+
)
338+
339+
const block = Array.from(state.prune.messages.blocksById.values())[0]
340+
assert.doesNotMatch(
341+
block?.summary || "",
342+
/The following protected prompt information was included in this conversation verbatim:/,
343+
)
344+
assert.doesNotMatch(block?.summary || "", /Do not preserve ignored note\./)
345+
})
346+
229347
test("compress message mode stores call id for later duration attachment", async () => {
230348
const sessionID = `ses_message_compress_duration_${Date.now()}`
231349
const rawMessages = buildMessages(sessionID)

0 commit comments

Comments
 (0)