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/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/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/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/notification.ts b/lib/ui/notification.ts
index e6909c64..9980c551 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 = {
@@ -168,7 +169,6 @@ export async function sendCompressNotification(
sessionId: string,
entries: CompressionNotificationEntry[],
batchTopic: string | undefined,
- totalSessionTokens: number,
sessionMessageIds: string[],
params: any,
): Promise {
@@ -233,11 +233,17 @@ export async function sendCompressNotification(
"(unknown topic)")
: "(unknown topic)")
+ const totalActiveSummaryTkns = getActiveSummaryTokenUsage(state)
+ const totalGross = state.stats.totalPruneTokens + state.stats.pruneTokenCounter
+ const notificationHeader =
+ totalActiveSummaryTkns > 0
+ ? `▣ DCP | ~${formatTokenCount(totalGross, true)} tokens removed (~${formatTokenCount(totalActiveSummaryTkns, true)} summary tokens added)`
+ : `▣ DCP | ~${formatTokenCount(totalGross, true)} tokens removed`
+
if (config.pruneNotification === "minimal") {
- message = formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter)
- message += ` — ${compressionLabel}`
+ message = `${notificationHeader} — ${compressionLabel}`
} else {
- message = formatStatsHeader(state.stats.totalPruneTokens, state.stats.pruneTokenCounter)
+ message = notificationHeader
const pruneTokenCounterStr = `~${formatTokenCount(compressedTokens)}`
@@ -251,12 +257,10 @@ export async function sendCompressNotification(
sessionMessageIds,
activePrunedMessages,
newlyCompressedMessageIds,
+ 70,
)
- const reduction =
- totalSessionTokens > 0 ? Math.round((compressedTokens / totalSessionTokens) * 100) : 0
-
message += `\n\n${progressBar}`
- message += `\n▣ ${compressionLabel} (${pruneTokenCounterStr} removed, ${reduction}% reduction)`
+ 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) {
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 {
diff --git a/package-lock.json b/package-lock.json
index 4b06e41c..0f269765 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,26 +1,26 @@
{
"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",
- "@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..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",
@@ -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/",
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",
)
})