Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
c426c33
fix: update user message ID assertions to match full tag format
Tarquinen Mar 29, 2026
2e48d0c
fix: shorten compression notification metrics
Tarquinen Mar 29, 2026
42a6cb5
fix: narrow compression notification bar
Tarquinen Mar 29, 2026
d93b7bc
refactor: split shared utility helpers
Tarquinen Mar 29, 2026
6feec64
fix: count compacted tool outputs correctly
Tarquinen Mar 29, 2026
c6921eb
docs: add global plugin install command
Tarquinen Mar 29, 2026
1bef1c3
docs: update installation instructions
Tarquinen Mar 30, 2026
074184e
docs: update installation instructions again
Tarquinen Mar 30, 2026
68a094b
update(prompts): refine compress and nudge prompt wording
Tarquinen Mar 30, 2026
2c28051
refactor(prompts): reorganize extensions and add protected tools exte…
Tarquinen Mar 30, 2026
c3c9bfa
fix: improve compress message skip handling
Tarquinen Mar 31, 2026
44115bc
style: format notification indentation
Tarquinen Mar 31, 2026
774bf96
inject message IDs into every tool part instead of only the last
Tarquinen Mar 31, 2026
0745a4b
update(prompts): describe message ID tags on every tool part
Tarquinen Mar 31, 2026
b5d608f
feat: add compression time to `/dcp stats`
spoons-and-mirrors Mar 30, 2026
b55b57c
trim
spoons-and-mirrors Mar 31, 2026
5182362
feat: add compression summary tokens amount to `dcp stats`
spoons-and-mirrors Mar 31, 2026
43dbee0
rm test noise
spoons-and-mirrors Mar 31, 2026
37ed1dd
stats changes
spoons-and-mirrors Mar 31, 2026
0072c7d
fix: track compression durations across sessions
Tarquinen Mar 31, 2026
caf6009
refactor: move compression duration updates into compress state
Tarquinen Mar 31, 2026
4671b5b
refactor: drop redundant timing flush in checkSession
Tarquinen Mar 31, 2026
8334dec
refactor: inline compression timing state
Tarquinen Mar 31, 2026
d9a43c2
refactor: centralize compression timing logic
Tarquinen Mar 31, 2026
97c3d0b
refactor: simplify compression timing state
Tarquinen Mar 31, 2026
ecde964
refactor: inline timing map writes
Tarquinen Mar 31, 2026
57dc7d6
refactor: inline timing state init
Tarquinen Mar 31, 2026
15c5d20
refactor: move event hook last
Tarquinen Mar 31, 2026
961edb5
Merge pull request #471 from Opencode-DCP/feat/compression-time
Tarquinen Mar 31, 2026
e7db0fa
fix: key compression timing by message id
Tarquinen Mar 31, 2026
a122abd
fix: tighten compression prompt rules
Tarquinen Apr 1, 2026
7cf3694
docs: readme install wording
Tarquinen Apr 1, 2026
00b65c5
fix: harden runtime imports for web plugin loading
Tarquinen Apr 1, 2026
47b4c04
chore: verify package runtime compatibility
Tarquinen Apr 1, 2026
499a59a
Merge branch 'master' into dev
Tarquinen Apr 1, 2026
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
13 changes: 4 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,13 @@ Automatically reduces token usage in OpenCode by managing conversation context.

## Installation

Add to your OpenCode config:
Install from the CLI:

```jsonc
// opencode.jsonc
{
"plugin": ["@tarquinen/opencode-dcp@latest"],
}
```bash
opencode plugin @tarquinen/opencode-dcp@latest --global
```

Using `@latest` ensures you always get the newest version automatically when OpenCode starts.

Restart OpenCode. The plugin will automatically start optimizing your sessions.
This installs the package and adds it to your global OpenCode config.

## How It Works

Expand Down
3 changes: 2 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
createChatMessageHandler,
createChatMessageTransformHandler,
createCommandExecuteHandler,
createEventHandler,
createSystemPromptHandler,
createTextCompleteHandler,
} from "./lib/hooks"
Expand Down Expand Up @@ -57,7 +58,6 @@ const plugin: Plugin = (async (ctx) => {
config,
prompts,
),

"experimental.chat.messages.transform": createChatMessageTransformHandler(
ctx.client,
state,
Expand All @@ -76,6 +76,7 @@ const plugin: Plugin = (async (ctx) => {
ctx.directory,
hostPermissions,
),
event: createEventHandler(state, logger),
tool: {
...(config.compress.permission !== "deny" && {
compress:
Expand Down
2 changes: 2 additions & 0 deletions lib/commands/compression-targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface CompressionTarget {
runId: number
topic: string
compressedTokens: number
durationMs: number
grouped: boolean
blocks: CompressionBlock[]
}
Expand All @@ -26,6 +27,7 @@ function buildTarget(blocks: CompressionBlock[]): CompressionTarget {
runId: first.runId,
topic: grouped ? first.batchTopic || first.topic : first.topic,
compressedTokens: ordered.reduce((total, block) => total + block.compressedTokens, 0),
durationMs: ordered.reduce((total, block) => Math.max(total, block.durationMs), 0),
grouped,
blocks: ordered,
}
Expand Down
13 changes: 5 additions & 8 deletions lib/commands/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ import type { Logger } from "../logger"
import type { SessionState, WithParts } from "../state"
import { sendIgnoredMessage } from "../ui/notification"
import { formatTokenCount } from "../ui/utils"
import { isMessageCompacted } from "../shared-utils"
import { isIgnoredUserMessage } from "../messages/utils"
import { countTokens, getCurrentParams } from "../strategies/utils"
import { isIgnoredUserMessage } from "../messages/query"
import { isMessageCompacted } from "../state/utils"
import { countTokens, extractCompletedToolOutput, getCurrentParams } from "../token-utils"
import type { AssistantMessage, TextPart, ToolPart } from "@opencode-ai/sdk/v2"

export interface ContextCommandContext {
Expand Down Expand Up @@ -159,11 +159,8 @@ function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdo
toolInputParts.push(inputStr)
}

if (toolPart.state?.status === "completed" && toolPart.state?.output) {
const outputStr =
typeof toolPart.state.output === "string"
? toolPart.state.output
: JSON.stringify(toolPart.state.output)
const outputStr = extractCompletedToolOutput(toolPart)
if (outputStr !== undefined) {
toolOutputParts.push(outputStr)
}
}
Expand Down
2 changes: 1 addition & 1 deletion lib/commands/decompress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Logger } from "../logger"
import type { CompressionBlock, PruneMessagesState, SessionState, WithParts } from "../state"
import { syncCompressionBlocks } from "../messages"
import { parseBlockRef } from "../message-ids"
import { getCurrentParams } from "../strategies/utils"
import { getCurrentParams } from "../token-utils"
import { saveSessionState } from "../state/persistence"
import { sendIgnoredMessage } from "../ui/notification"
import { formatTokenCount } from "../ui/utils"
Expand Down
4 changes: 2 additions & 2 deletions lib/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
import type { Logger } from "../logger"
import type { PluginConfig } from "../config"
import type { SessionState, WithParts } from "../state"
import { compressPermission } from "../shared-utils"
import { compressPermission } from "../compress-permission"
import { sendIgnoredMessage } from "../ui/notification"
import { getCurrentParams } from "../strategies/utils"
import { getCurrentParams } from "../token-utils"

export interface HelpCommandContext {
client: any
Expand Down
6 changes: 3 additions & 3 deletions lib/commands/manual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import type { Logger } from "../logger"
import type { SessionState, WithParts } from "../state"
import type { PluginConfig } from "../config"
import { sendIgnoredMessage } from "../ui/notification"
import { getCurrentParams } from "../strategies/utils"
import { buildCompressedBlockGuidance } from "../messages/inject/utils"
import { isIgnoredUserMessage } from "../messages/utils"
import { getCurrentParams } from "../token-utils"
import { buildCompressedBlockGuidance } from "../prompts/extensions/nudge"
import { isIgnoredUserMessage } from "../messages/query"

const MANUAL_MODE_ON = "Manual mode is now ON. Use /dcp compress to trigger context tools manually."

Expand Down
2 changes: 1 addition & 1 deletion lib/commands/recompress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Logger } from "../logger"
import type { PruneMessagesState, SessionState, WithParts } from "../state"
import { syncCompressionBlocks } from "../messages"
import { parseBlockRef } from "../message-ids"
import { getCurrentParams } from "../strategies/utils"
import { getCurrentParams } from "../token-utils"
import { saveSessionState } from "../state/persistence"
import { sendIgnoredMessage } from "../ui/notification"
import { formatTokenCount } from "../ui/utils"
Expand Down
72 changes: 66 additions & 6 deletions lib/commands/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import type { SessionState, WithParts } from "../state"
import { sendIgnoredMessage } from "../ui/notification"
import { formatTokenCount } from "../ui/utils"
import { loadAllSessionStats, type AggregatedStats } from "../state/persistence"
import { getCurrentParams } from "../strategies/utils"
import { getCurrentParams } from "../token-utils"
import { getActiveCompressionTargets } from "./compression-targets"

export interface StatsCommandContext {
client: any
Expand All @@ -20,8 +21,10 @@ export interface StatsCommandContext {

function formatStatsMessage(
sessionTokens: number,
sessionSummaryTokens: number,
sessionTools: number,
sessionMessages: number,
sessionDurationMs: number,
allTime: AggregatedStats,
): string {
const lines: string[] = []
Expand All @@ -30,11 +33,15 @@ function formatStatsMessage(
lines.push("│ DCP Statistics │")
lines.push("╰───────────────────────────────────────────────────────────╯")
lines.push("")
lines.push("Session:")
lines.push("Compression:")
lines.push("─".repeat(60))
lines.push(` Tokens pruned: ~${formatTokenCount(sessionTokens)}`)
lines.push(` Tools pruned: ${sessionTools}`)
lines.push(` Messages pruned: ${sessionMessages}`)
lines.push(
` Tokens in|out: ~${formatTokenCount(sessionTokens)} | ~${formatTokenCount(sessionSummaryTokens)}`,
)
lines.push(` Ratio: ${formatCompressionRatio(sessionTokens, sessionSummaryTokens)}`)
lines.push(` Time: ${formatCompressionTime(sessionDurationMs)}`)
lines.push(` Messages: ${sessionMessages}`)
lines.push(` Tools: ${sessionTools}`)
lines.push("")
lines.push("All-time:")
lines.push("─".repeat(60))
Expand All @@ -46,11 +53,55 @@ function formatStatsMessage(
return lines.join("\n")
}

function formatCompressionRatio(inputTokens: number, outputTokens: number): string {
if (inputTokens <= 0) {
return "0:1"
}

if (outputTokens <= 0) {
return "∞:1"
}

const ratio = Math.max(1, Math.round(inputTokens / outputTokens))
return `${ratio}:1`
}

function formatCompressionTime(ms: number): string {
const safeMs = Math.max(0, Math.round(ms))
if (safeMs < 1000) {
return `${safeMs} ms`
}

const totalSeconds = safeMs / 1000
if (totalSeconds < 60) {
return `${totalSeconds.toFixed(1)} s`
}

const wholeSeconds = Math.floor(totalSeconds)
const hours = Math.floor(wholeSeconds / 3600)
const minutes = Math.floor((wholeSeconds % 3600) / 60)
const seconds = wholeSeconds % 60

if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`
}

return `${minutes}m ${seconds}s`
}

export async function handleStatsCommand(ctx: StatsCommandContext): Promise<void> {
const { client, state, logger, sessionId, messages } = ctx

// Session stats from in-memory state
const sessionTokens = state.stats.totalPruneTokens
const sessionSummaryTokens = Array.from(state.prune.messages.blocksById.values()).reduce(
(total, block) => (block.active ? total + block.summaryTokens : total),
0,
)
const sessionDurationMs = getActiveCompressionTargets(state.prune.messages).reduce(
(total, target) => total + target.durationMs,
0,
)

const prunedToolIds = new Set<string>(state.prune.tools.keys())
for (const block of state.prune.messages.blocksById.values()) {
Expand All @@ -72,15 +123,24 @@ export async function handleStatsCommand(ctx: StatsCommandContext): Promise<void
// All-time stats from storage files
const allTime = await loadAllSessionStats(logger)

const message = formatStatsMessage(sessionTokens, sessionTools, sessionMessages, allTime)
const message = formatStatsMessage(
sessionTokens,
sessionSummaryTokens,
sessionTools,
sessionMessages,
sessionDurationMs,
allTime,
)

const params = getCurrentParams(state, messages, logger)
await sendIgnoredMessage(client, sessionId, message, params, logger)

logger.info("Stats command executed", {
sessionTokens,
sessionSummaryTokens,
sessionTools,
sessionMessages,
sessionDurationMs,
allTimeTokens: allTime.totalTokens,
allTimeTools: allTime.totalTools,
allTimeMessages: allTime.totalMessages,
Expand Down
7 changes: 4 additions & 3 deletions lib/commands/sweep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import type { SessionState, WithParts, ToolParameterEntry } from "../state"
import type { PluginConfig } from "../config"
import { sendIgnoredMessage } from "../ui/notification"
import { formatPrunedItemsList } from "../ui/utils"
import { getCurrentParams, getTotalToolTokens } from "../strategies/utils"
import { buildToolIdList, isIgnoredUserMessage } from "../messages/utils"
import { getCurrentParams, getTotalToolTokens } from "../token-utils"
import { isIgnoredUserMessage } from "../messages/query"
import { buildToolIdList } from "../messages/utils"
import { saveSessionState } from "../state/persistence"
import { isMessageCompacted } from "../shared-utils"
import { isMessageCompacted } from "../state/utils"
import {
getFilePathsFromParameters,
isFilePathProtected,
Expand Down
25 changes: 25 additions & 0 deletions lib/compress-permission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { PluginConfig } from "./config"
import { type HostPermissionSnapshot, resolveEffectiveCompressPermission } from "./host-permissions"
import type { SessionState, WithParts } from "./state"
import { getLastUserMessage } from "./messages/query"

export const compressPermission = (
state: SessionState,
config: PluginConfig,
): "ask" | "allow" | "deny" => {
return state.compressPermission ?? config.compress.permission
}

export const syncCompressPermissionState = (
state: SessionState,
config: PluginConfig,
hostPermissions: HostPermissionSnapshot,
messages: WithParts[],
): void => {
const activeAgent = getLastUserMessage(messages)?.info.agent
state.compressPermission = resolveEffectiveCompressPermission(
config.compress.permission,
hostPermissions,
activeAgent,
)
}
Loading
Loading