Skip to content
Merged
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
2 changes: 0 additions & 2 deletions lib/compress/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -99,7 +98,6 @@ export async function finalizeSession(
toolCtx.sessionID,
entries,
batchTopic,
totalSessionTokens,
sessionMessageIds,
params,
)
Expand Down
16 changes: 1 addition & 15 deletions lib/messages/inject/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions lib/messages/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import type { UserMessage } from "@opencode-ai/sdk/v2"

const SUMMARY_ID_HASH_LENGTH = 16
const DCP_BLOCK_ID_TAG_REGEX = /(<dcp-message-id(?=[\s>])[^>]*>)b\d+(<\/dcp-message-id>)/g
const DCP_ANY_TAG_REGEX = /<dcp[^>]*>[\s\S]*?<\/dcp[^>]*>/gi
const DCP_PAIRED_TAG_REGEX = /<dcp[^>]*>[\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)
Expand Down Expand Up @@ -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 => {
Expand Down
12 changes: 12 additions & 0 deletions lib/state/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,18 @@ export function collectTurnNudgeAnchors(messages: WithParts[]): Set<string> {
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<string, number>()
Expand Down
20 changes: 12 additions & 8 deletions lib/ui/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PruneReason, string> = {
Expand Down Expand Up @@ -168,7 +169,6 @@ export async function sendCompressNotification(
sessionId: string,
entries: CompressionNotificationEntry[],
batchTopic: string | undefined,
totalSessionTokens: number,
sessionMessageIds: string[],
params: any,
): Promise<boolean> {
Expand Down Expand Up @@ -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)}`

Expand All @@ -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) {
Expand Down
7 changes: 4 additions & 3 deletions lib/ui/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
46 changes: 23 additions & 23 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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/",
Expand Down
38 changes: 34 additions & 4 deletions tests/message-priority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<dcp:function_calls>\n\n"),
"narration\n\n\n\n",
)
assert.equal(stripHallucinationsFromString('text <dcp:invoke name="edit"> more'), "text more")
})

test("hallucination stripping removes orphan closing tags", async () => {
assert.equal(stripHallucinationsFromString("text</dcp:function_calls> more"), "text more")
assert.equal(stripHallucinationsFromString("before</dcp-message-id>after"), "beforeafter")
})

test("hallucination stripping handles nested dcp tags", async () => {
assert.equal(
stripHallucinationsFromString("before<dcp:message_id>m0074</dcp:message_id>after"),
"beforeafter",
stripHallucinationsFromString(
'before<dcp:function_calls>\n<dcp:invoke name="edit">content</dcp:invoke>\n</dcp:function_calls>after',
),
"before\nafter",
)
})

test("hallucination stripping handles mixed paired and orphan tags", async () => {
assert.equal(
stripHallucinationsFromString(
'start<dcp-function_calls><invoke name="Bash"></invoke></dcp-function_calls>end',
'text\n<dcp-message-id priority="low">m0045</dcp-message-id>\n<dcp:function_calls>\n',
),
"startend",
"text\n\n\n",
)
})

test("hallucination stripping does not affect non-dcp tags", async () => {
assert.equal(
stripHallucinationsFromString("<div>hello</div> <system-reminder>keep</system-reminder>"),
"<div>hello</div> <system-reminder>keep</system-reminder>",
)
})
Loading