From 7de671d1ea0805aaf1de9275a428925f9a8b1df0 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 25 Mar 2026 19:05:53 -0400 Subject: [PATCH 1/6] chore(deps): align opencode sdk/plugin with dev --- package-lock.json | 42 +++++++++++++++++++++--------------------- package.json | 8 ++++---- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4b06e41c..19d6b525 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,17 +10,17 @@ "license": "AGPL-3.0-or-later", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", - "@opencode-ai/sdk": "^1.1.48", + "@opencode-ai/sdk": "^1.3.2", "fuzzball": "^2.2.3", "jsonc-parser": "^3.3.1", "zod": "^4.3.6" }, "devDependencies": { - "@opencode-ai/plugin": "^1.1.49", - "@types/node": "^25.1.0", + "@opencode-ai/plugin": "^1.3.2", + "@types/node": "^25.5.0", "prettier": "^3.8.1", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^6.0.2" }, "peerDependencies": { "@opencode-ai/plugin": ">=0.13.7" @@ -494,13 +494,13 @@ } }, "node_modules/@opencode-ai/plugin": { - "version": "1.1.49", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.1.49.tgz", - "integrity": "sha512-+FEE730fLJtoHCta5MXixOIzI9Cjos700QDNnAx6mA8YjFzO+kABnyqLQrCgZ9wUPJgiKH9bnHxT7AdRjWsNPw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.2.tgz", + "integrity": "sha512-eT0ZovMCOQlfTdAnfbEWgW343mJ9SHgEVfdiOSX1NMIVXac6hxE2xwUsRVTV3wLvfA6dKZhN800f8wLUEyPlyg==", "dev": true, "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.1.49", + "@opencode-ai/sdk": "1.3.2", "zod": "4.1.8" } }, @@ -515,19 +515,19 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.1.49", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.49.tgz", - "integrity": "sha512-F5ZkgiqOiV+z3U4zeBLvrmNZv5MwNFMTWM+HWhChD+/UEswIebQKk9UMz9lPX4fswexIJdFPwFI/TBdNyZfKMg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.2.tgz", + "integrity": "sha512-u7sXVKn0kyAA5vVVHuHQfq3+3UGWOU1Sh6d/e+aS4zO8AwriTSWNQ9r8Qy5yxBH+PoeOGl5WIVdp+s2Ea2zuAg==", "license": "MIT" }, "node_modules/@types/node": { - "version": "25.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.1.0.tgz", - "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==", + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/esbuild": { @@ -688,9 +688,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -702,9 +702,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 98700e62..4b28d0fe 100644 --- a/package.json +++ b/package.json @@ -41,17 +41,17 @@ }, "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", - "@opencode-ai/sdk": "^1.1.48", + "@opencode-ai/sdk": "^1.3.2", "fuzzball": "^2.2.3", "jsonc-parser": "^3.3.1", "zod": "^4.3.6" }, "devDependencies": { - "@opencode-ai/plugin": "^1.1.49", - "@types/node": "^25.1.0", + "@opencode-ai/plugin": "^1.3.2", + "@types/node": "^25.5.0", "prettier": "^3.8.1", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^6.0.2" }, "files": [ "dist/", From b9b4cdf2ac816057f2f7002769082534f25f6ff6 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 25 Mar 2026 20:53:56 -0400 Subject: [PATCH 2/6] refactor: extract getActiveSummaryTokenUsage and add compact option to formatTokenCount --- lib/messages/inject/utils.ts | 16 +--------------- lib/state/utils.ts | 12 ++++++++++++ lib/ui/utils.ts | 7 ++++--- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/lib/messages/inject/utils.ts b/lib/messages/inject/utils.ts index 43f62c3a..a19618e9 100644 --- a/lib/messages/inject/utils.ts +++ b/lib/messages/inject/utils.ts @@ -11,6 +11,7 @@ import { import { appendToLastTextPart, createSyntheticTextPart, isIgnoredUserMessage } from "../utils" import { getLastUserMessage } from "../../shared-utils" import { getCurrentTokenUsage } from "../../strategies/utils" +import { getActiveSummaryTokenUsage } from "../../state/utils" const MESSAGE_MODE_NUDGE_PRIORITY: MessagePriority = "high" @@ -119,21 +120,6 @@ function resolveContextTokenLimit( return parseLimitValue(globalLimit) } -function getActiveSummaryTokenUsage(state: SessionState): number { - let total = 0 - - for (const blockId of state.prune.messages.activeBlockIds) { - const block = state.prune.messages.blocksById.get(blockId) - if (!block || !block.active) { - continue - } - - total += block.summaryTokens - } - - return total -} - export function isContextOverLimits( config: PluginConfig, state: SessionState, diff --git a/lib/state/utils.ts b/lib/state/utils.ts index bda142a6..08cd2096 100644 --- a/lib/state/utils.ts +++ b/lib/state/utils.ts @@ -271,6 +271,18 @@ export function collectTurnNudgeAnchors(messages: WithParts[]): Set { return anchors } +export function getActiveSummaryTokenUsage(state: SessionState): number { + let total = 0 + for (const blockId of state.prune.messages.activeBlockIds) { + const block = state.prune.messages.blocksById.get(blockId) + if (!block || !block.active) { + continue + } + total += block.summaryTokens + } + return total +} + export function resetOnCompaction(state: SessionState): void { state.toolParameters.clear() state.prune.tools = new Map() diff --git a/lib/ui/utils.ts b/lib/ui/utils.ts index 7f525d4b..a4f238f9 100644 --- a/lib/ui/utils.ts +++ b/lib/ui/utils.ts @@ -142,11 +142,12 @@ export function formatStatsHeader(totalTokensSaved: number, pruneTokenCounter: n return [`▣ DCP | ${totalTokensSavedStr} saved total`].join("\n") } -export function formatTokenCount(tokens: number): string { +export function formatTokenCount(tokens: number, compact?: boolean): string { + const suffix = compact ? "" : " tokens" if (tokens >= 1000) { - return `${(tokens / 1000).toFixed(1)}K`.replace(".0K", "K") + " tokens" + return `${(tokens / 1000).toFixed(1)}K`.replace(".0K", "K") + suffix } - return tokens.toString() + " tokens" + return tokens.toString() + suffix } export function truncate(str: string, maxLen: number = 60): string { From 69d488fc9c171242226904fe1450d85667a0d9ca Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 25 Mar 2026 20:54:01 -0400 Subject: [PATCH 3/6] add net savings and compression ratio to compress notifications --- lib/ui/notification.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index e6909c64..e0b81105 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -8,6 +8,7 @@ import { } from "./utils" import { ToolParameterEntry } from "../state" import { PluginConfig } from "../config" +import { getActiveSummaryTokenUsage } from "../state/utils" export type PruneReason = "completion" | "noise" | "extraction" export const PRUNE_REASON_LABELS: Record = { @@ -233,11 +234,18 @@ export async function sendCompressNotification( "(unknown topic)") : "(unknown topic)") + const totalActiveSummaryTkns = getActiveSummaryTokenUsage(state) + const totalGross = state.stats.totalPruneTokens + state.stats.pruneTokenCounter + const totalNet = Math.max(0, totalGross - totalActiveSummaryTkns) + const netSavedHeader = + totalActiveSummaryTkns > 0 + ? `▣ DCP | ~${formatTokenCount(totalNet, true)} net saved (~${formatTokenCount(totalActiveSummaryTkns, true)} in active summaries)` + : `▣ DCP | ~${formatTokenCount(totalNet, true)} net saved total` + if (config.pruneNotification === "minimal") { - message = formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter) - message += ` — ${compressionLabel}` + message = `${netSavedHeader} — ${compressionLabel}` } else { - message = formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter) + message = netSavedHeader const pruneTokenCounterStr = `~${formatTokenCount(compressedTokens)}` @@ -255,8 +263,15 @@ export async function sendCompressNotification( const reduction = totalSessionTokens > 0 ? Math.round((compressedTokens / totalSessionTokens) * 100) : 0 + const netSavings = Math.max(0, compressedTokens - summaryTokens) + const compressionPct = + compressedTokens > 0 + ? Math.max(0, Math.round((1 - summaryTokens / compressedTokens) * 100)) + : 0 + message += `\n\n${progressBar}` message += `\n▣ ${compressionLabel} (${pruneTokenCounterStr} removed, ${reduction}% reduction)` + message += `\n→ Net: ~${formatTokenCount(netSavings, true)} saved (~${formatTokenCount(summaryTokens, true)} summary, ${compressionPct}% compression)` message += `\n→ Topic: ${topic}` message += `\n→ Items: ${newlyCompressedMessageIds.length} messages` if (newlyCompressedToolIds.length > 0) { From b77a41bd58d08307d3798b4a620544e632061ded Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 25 Mar 2026 21:18:28 -0400 Subject: [PATCH 4/6] fix: strip orphan dcp tags from hallucination stripping Hallucinated opening tags without closing pairs (e.g. ) were not matched by the paired-only regex. Add a second pass for unpaired tags and expand test coverage. --- lib/messages/utils.ts | 5 +++-- tests/message-priority.test.ts | 38 ++++++++++++++++++++++++++++++---- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index 10c82eb0..0cadd0fe 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -6,7 +6,8 @@ import type { UserMessage } from "@opencode-ai/sdk/v2" const SUMMARY_ID_HASH_LENGTH = 16 const DCP_BLOCK_ID_TAG_REGEX = /(])[^>]*>)b\d+(<\/dcp-message-id>)/g -const DCP_ANY_TAG_REGEX = /]*>[\s\S]*?<\/dcp[^>]*>/gi +const DCP_PAIRED_TAG_REGEX = /]*>[\s\S]*?<\/dcp[^>]*>/gi +const DCP_UNPAIRED_TAG_REGEX = /<\/?dcp[^>]*>/gi const generateStableId = (prefix: string, seed: string): string => { const hash = createHash("sha256").update(seed).digest("hex").slice(0, SUMMARY_ID_HASH_LENGTH) @@ -171,7 +172,7 @@ export const replaceBlockIdsWithBlocked = (text: string): string => { } export const stripHallucinationsFromString = (text: string): string => { - return text.replace(DCP_ANY_TAG_REGEX, "") + return text.replace(DCP_PAIRED_TAG_REGEX, "").replace(DCP_UNPAIRED_TAG_REGEX, "") } export const stripHallucinations = (messages: WithParts[]): void => { diff --git a/tests/message-priority.test.ts b/tests/message-priority.test.ts index e02f36a8..bd8f405e 100644 --- a/tests/message-priority.test.ts +++ b/tests/message-priority.test.ts @@ -629,14 +629,44 @@ test("hallucination stripping removes all dcp-prefixed XML tags including varian }) test("hallucination stripping removes colon and underscore dcp tag variants", async () => { + assert.equal(stripHallucinationsFromString("beforeafter"), "beforeafter") + assert.equal(stripHallucinationsFromString("startend"), "startend") +}) + +test("hallucination stripping removes orphan opening tags", async () => { + assert.equal( + stripHallucinationsFromString("narration\n\n\n\n"), + "narration\n\n\n\n", + ) + assert.equal(stripHallucinationsFromString('text more'), "text more") +}) + +test("hallucination stripping removes orphan closing tags", async () => { + assert.equal(stripHallucinationsFromString("text more"), "text more") + assert.equal(stripHallucinationsFromString("beforeafter"), "beforeafter") +}) + +test("hallucination stripping handles nested dcp tags", async () => { assert.equal( - stripHallucinationsFromString("beforem0074after"), - "beforeafter", + stripHallucinationsFromString( + 'before\ncontent\nafter', + ), + "before\nafter", ) +}) + +test("hallucination stripping handles mixed paired and orphan tags", async () => { assert.equal( stripHallucinationsFromString( - 'startend', + 'text\nm0045\n\n', ), - "startend", + "text\n\n\n", + ) +}) + +test("hallucination stripping does not affect non-dcp tags", async () => { + assert.equal( + stripHallucinationsFromString("
hello
keep"), + "
hello
keep", ) }) From cb13dadb5bafae5afa73f0a359f0b8d87578f98e Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 25 Mar 2026 21:46:27 -0400 Subject: [PATCH 5/6] refine compress notifications Use consistent removed-versus-summary-token wording, drop the extra net/compression detail row, widen the progress bar, and remove the unused totalSessionTokens plumbing. --- lib/compress/pipeline.ts | 2 -- lib/ui/notification.ts | 25 +++++++------------------ 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/lib/compress/pipeline.ts b/lib/compress/pipeline.ts index 4e7a5fc6..366d17bb 100644 --- a/lib/compress/pipeline.ts +++ b/lib/compress/pipeline.ts @@ -86,7 +86,6 @@ export async function finalizeSession( await saveSessionState(ctx.state, ctx.logger) const params = getCurrentParams(ctx.state, rawMessages, ctx.logger) - const totalSessionTokens = getCurrentTokenUsage(ctx.state, rawMessages) const sessionMessageIds = rawMessages .filter((msg) => !isIgnoredUserMessage(msg)) .map((msg) => msg.info.id) @@ -99,7 +98,6 @@ export async function finalizeSession( toolCtx.sessionID, entries, batchTopic, - totalSessionTokens, sessionMessageIds, params, ) diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index e0b81105..9980c551 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -169,7 +169,6 @@ export async function sendCompressNotification( sessionId: string, entries: CompressionNotificationEntry[], batchTopic: string | undefined, - totalSessionTokens: number, sessionMessageIds: string[], params: any, ): Promise { @@ -236,16 +235,15 @@ export async function sendCompressNotification( const totalActiveSummaryTkns = getActiveSummaryTokenUsage(state) const totalGross = state.stats.totalPruneTokens + state.stats.pruneTokenCounter - const totalNet = Math.max(0, totalGross - totalActiveSummaryTkns) - const netSavedHeader = + const notificationHeader = totalActiveSummaryTkns > 0 - ? `▣ DCP | ~${formatTokenCount(totalNet, true)} net saved (~${formatTokenCount(totalActiveSummaryTkns, true)} in active summaries)` - : `▣ DCP | ~${formatTokenCount(totalNet, true)} net saved total` + ? `▣ DCP | ~${formatTokenCount(totalGross, true)} tokens removed (~${formatTokenCount(totalActiveSummaryTkns, true)} summary tokens added)` + : `▣ DCP | ~${formatTokenCount(totalGross, true)} tokens removed` if (config.pruneNotification === "minimal") { - message = `${netSavedHeader} — ${compressionLabel}` + message = `${notificationHeader} — ${compressionLabel}` } else { - message = netSavedHeader + message = notificationHeader const pruneTokenCounterStr = `~${formatTokenCount(compressedTokens)}` @@ -259,19 +257,10 @@ export async function sendCompressNotification( sessionMessageIds, activePrunedMessages, newlyCompressedMessageIds, + 70, ) - const reduction = - totalSessionTokens > 0 ? Math.round((compressedTokens / totalSessionTokens) * 100) : 0 - - const netSavings = Math.max(0, compressedTokens - summaryTokens) - const compressionPct = - compressedTokens > 0 - ? Math.max(0, Math.round((1 - summaryTokens / compressedTokens) * 100)) - : 0 - message += `\n\n${progressBar}` - message += `\n▣ ${compressionLabel} (${pruneTokenCounterStr} removed, ${reduction}% reduction)` - message += `\n→ Net: ~${formatTokenCount(netSavings, true)} saved (~${formatTokenCount(summaryTokens, true)} summary, ${compressionPct}% compression)` + message += `\n▣ ${compressionLabel} (${pruneTokenCounterStr} removed, ~${formatTokenCount(summaryTokens, true)} summary tokens added)` message += `\n→ Topic: ${topic}` message += `\n→ Items: ${newlyCompressedMessageIds.length} messages` if (newlyCompressedToolIds.length > 0) { From e7719e1b185f1e0d29783b9672a21958190bb3da Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 25 Mar 2026 21:48:34 -0400 Subject: [PATCH 6/6] v3.1.2 - Bump version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 19d6b525..0f269765 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tarquinen/opencode-dcp", - "version": "3.1.1", + "version": "3.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tarquinen/opencode-dcp", - "version": "3.1.1", + "version": "3.1.2", "license": "AGPL-3.0-or-later", "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", diff --git a/package.json b/package.json index 4b28d0fe..ef184efa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@tarquinen/opencode-dcp", - "version": "3.1.1", + "version": "3.1.2", "type": "module", "description": "OpenCode plugin that optimizes token usage by pruning obsolete tool outputs from conversation context", "main": "./dist/index.js",