diff --git a/README.md b/README.md index 0d2f0f7a..627418a9 100644 --- a/README.md +++ b/README.md @@ -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%". @@ -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) diff --git a/dcp.schema.json b/dcp.schema.json index 9c175312..a927b870 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -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" @@ -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" diff --git a/lib/config.ts b/lib/config.ts index 8b289e29..c0c96547 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -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", diff --git a/lib/prompts/store.ts b/lib/prompts/store.ts index 5216dade..d088f07b 100644 --- a/lib/prompts/store.ts +++ b/lib/prompts/store.ts @@ -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() @@ -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*]*>/i.test(normalized) + const endsWrapped = /<\/dcp-system-reminder>\s*$/i.test(normalized) + + if (startsWrapped !== endsWrapped) { return "" } diff --git a/lib/tools/utils.ts b/lib/tools/utils.ts index e7992136..a3c24013 100644 --- a/lib/tools/utils.ts +++ b/lib/tools/utils.ts @@ -409,67 +409,40 @@ export function validateSummaryPlaceholders( endReference: BoundaryReference, summaryByBlockId: Map, ): number[] { - const issues: string[] = [] - const boundaryOptionalIds = new Set() 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() - const duplicateIds = new Set() - - 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() + 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( diff --git a/tests/compress-placeholders.test.ts b/tests/compress-placeholders.test.ts new file mode 100644 index 00000000..e7b1c27a --- /dev/null +++ b/tests/compress-placeholders.test.ts @@ -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]) +}) diff --git a/tests/compress.test.ts b/tests/compress.test.ts index 17706db7..988603e2 100644 --- a/tests/compress.test.ts +++ b/tests/compress.test.ts @@ -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", diff --git a/tests/prompts.test.ts b/tests/prompts.test.ts new file mode 100644 index 00000000..9a3974c5 --- /dev/null +++ b/tests/prompts.test.ts @@ -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( + `\nWrapped override body\n\n`, + ) + + try { + const runtimeSystemPrompt = fixture.store.getRuntimePrompts().system + const openingTags = runtimeSystemPrompt.match(/]*>/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( + `\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() + } + }) +})