Skip to content
Merged

Dev #443

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,11 @@ Each level overrides the previous, so project settings take priority over global
// Soft upper threshold: above this, DCP keeps injecting strong
// compression nudges (based on nudgeFrequency), so compression is
// much more likely. Accepts: number or "X%" of model context window.
"maxContextLimit": 100000,
"maxContextLimit": 150000,
// Soft lower threshold for reminder nudges: below this, turn/iteration
// reminders are off (compression less likely). At/above this, reminders
// are on. Accepts: number or "X%" of model context window.
"minContextLimit": 30000,
"minContextLimit": 50000,
// Optional per-model override for maxContextLimit by providerID/modelID.
// If present, this wins over the global maxContextLimit.
// Accepts: number or "X%".
Expand All @@ -122,7 +122,7 @@ Each level overrides the previous, so project settings take priority over global
// Optional per-model override for minContextLimit.
// If present, this wins over the global minContextLimit.
// "modelMinLimits": {
// "openai/gpt-5.3-codex": 30000,
// "openai/gpt-5.3-codex": 50000,
// "anthropic/claude-sonnet-4.6": "25%"
// },
// How often the context-limit nudge fires (1 = every fetch, 5 = every 5th)
Expand Down
4 changes: 2 additions & 2 deletions dcp.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@
},
"maxContextLimit": {
"description": "Soft upper threshold. Above this, DCP keeps sending strong compression nudges (based on nudgeFrequency), so the model is pushed to compress. Accepts number or \"X%\" of the model context window.",
"default": 100000,
"default": 150000,
"oneOf": [
{
"type": "number"
Expand All @@ -154,7 +154,7 @@
},
"minContextLimit": {
"description": "Soft lower threshold for reminder nudges. Below this, turn/iteration reminders are off (compression is less likely). At or above this, reminders are on. Accepts number or \"X%\" of the model context window.",
"default": 30000,
"default": 50000,
"oneOf": [
{
"type": "number"
Expand Down
4 changes: 2 additions & 2 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,8 +664,8 @@ const defaultConfig: PluginConfig = {
compress: {
permission: "allow",
showCompression: false,
maxContextLimit: 100000,
minContextLimit: 30000,
maxContextLimit: 150000,
minContextLimit: 50000,
nudgeFrequency: 5,
iterationNudgeThreshold: 15,
nudgeForce: "soft",
Expand Down
15 changes: 8 additions & 7 deletions lib/prompts/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,12 +183,6 @@ function stripConditionalTag(content: string, tagName: string): string {
return content.replace(regex, "")
}

function hasTagPairMismatch(content: string, tagName: string): boolean {
const openRegex = new RegExp(`<${tagName}\\b[^>]*>`, "i")
const closeRegex = new RegExp(`<\/${tagName}>`, "i")
return openRegex.test(content) !== closeRegex.test(content)
}

function unwrapDcpTagIfWrapped(content: string): string {
const trimmed = content.trim()

Expand All @@ -205,7 +199,14 @@ function unwrapDcpTagIfWrapped(content: string): string {
function normalizeReminderPromptContent(content: string): string {
const normalized = content.trim()

if (hasTagPairMismatch(normalized, "dcp-system-reminder")) {
if (!normalized) {
return ""
}

const startsWrapped = /^\s*<dcp-system-reminder\b[^>]*>/i.test(normalized)
const endsWrapped = /<\/dcp-system-reminder>\s*$/i.test(normalized)

if (startsWrapped !== endsWrapped) {
return ""
}

Expand Down
61 changes: 17 additions & 44 deletions lib/tools/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,67 +409,40 @@ export function validateSummaryPlaceholders(
endReference: BoundaryReference,
summaryByBlockId: Map<number, CompressionBlock>,
): number[] {
const issues: string[] = []

const boundaryOptionalIds = new Set<number>()
if (startReference.kind === "compressed-block") {
if (startReference.blockId === undefined) {
issues.push("Failed to map boundary matches back to raw messages")
} else {
boundaryOptionalIds.add(startReference.blockId)
throw new Error("Failed to map boundary matches back to raw messages")
}
boundaryOptionalIds.add(startReference.blockId)
}
if (endReference.kind === "compressed-block") {
if (endReference.blockId === undefined) {
issues.push("Failed to map boundary matches back to raw messages")
} else {
boundaryOptionalIds.add(endReference.blockId)
throw new Error("Failed to map boundary matches back to raw messages")
}
boundaryOptionalIds.add(endReference.blockId)
}

const strictRequiredIds = requiredBlockIds.filter((id) => !boundaryOptionalIds.has(id))
const requiredSet = new Set(requiredBlockIds)
const placeholderIds = placeholders.map((p) => p.blockId)
const placeholderSet = new Set<number>()
const duplicateIds = new Set<number>()

for (const id of placeholderIds) {
if (placeholderSet.has(id)) {
duplicateIds.add(id)
continue
}
placeholderSet.add(id)
}

const missing = strictRequiredIds.filter((id) => !placeholderSet.has(id))

const unknown = placeholderIds.filter((id) => !summaryByBlockId.has(id))
if (unknown.length > 0) {
const uniqueUnknown = [...new Set(unknown)]
issues.push(
`Unknown block placeholders: ${uniqueUnknown.map(formatBlockPlaceholder).join(", ")}`,
)
}
const keptPlaceholderIds = new Set<number>()
const validPlaceholders: ParsedBlockPlaceholder[] = []

const invalid = placeholderIds.filter((id) => !requiredSet.has(id))
if (invalid.length > 0) {
const uniqueInvalid = [...new Set(invalid)]
issues.push(
`Invalid block placeholders for selected range: ${uniqueInvalid.map(formatBlockPlaceholder).join(", ")}`,
)
}
for (const placeholder of placeholders) {
const isKnown = summaryByBlockId.has(placeholder.blockId)
const isRequired = requiredSet.has(placeholder.blockId)
const isDuplicate = keptPlaceholderIds.has(placeholder.blockId)

if (duplicateIds.size > 0) {
issues.push(
`Duplicate block placeholders are not allowed: ${[...duplicateIds].map(formatBlockPlaceholder).join(", ")}`,
)
if (isKnown && isRequired && !isDuplicate) {
validPlaceholders.push(placeholder)
keptPlaceholderIds.add(placeholder.blockId)
}
}

if (issues.length > 0) {
throwCombinedIssues(issues)
}
placeholders.length = 0
placeholders.push(...validPlaceholders)

return missing
return strictRequiredIds.filter((id) => !keptPlaceholderIds.has(id))
}

export function injectBlockPlaceholders(
Expand Down
117 changes: 117 additions & 0 deletions tests/compress-placeholders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import assert from "node:assert/strict"
import test from "node:test"
import type { CompressionBlock } from "../lib/state"
import {
appendMissingBlockSummaries,
injectBlockPlaceholders,
parseBlockPlaceholders,
validateSummaryPlaceholders,
wrapCompressedSummary,
type BoundaryReference,
} from "../lib/tools/utils"

function createBlock(blockId: number, body: string): CompressionBlock {
return {
blockId,
active: true,
deactivatedByUser: false,
compressedTokens: 0,
topic: `Block ${blockId}`,
startId: "m0001",
endId: "m0002",
anchorMessageId: `msg-${blockId}`,
compressMessageId: `compress-${blockId}`,
includedBlockIds: [],
consumedBlockIds: [],
parentBlockIds: [],
directMessageIds: [],
directToolIds: [],
effectiveMessageIds: [`msg-${blockId}`],
effectiveToolIds: [],
createdAt: blockId,
summary: wrapCompressedSummary(blockId, body),
}
}

function createMessageBoundary(messageId: string, rawIndex: number): BoundaryReference {
return {
kind: "message",
messageId,
rawIndex,
}
}

test("compress placeholder validation keeps valid placeholders and ignores invalid ones", () => {
const summaryByBlockId = new Map([
[1, createBlock(1, "First compressed summary")],
[2, createBlock(2, "Second compressed summary")],
])
const summary = "Intro (b1) unknown (b9) duplicate (b1) out-of-range (b2) outro"
const parsed = parseBlockPlaceholders(summary)

const missingBlockIds = validateSummaryPlaceholders(
parsed,
[1],
createMessageBoundary("msg-a", 0),
createMessageBoundary("msg-b", 1),
summaryByBlockId,
)

assert.deepEqual(
parsed.map((placeholder) => placeholder.blockId),
[1],
)
assert.equal(missingBlockIds.length, 0)

const injected = injectBlockPlaceholders(
summary,
parsed,
summaryByBlockId,
createMessageBoundary("msg-a", 0),
createMessageBoundary("msg-b", 1),
)

assert.match(injected.expandedSummary, /First compressed summary/)
assert.doesNotMatch(injected.expandedSummary, /Second compressed summary/)
assert.match(injected.expandedSummary, /\(b9\)/)
assert.match(injected.expandedSummary, /\(b2\)/)
assert.deepEqual(injected.consumedBlockIds, [1])
})

test("compress continues by appending required block summaries the model omitted", () => {
const summaryByBlockId = new Map([[1, createBlock(1, "Recovered compressed summary")]])
const summary = "The model forgot to include the prior block."
const parsed = parseBlockPlaceholders(summary)

const missingBlockIds = validateSummaryPlaceholders(
parsed,
[1],
createMessageBoundary("msg-a", 0),
createMessageBoundary("msg-b", 1),
summaryByBlockId,
)

assert.deepEqual(missingBlockIds, [1])

const injected = injectBlockPlaceholders(
summary,
parsed,
summaryByBlockId,
createMessageBoundary("msg-a", 0),
createMessageBoundary("msg-b", 1),
)
const finalSummary = appendMissingBlockSummaries(
injected.expandedSummary,
missingBlockIds,
summaryByBlockId,
injected.consumedBlockIds,
)

assert.match(
finalSummary.expandedSummary,
/The following previously compressed summaries were also part of this conversation section:/,
)
assert.match(finalSummary.expandedSummary, /### \(b1\)/)
assert.match(finalSummary.expandedSummary, /Recovered compressed summary/)
assert.deepEqual(finalSummary.consumedBlockIds, [1])
})
4 changes: 2 additions & 2 deletions tests/compress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ function buildConfig(): PluginConfig {
compress: {
permission: "allow",
showCompression: false,
maxContextLimit: 100000,
minContextLimit: 30000,
maxContextLimit: 150000,
minContextLimit: 50000,
nudgeFrequency: 5,
iterationNudgeThreshold: 15,
nudgeForce: "soft",
Expand Down
103 changes: 103 additions & 0 deletions tests/prompts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import assert from "node:assert/strict"
import test from "node:test"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { Logger } from "../lib/logger"
import { PromptStore } from "../lib/prompts/store"
import { SYSTEM as SYSTEM_PROMPT } from "../lib/prompts/system"

function createPromptStoreFixture(overrideContent?: string) {
const rootDir = mkdtempSync(join(tmpdir(), "opencode-dcp-prompts-"))
const configHome = join(rootDir, "config")
const workspaceDir = join(rootDir, "workspace")

mkdirSync(configHome, { recursive: true })
mkdirSync(workspaceDir, { recursive: true })

const previousConfigHome = process.env.XDG_CONFIG_HOME
const previousOpencodeConfigDir = process.env.OPENCODE_CONFIG_DIR

process.env.XDG_CONFIG_HOME = configHome
delete process.env.OPENCODE_CONFIG_DIR

if (overrideContent !== undefined) {
const overrideDir = join(configHome, "opencode", "dcp-prompts", "overrides")
mkdirSync(overrideDir, { recursive: true })
writeFileSync(join(overrideDir, "system.md"), overrideContent, "utf-8")
}

const store = new PromptStore(new Logger(false), workspaceDir, true)

return {
store,
cleanup() {
if (previousConfigHome === undefined) {
delete process.env.XDG_CONFIG_HOME
} else {
process.env.XDG_CONFIG_HOME = previousConfigHome
}

if (previousOpencodeConfigDir === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = previousOpencodeConfigDir
}

rmSync(rootDir, { recursive: true, force: true })
},
}
}

test("system prompt overrides handle reminder tags safely", async (t) => {
await t.test("plain-text mentions do not invalidate copied system prompt overrides", () => {
const fixture = createPromptStoreFixture(
`${SYSTEM_PROMPT.trim()}\n\nExtra override line.\n`,
)

try {
const runtimeSystemPrompt = fixture.store.getRuntimePrompts().system

assert.match(runtimeSystemPrompt, /Extra override line\./)
assert.match(runtimeSystemPrompt, /environment-injected metadata/)
} finally {
fixture.cleanup()
}
})

await t.test("fully wrapped overrides are normalized to a single runtime wrapper", () => {
const fixture = createPromptStoreFixture(
`<dcp-system-reminder>\nWrapped override body\n</dcp-system-reminder>\n`,
)

try {
const runtimeSystemPrompt = fixture.store.getRuntimePrompts().system
const openingTags = runtimeSystemPrompt.match(/<dcp-system-reminder\b[^>]*>/g) ?? []
const closingTags = runtimeSystemPrompt.match(/<\/dcp-system-reminder>/g) ?? []

assert.equal(openingTags.length, 1)
assert.equal(closingTags.length, 1)
assert.match(runtimeSystemPrompt, /Wrapped override body/)
} finally {
fixture.cleanup()
}
})

await t.test("malformed boundary wrappers are rejected", () => {
const baselineFixture = createPromptStoreFixture()
const malformedFixture = createPromptStoreFixture(
`<dcp-system-reminder>\nMalformed override body\n`,
)

try {
const baselineSystemPrompt = baselineFixture.store.getRuntimePrompts().system
const malformedSystemPrompt = malformedFixture.store.getRuntimePrompts().system

assert.equal(malformedSystemPrompt, baselineSystemPrompt)
assert.doesNotMatch(malformedSystemPrompt, /Malformed override body/)
} finally {
malformedFixture.cleanup()
baselineFixture.cleanup()
}
})
})
Loading