From c426c33024a59524845eae81b084a54070c66194 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sat, 28 Mar 2026 21:29:48 -0400 Subject: [PATCH 01/33] fix: update user message ID assertions to match full tag format --- tests/message-priority.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/message-priority.test.ts b/tests/message-priority.test.ts index ca4ce3a3..31932095 100644 --- a/tests/message-priority.test.ts +++ b/tests/message-priority.test.ts @@ -212,8 +212,14 @@ test("injectMessageIds injects ID once into last tool output for assistant messa assert.equal(assistantTextTwo?.type, "text") assert.equal(assistantToolTwo?.type, "tool") // User messages: still injected into all text parts - assert.match((userTextOne as any).text, /\n\nm0001<\/dcp-message-id>/) - assert.match((userTextTwo as any).text, /\n\nm0001<\/dcp-message-id>/) + assert.match( + (userTextOne as any).text, + /\n\nm0001<\/dcp-message-id>/, + ) + assert.match( + (userTextTwo as any).text, + /\n\nm0001<\/dcp-message-id>/, + ) // Assistant messages: ID injected only once into the last tool output assert.doesNotMatch((assistantTextOne as any).text, /dcp-message-id/) assert.doesNotMatch((assistantToolOne as any).state.output, /dcp-message-id/) From 2e48d0c28fad45bd7292769e51abcebdabaed925 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sat, 28 Mar 2026 23:31:13 -0400 Subject: [PATCH 02/33] fix: shorten compression notification metrics --- lib/ui/notification.ts | 17 ++++++++++------- tests/compress-message.test.ts | 2 ++ tests/compress-range.test.ts | 2 ++ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index 9980c551..a1ec2297 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -161,6 +161,14 @@ function getCompressionLabel(entries: CompressionNotificationEntry[]): string { return `Compression #${runId}` } +function formatCompressionMetrics(removedTokens: number, summaryTokens: number): string { + const metrics = [`-${formatTokenCount(removedTokens, true)} removed`] + if (summaryTokens > 0) { + metrics.push(`+${formatTokenCount(summaryTokens, true)} summary`) + } + return metrics.join(", ") +} + export async function sendCompressNotification( client: any, logger: Logger, @@ -235,18 +243,13 @@ export async function sendCompressNotification( 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` + const notificationHeader = `▣ DCP | ${formatCompressionMetrics(totalGross, totalActiveSummaryTkns)}` if (config.pruneNotification === "minimal") { message = `${notificationHeader} — ${compressionLabel}` } else { message = notificationHeader - const pruneTokenCounterStr = `~${formatTokenCount(compressedTokens)}` - const activePrunedMessages = new Map() for (const [messageId, entry] of state.prune.messages.byMessageId) { if (entry.activeBlockIds.length > 0) { @@ -260,7 +263,7 @@ export async function sendCompressNotification( 70, ) message += `\n\n${progressBar}` - message += `\n▣ ${compressionLabel} (${pruneTokenCounterStr} removed, ~${formatTokenCount(summaryTokens, true)} summary tokens added)` + message += `\n▣ ${compressionLabel} ${formatCompressionMetrics(compressedTokens, summaryTokens)}` message += `\n→ Topic: ${topic}` message += `\n→ Items: ${newlyCompressedMessageIds.length} messages` if (newlyCompressedToolIds.length > 0) { diff --git a/tests/compress-message.test.ts b/tests/compress-message.test.ts index a48a2f9b..db03ff1a 100644 --- a/tests/compress-message.test.ts +++ b/tests/compress-message.test.ts @@ -525,7 +525,9 @@ test("compress message mode sends one aggregated notification for batched messag ) assert.equal(toastCalls.length, 1) + assert.match(toastCalls[0] || "", /▣ DCP \| -[^,\n]+ removed, \+[^\s\n]+ summary/) assert.match(toastCalls[0] || "", /Compression #1/) + assert.match(toastCalls[0] || "", /▣ Compression #1 -[^,\n]+ removed, \+[^\s\n]+ summary/) assert.match(toastCalls[0] || "", /Topic: Batch stale notes/) assert.match(toastCalls[0] || "", /Items: 2 messages/) }) diff --git a/tests/compress-range.test.ts b/tests/compress-range.test.ts index 621c6622..7899189e 100644 --- a/tests/compress-range.test.ts +++ b/tests/compress-range.test.ts @@ -238,7 +238,9 @@ test("compress range mode batches multiple ranges into one notification", async assert.equal(result, "Compressed 2 messages into [Compressed conversation section].") assert.equal(state.prune.messages.blocksById.size, 2) assert.equal(toastCalls.length, 1) + assert.match(toastCalls[0] || "", /▣ DCP \| -[^,\n]+ removed, \+[^\s\n]+ summary/) assert.match(toastCalls[0] || "", /Compression #1/) + assert.match(toastCalls[0] || "", /▣ Compression #1 -[^,\n]+ removed, \+[^\s\n]+ summary/) assert.match(toastCalls[0] || "", /Topic: Batch stale notes/) assert.match(toastCalls[0] || "", /Items: 2 messages/) }) From 42a6cb5006cc0ef5eaf11a71db14bf503a6b810a Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sat, 28 Mar 2026 23:59:52 -0400 Subject: [PATCH 03/33] fix: narrow compression notification bar --- lib/ui/notification.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index a1ec2297..8c45becb 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -238,7 +238,7 @@ export async function sendCompressNotification( batchTopic ?? (entries.length === 1 ? (state.prune.messages.blocksById.get(entries[0]?.blockId ?? -1)?.topic ?? - "(unknown topic)") + "(unknown topic)") : "(unknown topic)") const totalActiveSummaryTkns = getActiveSummaryTokenUsage(state) @@ -260,7 +260,7 @@ export async function sendCompressNotification( sessionMessageIds, activePrunedMessages, newlyCompressedMessageIds, - 70, + 50, ) message += `\n\n${progressBar}` message += `\n▣ ${compressionLabel} ${formatCompressionMetrics(compressedTokens, summaryTokens)}` @@ -317,9 +317,9 @@ export async function sendIgnoredMessage( const model = params.providerId && params.modelId ? { - providerID: params.providerId, - modelID: params.modelId, - } + providerID: params.providerId, + modelID: params.modelId, + } : undefined try { From d93b7bc108158b9108eae0488071b389d21f1036 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sun, 29 Mar 2026 03:40:28 -0400 Subject: [PATCH 04/33] refactor: split shared utility helpers --- lib/commands/context.ts | 6 +- lib/commands/decompress.ts | 2 +- lib/commands/help.ts | 4 +- lib/commands/manual.ts | 4 +- lib/commands/recompress.ts | 2 +- lib/commands/stats.ts | 2 +- lib/commands/sweep.ts | 7 ++- lib/compress-permission.ts | 25 +++++++++ lib/compress/message-utils.ts | 2 +- lib/compress/message.ts | 2 +- lib/compress/pipeline.ts | 4 +- lib/compress/protected-content.ts | 2 +- lib/compress/range.ts | 2 +- lib/compress/search.ts | 4 +- lib/hooks.ts | 2 +- lib/message-ids.ts | 2 +- lib/messages/inject/inject.ts | 10 +++- lib/messages/inject/utils.ts | 5 +- lib/messages/priority.ts | 6 +- lib/messages/prune.ts | 3 +- lib/messages/query.ts | 56 +++++++++++++++++++ lib/messages/reasoning-strip.ts | 2 +- lib/messages/utils.ts | 31 +---------- lib/shared-utils.ts | 62 --------------------- lib/state/state.ts | 2 +- lib/state/tool-cache.ts | 4 +- lib/state/utils.ts | 16 +++++- lib/strategies/deduplication.ts | 2 +- lib/strategies/purge-errors.ts | 2 +- lib/{strategies/utils.ts => token-utils.ts} | 6 +- lib/ui/utils.ts | 4 +- tests/message-utils.test.ts | 2 +- tests/token-counting.test.ts | 2 +- tests/token-usage.test.ts | 2 +- 34 files changed, 147 insertions(+), 142 deletions(-) create mode 100644 lib/compress-permission.ts create mode 100644 lib/messages/query.ts delete mode 100644 lib/shared-utils.ts rename lib/{strategies/utils.ts => token-utils.ts} (97%) diff --git a/lib/commands/context.ts b/lib/commands/context.ts index cf765566..8c287a08 100644 --- a/lib/commands/context.ts +++ b/lib/commands/context.ts @@ -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, getCurrentParams } from "../token-utils" import type { AssistantMessage, TextPart, ToolPart } from "@opencode-ai/sdk/v2" export interface ContextCommandContext { diff --git a/lib/commands/decompress.ts b/lib/commands/decompress.ts index 5957632c..2f346105 100644 --- a/lib/commands/decompress.ts +++ b/lib/commands/decompress.ts @@ -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" diff --git a/lib/commands/help.ts b/lib/commands/help.ts index e8843bdb..d1682ce4 100644 --- a/lib/commands/help.ts +++ b/lib/commands/help.ts @@ -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 diff --git a/lib/commands/manual.ts b/lib/commands/manual.ts index e19a5e09..1d2100b0 100644 --- a/lib/commands/manual.ts +++ b/lib/commands/manual.ts @@ -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 { getCurrentParams } from "../token-utils" import { buildCompressedBlockGuidance } from "../messages/inject/utils" -import { isIgnoredUserMessage } from "../messages/utils" +import { isIgnoredUserMessage } from "../messages/query" const MANUAL_MODE_ON = "Manual mode is now ON. Use /dcp compress to trigger context tools manually." diff --git a/lib/commands/recompress.ts b/lib/commands/recompress.ts index eda5879b..1d67d6bd 100644 --- a/lib/commands/recompress.ts +++ b/lib/commands/recompress.ts @@ -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" diff --git a/lib/commands/stats.ts b/lib/commands/stats.ts index 21f626f5..559c3859 100644 --- a/lib/commands/stats.ts +++ b/lib/commands/stats.ts @@ -8,7 +8,7 @@ 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" export interface StatsCommandContext { client: any diff --git a/lib/commands/sweep.ts b/lib/commands/sweep.ts index 2c596250..b60b3a9b 100644 --- a/lib/commands/sweep.ts +++ b/lib/commands/sweep.ts @@ -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, diff --git a/lib/compress-permission.ts b/lib/compress-permission.ts new file mode 100644 index 00000000..b7826343 --- /dev/null +++ b/lib/compress-permission.ts @@ -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, + ) +} diff --git a/lib/compress/message-utils.ts b/lib/compress/message-utils.ts index d82901ac..fffee3e8 100644 --- a/lib/compress/message-utils.ts +++ b/lib/compress/message-utils.ts @@ -1,7 +1,7 @@ import type { PluginConfig } from "../config" import type { SessionState } from "../state" import { parseBoundaryId } from "../message-ids" -import { isIgnoredUserMessage, isProtectedUserMessage } from "../messages/utils" +import { isIgnoredUserMessage, isProtectedUserMessage } from "../messages/query" import { resolveAnchorMessageId, resolveBoundaryIds, resolveSelection } from "./search" import { COMPRESSED_BLOCK_HEADER } from "./state" import type { diff --git a/lib/compress/message.ts b/lib/compress/message.ts index 91109612..91ffa4a4 100644 --- a/lib/compress/message.ts +++ b/lib/compress/message.ts @@ -1,6 +1,6 @@ import { tool } from "@opencode-ai/plugin" import type { ToolContext } from "./types" -import { countTokens } from "../strategies/utils" +import { countTokens } from "../token-utils" import { MESSAGE_FORMAT_OVERLAY } from "../prompts/internal-overlays" import { formatIssues, formatResult, resolveMessages, validateArgs } from "./message-utils" import { finalizeSession, prepareSession, type NotificationEntry } from "./pipeline" diff --git a/lib/compress/pipeline.ts b/lib/compress/pipeline.ts index 366d17bb..acf92392 100644 --- a/lib/compress/pipeline.ts +++ b/lib/compress/pipeline.ts @@ -2,9 +2,9 @@ import type { WithParts } from "../state" import { ensureSessionInitialized } from "../state" import { saveSessionState } from "../state/persistence" import { assignMessageRefs } from "../message-ids" -import { isIgnoredUserMessage } from "../messages/utils" +import { isIgnoredUserMessage } from "../messages/query" import { deduplicate, purgeErrors } from "../strategies" -import { getCurrentParams, getCurrentTokenUsage } from "../strategies/utils" +import { getCurrentParams, getCurrentTokenUsage } from "../token-utils" import { sendCompressNotification } from "../ui/notification" import type { ToolContext } from "./types" import { buildSearchContext, fetchSessionMessages } from "./search" diff --git a/lib/compress/protected-content.ts b/lib/compress/protected-content.ts index 3db4cf0a..5001ed40 100644 --- a/lib/compress/protected-content.ts +++ b/lib/compress/protected-content.ts @@ -1,5 +1,5 @@ import type { SessionState } from "../state" -import { isIgnoredUserMessage } from "../messages/utils" +import { isIgnoredUserMessage } from "../messages/query" import { getFilePathsFromParameters, isFilePathProtected, diff --git a/lib/compress/range.ts b/lib/compress/range.ts index cfbe21cf..4c1994f0 100644 --- a/lib/compress/range.ts +++ b/lib/compress/range.ts @@ -1,6 +1,6 @@ import { tool } from "@opencode-ai/plugin" import type { ToolContext } from "./types" -import { countTokens } from "../strategies/utils" +import { countTokens } from "../token-utils" import { RANGE_FORMAT_OVERLAY } from "../prompts/internal-overlays" import { finalizeSession, prepareSession, type NotificationEntry } from "./pipeline" import { appendProtectedTools, appendProtectedUserMessages } from "./protected-content" diff --git a/lib/compress/search.ts b/lib/compress/search.ts index 30655e32..0587c85a 100644 --- a/lib/compress/search.ts +++ b/lib/compress/search.ts @@ -1,7 +1,7 @@ import type { SessionState, WithParts } from "../state" import { formatBlockRef, parseBoundaryId } from "../message-ids" -import { isIgnoredUserMessage } from "../messages/utils" -import { countAllMessageTokens } from "../strategies/utils" +import { isIgnoredUserMessage } from "../messages/query" +import { countAllMessageTokens } from "../token-utils" import type { BoundaryReference, SearchContext, SelectionResolution } from "./types" export async function fetchSessionMessages(client: any, sessionId: string): Promise { diff --git a/lib/hooks.ts b/lib/hooks.ts index b8e2ed79..3b1d3d41 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -27,7 +27,7 @@ import { handleSweepCommand, } from "./commands" import { type HostPermissionSnapshot } from "./host-permissions" -import { compressPermission, syncCompressPermissionState } from "./shared-utils" +import { compressPermission, syncCompressPermissionState } from "./compress-permission" import { checkSession, ensureSessionInitialized, syncToolCache } from "./state" import { cacheSystemPromptTokens } from "./ui/utils" diff --git a/lib/message-ids.ts b/lib/message-ids.ts index c3dea26f..da003999 100644 --- a/lib/message-ids.ts +++ b/lib/message-ids.ts @@ -1,5 +1,5 @@ import type { SessionState, WithParts } from "./state" -import { isIgnoredUserMessage } from "./messages/utils" +import { isIgnoredUserMessage } from "./messages/query" const MESSAGE_REF_REGEX = /^m(\d{4})$/ const BLOCK_REF_REGEX = /^b([1-9]\d*)$/ diff --git a/lib/messages/inject/inject.ts b/lib/messages/inject/inject.ts index 7fa82730..d746cdcd 100644 --- a/lib/messages/inject/inject.ts +++ b/lib/messages/inject/inject.ts @@ -4,7 +4,13 @@ import type { PluginConfig } from "../../config" import type { RuntimePrompts } from "../../prompts/store" import { formatMessageIdTag } from "../../message-ids" import type { CompressionPriorityMap } from "../priority" -import { compressPermission, getLastUserMessage, messageHasCompress } from "../../shared-utils" +import { compressPermission } from "../../compress-permission" +import { + getLastUserMessage, + isIgnoredUserMessage, + isProtectedUserMessage, + messageHasCompress, +} from "../query" import { saveSessionState } from "../../state/persistence" import { appendToTextPart, @@ -12,8 +18,6 @@ import { appendToLastToolPart, createSyntheticTextPart, hasContent, - isIgnoredUserMessage, - isProtectedUserMessage, } from "../utils" import { addAnchor, diff --git a/lib/messages/inject/utils.ts b/lib/messages/inject/utils.ts index a216c6b6..43722a4c 100644 --- a/lib/messages/inject/utils.ts +++ b/lib/messages/inject/utils.ts @@ -13,10 +13,9 @@ import { appendToLastTextPart, createSyntheticTextPart, hasContent, - isIgnoredUserMessage, } from "../utils" -import { getLastUserMessage } from "../../shared-utils" -import { getCurrentTokenUsage } from "../../strategies/utils" +import { getLastUserMessage, isIgnoredUserMessage } from "../query" +import { getCurrentTokenUsage } from "../../token-utils" import { getActiveSummaryTokenUsage } from "../../state/utils" const MESSAGE_MODE_NUDGE_PRIORITY: MessagePriority = "high" diff --git a/lib/messages/priority.ts b/lib/messages/priority.ts index 4622e17c..2ff99fbd 100644 --- a/lib/messages/priority.ts +++ b/lib/messages/priority.ts @@ -1,8 +1,8 @@ import type { PluginConfig } from "../config" -import { countAllMessageTokens } from "../strategies/utils" -import { isMessageCompacted, messageHasCompress } from "../shared-utils" +import { countAllMessageTokens } from "../token-utils" +import { isMessageCompacted } from "../state/utils" import type { SessionState, WithParts } from "../state" -import { isIgnoredUserMessage, isProtectedUserMessage } from "./utils" +import { isIgnoredUserMessage, isProtectedUserMessage, messageHasCompress } from "./query" const MEDIUM_PRIORITY_MIN_TOKENS = 500 const HIGH_PRIORITY_MIN_TOKENS = 5000 diff --git a/lib/messages/prune.ts b/lib/messages/prune.ts index fa5b7098..1926ab8f 100644 --- a/lib/messages/prune.ts +++ b/lib/messages/prune.ts @@ -1,8 +1,9 @@ import type { SessionState, WithParts } from "../state" import type { Logger } from "../logger" import type { PluginConfig } from "../config" -import { isMessageCompacted, getLastUserMessage } from "../shared-utils" +import { isMessageCompacted } from "../state/utils" import { createSyntheticUserMessage, replaceBlockIdsWithBlocked } from "./utils" +import { getLastUserMessage } from "./query" import type { UserMessage } from "@opencode-ai/sdk/v2" const PRUNED_TOOL_OUTPUT_REPLACEMENT = diff --git a/lib/messages/query.ts b/lib/messages/query.ts new file mode 100644 index 00000000..52cf0e5c --- /dev/null +++ b/lib/messages/query.ts @@ -0,0 +1,56 @@ +import type { PluginConfig } from "../config" +import type { WithParts } from "../state" + +export const getLastUserMessage = ( + messages: WithParts[], + startIndex?: number, +): WithParts | null => { + const start = startIndex ?? messages.length - 1 + for (let i = start; i >= 0; i--) { + const msg = messages[i] + if (msg.info.role === "user" && !isIgnoredUserMessage(msg)) { + return msg + } + } + return null +} + +export const messageHasCompress = (message: WithParts): boolean => { + if (message.info.role !== "assistant") { + return false + } + + const parts = Array.isArray(message.parts) ? message.parts : [] + return parts.some( + (part) => + part.type === "tool" && part.tool === "compress" && part.state?.status === "completed", + ) +} + +export const isIgnoredUserMessage = (message: WithParts): boolean => { + if (message.info.role !== "user") { + return false + } + + const parts = Array.isArray(message.parts) ? message.parts : [] + if (parts.length === 0) { + return true + } + + for (const part of parts) { + if (!(part as any).ignored) { + return false + } + } + + return true +} + +export function isProtectedUserMessage(config: PluginConfig, message: WithParts): boolean { + return ( + config.compress.mode === "message" && + config.compress.protectUserMessages && + message.info.role === "user" && + !isIgnoredUserMessage(message) + ) +} diff --git a/lib/messages/reasoning-strip.ts b/lib/messages/reasoning-strip.ts index 33f3d9be..d2c98620 100644 --- a/lib/messages/reasoning-strip.ts +++ b/lib/messages/reasoning-strip.ts @@ -1,5 +1,5 @@ import type { WithParts } from "../state" -import { getLastUserMessage } from "../shared-utils" +import { getLastUserMessage } from "./query" /** * Mirrors opencode's differentModel handling by preserving part content while diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index 222ad9f2..cfbb9922 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -1,7 +1,6 @@ import { createHash } from "node:crypto" -import type { PluginConfig } from "../config" -import { isMessageCompacted } from "../shared-utils" import type { SessionState, WithParts } from "../state" +import { isMessageCompacted } from "../state/utils" import type { UserMessage } from "@opencode-ai/sdk/v2" const SUMMARY_ID_HASH_LENGTH = 16 @@ -171,34 +170,6 @@ export function buildToolIdList(state: SessionState, messages: WithParts[]): str return toolIds } -export const isIgnoredUserMessage = (message: WithParts): boolean => { - if (message.info.role !== "user") { - return false - } - - const parts = Array.isArray(message.parts) ? message.parts : [] - if (parts.length === 0) { - return true - } - - for (const part of parts) { - if (!(part as any).ignored) { - return false - } - } - - return true -} - -export function isProtectedUserMessage(config: PluginConfig, message: WithParts): boolean { - return ( - config.compress.mode === "message" && - config.compress.protectUserMessages && - message.info.role === "user" && - !isIgnoredUserMessage(message) - ) -} - export const replaceBlockIdsWithBlocked = (text: string): string => { return text.replace(DCP_BLOCK_ID_TAG_REGEX, "$1BLOCKED$2") } diff --git a/lib/shared-utils.ts b/lib/shared-utils.ts deleted file mode 100644 index 1af77223..00000000 --- a/lib/shared-utils.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { PluginConfig } from "./config" -import { type HostPermissionSnapshot, resolveEffectiveCompressPermission } from "./host-permissions" -import { SessionState, WithParts } from "./state" -import { isIgnoredUserMessage } from "./messages/utils" - -export const isMessageCompacted = (state: SessionState, msg: WithParts): boolean => { - if (msg.info.time.created < state.lastCompaction) { - return true - } - const pruneEntry = state.prune.messages.byMessageId.get(msg.info.id) - if (pruneEntry && pruneEntry.activeBlockIds.length > 0) { - return true - } - return false -} - -export const getLastUserMessage = ( - messages: WithParts[], - startIndex?: number, -): WithParts | null => { - const start = startIndex ?? messages.length - 1 - for (let i = start; i >= 0; i--) { - const msg = messages[i] - if (msg.info.role === "user" && !isIgnoredUserMessage(msg)) { - return msg - } - } - return null -} - -export const messageHasCompress = (message: WithParts): boolean => { - if (message.info.role !== "assistant") { - return false - } - - const parts = Array.isArray(message.parts) ? message.parts : [] - return parts.some( - (part) => - part.type === "tool" && part.tool === "compress" && part.state?.status === "completed", - ) -} - -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, - ) -} diff --git a/lib/state/state.ts b/lib/state/state.ts index f401e3de..c8a00ba1 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -11,7 +11,7 @@ import { loadPruneMap, collectTurnNudgeAnchors, } from "./utils" -import { getLastUserMessage } from "../shared-utils" +import { getLastUserMessage } from "../messages/query" export const checkSession = async ( client: any, diff --git a/lib/state/tool-cache.ts b/lib/state/tool-cache.ts index 0678ed2d..82f4f836 100644 --- a/lib/state/tool-cache.ts +++ b/lib/state/tool-cache.ts @@ -1,8 +1,8 @@ import type { SessionState, ToolStatus, WithParts } from "./index" import type { Logger } from "../logger" import { PluginConfig } from "../config" -import { isMessageCompacted } from "../shared-utils" -import { countToolTokens } from "../strategies/utils" +import { isMessageCompacted } from "./utils" +import { countToolTokens } from "../token-utils" const MAX_TOOL_CACHE_SIZE = 1000 diff --git a/lib/state/utils.ts b/lib/state/utils.ts index 08cd2096..fcaaa290 100644 --- a/lib/state/utils.ts +++ b/lib/state/utils.ts @@ -5,9 +5,19 @@ import type { SessionState, WithParts, } from "./types" -import { isMessageCompacted, messageHasCompress } from "../shared-utils" -import { isIgnoredUserMessage } from "../messages/utils" -import { countTokens } from "../strategies/utils" +import { isIgnoredUserMessage, messageHasCompress } from "../messages/query" +import { countTokens } from "../token-utils" + +export const isMessageCompacted = (state: SessionState, msg: WithParts): boolean => { + if (msg.info.time.created < state.lastCompaction) { + return true + } + const pruneEntry = state.prune.messages.byMessageId.get(msg.info.id) + if (pruneEntry && pruneEntry.activeBlockIds.length > 0) { + return true + } + return false +} interface PersistedPruneMessagesState { byMessageId?: Record diff --git a/lib/strategies/deduplication.ts b/lib/strategies/deduplication.ts index e7f7b310..42fbeda9 100644 --- a/lib/strategies/deduplication.ts +++ b/lib/strategies/deduplication.ts @@ -6,7 +6,7 @@ import { isFilePathProtected, isToolNameProtected, } from "../protected-patterns" -import { getTotalToolTokens } from "./utils" +import { getTotalToolTokens } from "../token-utils" /** * Deduplication strategy - prunes older tool calls that have identical diff --git a/lib/strategies/purge-errors.ts b/lib/strategies/purge-errors.ts index 8a0a9a08..d19be82a 100644 --- a/lib/strategies/purge-errors.ts +++ b/lib/strategies/purge-errors.ts @@ -6,7 +6,7 @@ import { isFilePathProtected, isToolNameProtected, } from "../protected-patterns" -import { getTotalToolTokens } from "./utils" +import { getTotalToolTokens } from "../token-utils" /** * Purge Errors strategy - prunes tool inputs for tools that errored diff --git a/lib/strategies/utils.ts b/lib/token-utils.ts similarity index 97% rename from lib/strategies/utils.ts rename to lib/token-utils.ts index 3136a61c..ff9e1df5 100644 --- a/lib/strategies/utils.ts +++ b/lib/token-utils.ts @@ -1,8 +1,8 @@ -import { SessionState, WithParts } from "../state" +import { SessionState, WithParts } from "./state" import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" -import { Logger } from "../logger" +import { Logger } from "./logger" import { countTokens as anthropicCountTokens } from "@anthropic-ai/tokenizer" -import { getLastUserMessage } from "../shared-utils" +import { getLastUserMessage } from "./messages/query" export function getCurrentTokenUsage(state: SessionState, messages: WithParts[]): number { for (let i = messages.length - 1; i >= 0; i--) { diff --git a/lib/ui/utils.ts b/lib/ui/utils.ts index a4f238f9..a3de072b 100644 --- a/lib/ui/utils.ts +++ b/lib/ui/utils.ts @@ -1,6 +1,6 @@ import { SessionState, ToolParameterEntry, WithParts } from "../state" -import { countTokens } from "../strategies/utils" -import { isIgnoredUserMessage } from "../messages/utils" +import { countTokens } from "../token-utils" +import { isIgnoredUserMessage } from "../messages/query" function extractParameterKey(tool: string, parameters: any): string { if (!parameters) return "" diff --git a/tests/message-utils.test.ts b/tests/message-utils.test.ts index 0d90990b..02afebb4 100644 --- a/tests/message-utils.test.ts +++ b/tests/message-utils.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import test from "node:test" -import { isIgnoredUserMessage } from "../lib/messages/utils" +import { isIgnoredUserMessage } from "../lib/messages/query" import type { WithParts } from "../lib/state" function buildMessage(role: "user" | "assistant", parts: WithParts["parts"]): WithParts { diff --git a/tests/token-counting.test.ts b/tests/token-counting.test.ts index dff1ff7b..d6032bf3 100644 --- a/tests/token-counting.test.ts +++ b/tests/token-counting.test.ts @@ -6,7 +6,7 @@ import { countToolTokens, estimateTokensBatch, extractToolContent, -} from "../lib/strategies/utils" +} from "../lib/token-utils" function buildToolMessage(part: Record): WithParts { return { diff --git a/tests/token-usage.test.ts b/tests/token-usage.test.ts index a1d1ebeb..4c1e6af7 100644 --- a/tests/token-usage.test.ts +++ b/tests/token-usage.test.ts @@ -5,7 +5,7 @@ import { isContextOverLimits } from "../lib/messages/inject/utils" import { wrapCompressedSummary } from "../lib/compress/state" import { createSessionState, type WithParts } from "../lib/state" import type { CompressionBlock } from "../lib/state" -import { getCurrentTokenUsage } from "../lib/strategies/utils" +import { getCurrentTokenUsage } from "../lib/token-utils" function buildConfig(maxContextLimit: number, minContextLimit = 1): PluginConfig { return { From 6feec64576a06f1a4e77d5e0f30a1b1a0e11a3aa Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sun, 29 Mar 2026 03:41:10 -0400 Subject: [PATCH 05/33] fix: count compacted tool outputs correctly --- lib/commands/context.ts | 9 +++----- lib/token-utils.ts | 43 ++++++++++++++++++++++-------------- tests/token-counting.test.ts | 19 ++++++++++++++++ 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/lib/commands/context.ts b/lib/commands/context.ts index 8c287a08..c4f0408e 100644 --- a/lib/commands/context.ts +++ b/lib/commands/context.ts @@ -46,7 +46,7 @@ import { sendIgnoredMessage } from "../ui/notification" import { formatTokenCount } from "../ui/utils" import { isIgnoredUserMessage } from "../messages/query" import { isMessageCompacted } from "../state/utils" -import { countTokens, getCurrentParams } from "../token-utils" +import { countTokens, extractCompletedToolOutput, getCurrentParams } from "../token-utils" import type { AssistantMessage, TextPart, ToolPart } from "@opencode-ai/sdk/v2" export interface ContextCommandContext { @@ -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) } } diff --git a/lib/token-utils.ts b/lib/token-utils.ts index ff9e1df5..5ca09372 100644 --- a/lib/token-utils.ts +++ b/lib/token-utils.ts @@ -78,6 +78,28 @@ export function estimateTokensBatch(texts: string[]): number { return countTokens(texts.join(" ")) } +export const COMPACTED_TOOL_OUTPUT_PLACEHOLDER = "[Old tool result content cleared]" + +function stringifyToolContent(value: unknown): string { + return typeof value === "string" ? value : JSON.stringify(value) +} + +export function extractCompletedToolOutput(part: any): string | undefined { + if ( + part?.type !== "tool" || + part.state?.status !== "completed" || + part.state?.output === undefined + ) { + return undefined + } + + if (part.state?.time?.compacted) { + return COMPACTED_TOOL_OUTPUT_PLACEHOLDER + } + + return stringifyToolContent(part.state.output) +} + export function extractToolContent(part: any): string[] { const contents: string[] = [] @@ -86,25 +108,14 @@ export function extractToolContent(part: any): string[] { } if (part.state?.input !== undefined) { - const inputContent = - typeof part.state.input === "string" - ? part.state.input - : JSON.stringify(part.state.input) - contents.push(inputContent) + contents.push(stringifyToolContent(part.state.input)) } - if (part.state?.status === "completed" && part.state?.output) { - const content = - typeof part.state.output === "string" - ? part.state.output - : JSON.stringify(part.state.output) - contents.push(content) + const completedOutput = extractCompletedToolOutput(part) + if (completedOutput !== undefined) { + contents.push(completedOutput) } else if (part.state?.status === "error" && part.state?.error) { - const content = - typeof part.state.error === "string" - ? part.state.error - : JSON.stringify(part.state.error) - contents.push(content) + contents.push(stringifyToolContent(part.state.error)) } return contents diff --git a/tests/token-counting.test.ts b/tests/token-counting.test.ts index d6032bf3..03e9439c 100644 --- a/tests/token-counting.test.ts +++ b/tests/token-counting.test.ts @@ -2,9 +2,11 @@ import assert from "node:assert/strict" import test from "node:test" import type { WithParts } from "../lib/state" import { + COMPACTED_TOOL_OUTPUT_PLACEHOLDER, countAllMessageTokens, countToolTokens, estimateTokensBatch, + extractCompletedToolOutput, extractToolContent, } from "../lib/token-utils" @@ -153,3 +155,20 @@ test("counting includes input for errored custom tools", () => { assertCounted(part, [JSON.stringify(customInput), "Tool execution failed"]) }) + +test("counting uses the compacted tool placeholder for completed outputs", () => { + const input = { filePath: "/tmp/large.log" } + const part = buildToolPart("read", { + status: "completed", + input, + output: "full original output that is no longer visible to the model", + time: { + start: 1, + end: 2, + compacted: 3, + }, + }) + + assert.equal(extractCompletedToolOutput(part), COMPACTED_TOOL_OUTPUT_PLACEHOLDER) + assertCounted(part, [JSON.stringify(input), COMPACTED_TOOL_OUTPUT_PLACEHOLDER]) +}) From c6921eb2aad17aec74feed41b877c380bedb65bb Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sun, 29 Mar 2026 14:51:41 -0400 Subject: [PATCH 06/33] docs: add global plugin install command --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5f7d03e0..33aa250c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,15 @@ Automatically reduces token usage in OpenCode by managing conversation context. ## Installation -Add to your OpenCode config: +Install with the OpenCode CLI: + +```bash +opencode plugin @tarquinen/opencode-dcp@latest --global +``` + +This installs the package and adds it to your global OpenCode config. + +Or add it to your OpenCode config manually: ```jsonc // opencode.jsonc From 1bef1c3a88d7daf095c4e42b128af7b2725c760b Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sun, 29 Mar 2026 21:34:39 -0400 Subject: [PATCH 07/33] docs: update installation instructions --- README.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/README.md b/README.md index 33aa250c..3c3c8921 100644 --- a/README.md +++ b/README.md @@ -17,17 +17,6 @@ opencode plugin @tarquinen/opencode-dcp@latest --global This installs the package and adds it to your global OpenCode config. -Or add it to your OpenCode config manually: - -```jsonc -// opencode.jsonc -{ - "plugin": ["@tarquinen/opencode-dcp@latest"], -} -``` - -Using `@latest` ensures you always get the newest version automatically when OpenCode starts. - Restart OpenCode. The plugin will automatically start optimizing your sessions. ## How It Works From 074184e37a7f5b8902c4ea6ccc1d4ac1b128bba3 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sun, 29 Mar 2026 21:35:15 -0400 Subject: [PATCH 08/33] docs: update installation instructions again --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 3c3c8921..f3a9480f 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,6 @@ opencode plugin @tarquinen/opencode-dcp@latest --global This installs the package and adds it to your global OpenCode config. -Restart OpenCode. The plugin will automatically start optimizing your sessions. - ## How It Works DCP reduces context size through a compress tool and automatic cleanup. Your session history is never modified — DCP replaces pruned content with placeholders before sending requests to your LLM. From 68a094be9f9560cb056b1610eb334941c29fe9e9 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sun, 29 Mar 2026 23:53:27 -0400 Subject: [PATCH 09/33] update(prompts): refine compress and nudge prompt wording --- lib/prompts/compress-message.ts | 14 +++----------- lib/prompts/context-limit-nudge.ts | 12 +++--------- lib/prompts/iteration-nudge.ts | 2 -- lib/prompts/system.ts | 13 ------------- lib/prompts/turn-nudge.ts | 3 +-- 5 files changed, 7 insertions(+), 37 deletions(-) diff --git a/lib/prompts/compress-message.ts b/lib/prompts/compress-message.ts index 94d7ff85..007eb995 100644 --- a/lib/prompts/compress-message.ts +++ b/lib/prompts/compress-message.ts @@ -8,6 +8,7 @@ When a selected message contains user intent, preserve that intent with extra ca Directly quote short user instructions when that best preserves exact meaning. Yet be LEAN. Strip away the noise: failed attempts that led nowhere, verbose tool output, and repetition. What remains should be pure signal - golden nuggets of detail that preserve full understanding with zero ambiguity. +If a message contains no significant technical decisions, code changes, or user requirements, produce a minimal one-line summary rather than a detailed one. MESSAGE IDS You specify individual raw messages by ID using the injected IDs visible in the conversation: @@ -17,7 +18,8 @@ You specify individual raw messages by ID using the injected IDs visible in the Each message has an ID inside XML metadata tags like \`m0007\`. The ID tag appears at the end of the message it belongs to — each ID covers all the content above it back to the previous ID. Treat these tags as message metadata only, not as content to summarize. Use only the inner \`mNNNN\` value as the \`messageId\`. -The \`priority\` attribute indicates relative context cost. Prefer higher-priority closed messages before lower-priority ones. +The \`priority\` attribute indicates relative context cost. When using the compress tool, if there are high-priority messages they MUST be compressed if all of their information is not vital to the task at hand. +If there are previous messages with compress tool results, these MUST be compressed with a minimal summary. Messages marked as \`BLOCKED\` cannot be compressed. Rules: @@ -25,19 +27,9 @@ Rules: - Pick each \`messageId\` directly from injected IDs visible in context. - Only use raw message IDs of the form \`mNNNN\`. - Ignore XML attributes such as \`priority\` when copying the ID; use only the inner \`mNNNN\` value. -- Do NOT use compressed block IDs like \`bN\`. - Do not invent IDs. Use only IDs that are present in context. -- Do not target prior compressed blocks or block summaries. BATCHING Select MANY messages in a single tool call when they are independently safe to compress. Each entry should summarize exactly one message, and the tool can receive as many entries as needed in one batch. -When several messages are equally safe to compress, prefer higher-priority messages first. - -Because each message is compressed independently: - -- Do not describe ranges -- Do not use start/end boundaries -- Do not use compressed block placeholders -- Do not reference prior compressed blocks with \`(bN)\` ` diff --git a/lib/prompts/context-limit-nudge.ts b/lib/prompts/context-limit-nudge.ts index f216e5ff..1b378470 100644 --- a/lib/prompts/context-limit-nudge.ts +++ b/lib/prompts/context-limit-nudge.ts @@ -7,18 +7,12 @@ You MUST use the \`compress\` tool now. Do not continue normal exploration until If you are in the middle of a critical atomic operation, finish that atomic step first, then compress immediately. -RANGE STRATEGY (MANDATORY) -Prioritize one large, closed, high-yield compression range first. -This overrides the normal preference for many small compressions. -Only split into multiple compressions if one large range would reduce summary quality or make boundary selection unsafe. - -RANGE SELECTION +SELECTION PROCESS Start from older, resolved history and capture as much stale context as safely possible in one pass. -Avoid the newest active working slice unless it is clearly closed. -Use visible injected boundary IDs for compression (\`mNNNN\` for messages, \`bN\` for compressed blocks), and ensure \`startId\` appears before \`endId\`. +Avoid the newest active working messages unless it is clearly closed. SUMMARY REQUIREMENTS -Your summary must cover all essential details from the selected range so work can continue without reopening raw messages. +Your summary MUST cover all essential details from the selected messages so work can continue. If the compressed range includes user messages, preserve user intent exactly. Prefer direct quotes for short user messages to avoid semantic drift. ` diff --git a/lib/prompts/iteration-nudge.ts b/lib/prompts/iteration-nudge.ts index f8c6054c..f8e4fa9b 100644 --- a/lib/prompts/iteration-nudge.ts +++ b/lib/prompts/iteration-nudge.ts @@ -2,7 +2,5 @@ export const ITERATION_NUDGE = ` You've been iterating for a while after the last user message. If there is a closed portion that is unlikely to be referenced immediately (for example, finished research before implementation), use the compress tool on it now. - -Prefer multiple short, closed ranges over one large range when several independent slices are ready. ` diff --git a/lib/prompts/system.ts b/lib/prompts/system.ts index 69ffbb80..da3cd41e 100644 --- a/lib/prompts/system.ts +++ b/lib/prompts/system.ts @@ -10,19 +10,6 @@ THE PHILOSOPHY OF COMPRESS Think of compression as phase transitions: raw exploration becomes refined understanding. The original context served its purpose; your summary now carries that understanding forward. -OPERATING STANCE -Prefer short, closed, summary-safe compressions. -When multiple independent stale sections exist, prefer several focused compressions (in parallel when possible) over one broad compression. - -Use \`compress\` as steady housekeeping while you work. - -CADENCE, SIGNALS, AND LATENCY - -- No fixed threshold mandates compression -- Prioritize closedness and independence over raw size -- Prefer smaller, regular compressions over infrequent massive compressions for better latency and summary quality -- When multiple independent stale sections are ready, batch compressions in parallel - COMPRESS WHEN A section is genuinely closed and the raw conversation has served its purpose: diff --git a/lib/prompts/turn-nudge.ts b/lib/prompts/turn-nudge.ts index e6a5bdc2..9f64f108 100644 --- a/lib/prompts/turn-nudge.ts +++ b/lib/prompts/turn-nudge.ts @@ -1,10 +1,9 @@ export const TURN_NUDGE = ` Evaluate the conversation for compressible ranges. -If any range is cleanly closed and unlikely to be needed again, use the compress tool on it. +If any messages are cleanly closed and unlikely to be needed again, use the compress tool on them. If direction has shifted, compress earlier ranges that are now less relevant. -Prefer small, closed-range compressions over one broad compression. The goal is to filter noise and distill key information so context accumulation stays under control. Keep active context uncompressed. From 2c2805106067c8e7ff8b1e2ce7497478f0ee7c9e Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Sun, 29 Mar 2026 23:53:34 -0400 Subject: [PATCH 10/33] refactor(prompts): reorganize extensions and add protected tools extension --- lib/commands/manual.ts | 2 +- lib/compress/message.ts | 4 +- lib/compress/range.ts | 4 +- lib/hooks.ts | 2 + lib/messages/inject/utils.ts | 38 +++--------------- lib/prompts/extensions/nudge.ts | 43 ++++++++++++++++++++ lib/prompts/extensions/system.ts | 32 +++++++++++++++ lib/prompts/extensions/tool.ts | 35 ++++++++++++++++ lib/prompts/index.ts | 28 ++++++------- lib/prompts/internal-overlays.ts | 51 ------------------------ lib/prompts/message-priority-guidance.ts | 9 ----- lib/prompts/store.ts | 16 ++++---- scripts/print.ts | 16 ++++---- tests/compress-message.test.ts | 2 +- tests/prompts.test.ts | 3 +- 15 files changed, 154 insertions(+), 131 deletions(-) create mode 100644 lib/prompts/extensions/nudge.ts create mode 100644 lib/prompts/extensions/system.ts create mode 100644 lib/prompts/extensions/tool.ts delete mode 100644 lib/prompts/internal-overlays.ts delete mode 100644 lib/prompts/message-priority-guidance.ts diff --git a/lib/commands/manual.ts b/lib/commands/manual.ts index 1d2100b0..e93af727 100644 --- a/lib/commands/manual.ts +++ b/lib/commands/manual.ts @@ -12,7 +12,7 @@ import type { SessionState, WithParts } from "../state" import type { PluginConfig } from "../config" import { sendIgnoredMessage } from "../ui/notification" import { getCurrentParams } from "../token-utils" -import { buildCompressedBlockGuidance } from "../messages/inject/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." diff --git a/lib/compress/message.ts b/lib/compress/message.ts index 91ffa4a4..74139857 100644 --- a/lib/compress/message.ts +++ b/lib/compress/message.ts @@ -1,7 +1,7 @@ import { tool } from "@opencode-ai/plugin" import type { ToolContext } from "./types" import { countTokens } from "../token-utils" -import { MESSAGE_FORMAT_OVERLAY } from "../prompts/internal-overlays" +import { MESSAGE_FORMAT_EXTENSION } from "../prompts/extensions/tool" import { formatIssues, formatResult, resolveMessages, validateArgs } from "./message-utils" import { finalizeSession, prepareSession, type NotificationEntry } from "./pipeline" import { appendProtectedTools } from "./protected-content" @@ -43,7 +43,7 @@ export function createCompressMessageTool(ctx: ToolContext): ReturnType Number.isInteger(id) && id > 0) - .sort((a, b) => a - b) - .map((id) => `b${id}`) - const blockCount = refs.length - const blockList = blockCount > 0 ? refs.join(", ") : "none" - - return [ - "Compressed block context:", - `- Active compressed blocks in this session: ${blockCount} (${blockList})`, - "- If your selected compression range includes any listed block, include each required placeholder exactly once in the summary using \`(bN)\`.", - ].join("\n") -} - -function appendGuidanceToDcpTag(nudgeText: string, guidance: string): string { - if (!guidance.trim()) { - return nudgeText - } - - const closeTag = "" - const closeTagIndex = nudgeText.lastIndexOf(closeTag) - - if (closeTagIndex === -1) { - return nudgeText - } - - const beforeClose = nudgeText.slice(0, closeTagIndex).trimEnd() - const afterClose = nudgeText.slice(closeTagIndex) - return `${beforeClose}\n\n${guidance}\n${afterClose}` -} - function buildMessagePriorityGuidance( messages: WithParts[], compressionPriorities: CompressionPriorityMap | undefined, diff --git a/lib/prompts/extensions/nudge.ts b/lib/prompts/extensions/nudge.ts new file mode 100644 index 00000000..7137eb01 --- /dev/null +++ b/lib/prompts/extensions/nudge.ts @@ -0,0 +1,43 @@ +import type { SessionState } from "../../state" + +export function buildCompressedBlockGuidance(state: SessionState): string { + const refs = Array.from(state.prune.messages.activeBlockIds) + .filter((id) => Number.isInteger(id) && id > 0) + .sort((a, b) => a - b) + .map((id) => `b${id}`) + const blockCount = refs.length + const blockList = blockCount > 0 ? refs.join(", ") : "none" + + return [ + "Compressed block context:", + `- Active compressed blocks in this session: ${blockCount} (${blockList})`, + "- If your selected compression range includes any listed block, include each required placeholder exactly once in the summary using `(bN)`.", + ].join("\n") +} + +export function renderMessagePriorityGuidance(priorityLabel: string, refs: string[]): string { + const refList = refs.length > 0 ? refs.join(", ") : "none" + + return [ + "Message priority context:", + "- Higher-priority older messages consume more context and should be compressed right away if it is safe to do so.", + `- ${priorityLabel}-priority message IDs before this point: ${refList}`, + ].join("\n") +} + +export function appendGuidanceToDcpTag(nudgeText: string, guidance: string): string { + if (!guidance.trim()) { + return nudgeText + } + + const closeTag = "" + const closeTagIndex = nudgeText.lastIndexOf(closeTag) + + if (closeTagIndex === -1) { + return nudgeText + } + + const beforeClose = nudgeText.slice(0, closeTagIndex).trimEnd() + const afterClose = nudgeText.slice(closeTagIndex) + return `${beforeClose}\n\n${guidance}\n${afterClose}` +} diff --git a/lib/prompts/extensions/system.ts b/lib/prompts/extensions/system.ts new file mode 100644 index 00000000..f5f038bd --- /dev/null +++ b/lib/prompts/extensions/system.ts @@ -0,0 +1,32 @@ +export const MANUAL_MODE_SYSTEM_EXTENSION = ` +Manual mode is enabled. Do NOT use compress unless the user has explicitly triggered it through a manual marker. + +Only use the compress tool after seeing \`\` in the current user instruction context. + +Issue exactly ONE compress tool per manual trigger. Do NOT launch multiple compress tools in parallel. Each trigger grants a single compression; after it completes, wait for the next trigger. + +After completing a manually triggered context-management action, STOP IMMEDIATELY. Do NOT continue with any task execution. End your response right after the tool use completes and wait for the next user input. + +` + +export const SUBAGENT_SYSTEM_EXTENSION = ` +You are operating in a subagent environment. + +The initial subagent instruction is imperative and must be followed exactly. +It is the only user message intentionally not assigned a message ID, and therefore is not eligible for compression. +All subsequent messages in the session will have IDs. + +` + +export function buildProtectedToolsExtension(protectedTools: string[]): string { + if (protectedTools.length === 0) { + return "" + } + + const toolList = protectedTools.map((t) => `\`${t}\``).join(", ") + return ` +The following tools are environment-managed: ${toolList}. +Their outputs are automatically preserved during compression. +Do not include their content in compress tool summaries — the environment retains it independently. +` +} diff --git a/lib/prompts/extensions/tool.ts b/lib/prompts/extensions/tool.ts new file mode 100644 index 00000000..ff852ac7 --- /dev/null +++ b/lib/prompts/extensions/tool.ts @@ -0,0 +1,35 @@ +// These format schemas are kept separate from the editable compress prompts +// so they cannot be modified via custom prompt overrides. The schemas must +// match the tool's input validation and are not safe to change independently. + +export const RANGE_FORMAT_EXTENSION = ` +THE FORMAT OF COMPRESS + +\`\`\` +{ + topic: string, // Short label (3-5 words) - e.g., "Auth System Exploration" + content: [ // One or more ranges to compress + { + startId: string, // Boundary ID at range start: mNNNN or bN + endId: string, // Boundary ID at range end: mNNNN or bN + summary: string // Complete technical summary replacing all content in range + } + ] +} +\`\`\`` + +export const MESSAGE_FORMAT_EXTENSION = ` +THE FORMAT OF COMPRESS + +\`\`\` +{ + topic: string, // Short label (3-5 words) for the overall batch + content: [ // One or more messages to compress independently + { + messageId: string, // Raw message ID only: mNNNN (ignore metadata attributes like priority) + topic: string, // Short label (3-5 words) for this one message summary + summary: string // Complete technical summary replacing that one message + } + ] +} +\`\`\`` diff --git a/lib/prompts/index.ts b/lib/prompts/index.ts index 0b3cd98d..bdfbee5e 100644 --- a/lib/prompts/index.ts +++ b/lib/prompts/index.ts @@ -1,29 +1,29 @@ import type { RuntimePrompts } from "./store" export type { PromptStore, RuntimePrompts } from "./store" -function stripLegacyInlineComments(content: string): string { - return content.replace(/^[ \t]*\/\/.*?\/\/[ \t]*$/gm, "") -} - -function appendSystemOverlays(systemPrompt: string, overlays: string[]): string { - return [systemPrompt, ...overlays].filter(Boolean).join("\n\n") -} - export function renderSystemPrompt( prompts: RuntimePrompts, + protectedToolsExtension?: string, manual?: boolean, subagent?: boolean, ): string { - const overlays: string[] = [] + const extensions: string[] = [] + + if (protectedToolsExtension) { + extensions.push(protectedToolsExtension.trim()) + } + if (manual) { - overlays.push(prompts.manualOverlay.trim()) + extensions.push(prompts.manualExtension.trim()) } if (subagent) { - overlays.push(prompts.subagentOverlay.trim()) + extensions.push(prompts.subagentExtension.trim()) } - const strippedSystem = stripLegacyInlineComments(prompts.system).trim() - const withOverlays = appendSystemOverlays(strippedSystem, overlays) - return withOverlays.replace(/\n([ \t]*\n)+/g, "\n\n").trim() + return [prompts.system.trim(), ...extensions] + .filter(Boolean) + .join("\n\n") + .replace(/\n([ \t]*\n)+/g, "\n\n") + .trim() } diff --git a/lib/prompts/internal-overlays.ts b/lib/prompts/internal-overlays.ts deleted file mode 100644 index e0dd317f..00000000 --- a/lib/prompts/internal-overlays.ts +++ /dev/null @@ -1,51 +0,0 @@ -export const MANUAL_MODE_SYSTEM_OVERLAY = ` -Manual mode is enabled. Do NOT use compress unless the user has explicitly triggered it through a manual marker. - -Only use the compress tool after seeing \`\` in the current user instruction context. - -Issue exactly ONE compress call per manual trigger. Do NOT launch multiple compress calls in parallel. Each trigger grants a single compression; after it completes, wait for the next trigger. - -After completing a manually triggered context-management action, STOP IMMEDIATELY. Do NOT continue with any task execution. End your response right after the tool use completes and wait for the next user input. - -` - -export const SUBAGENT_SYSTEM_OVERLAY = ` -You are operating in a subagent environment. - -The initial subagent instruction is imperative and must be followed exactly. -It is the only user message intentionally not assigned a message ID, and therefore is not eligible for compression. -All subsequent messages in the session will have IDs. - -` - -export const RANGE_FORMAT_OVERLAY = ` -THE FORMAT OF COMPRESS - -\`\`\` -{ - topic: string, // Short label (3-5 words) - e.g., "Auth System Exploration" - content: [ // One or more ranges to compress - { - startId: string, // Boundary ID at range start: mNNNN or bN - endId: string, // Boundary ID at range end: mNNNN or bN - summary: string // Complete technical summary replacing all content in range - } - ] -} -\`\`\`` - -export const MESSAGE_FORMAT_OVERLAY = ` -THE FORMAT OF COMPRESS - -\`\`\` -{ - topic: string, // Short label (3-5 words) for the overall batch - content: [ // One or more messages to compress independently - { - messageId: string, // Raw message ID only: mNNNN (ignore metadata attributes like priority) - topic: string, // Short label (3-5 words) for this one message summary - summary: string // Complete technical summary replacing that one message - } - ] -} -\`\`\`` diff --git a/lib/prompts/message-priority-guidance.ts b/lib/prompts/message-priority-guidance.ts deleted file mode 100644 index f41ab626..00000000 --- a/lib/prompts/message-priority-guidance.ts +++ /dev/null @@ -1,9 +0,0 @@ -export function renderMessagePriorityGuidance(priorityLabel: string, refs: string[]): string { - const refList = refs.length > 0 ? refs.join(", ") : "none" - - return [ - "Message priority context:", - "- Higher-priority older messages consume more context and should be compressed before lower-priority ones when safely closed.", - `- ${priorityLabel}-priority message IDs before this point: ${refList}`, - ].join("\n") -} diff --git a/lib/prompts/store.ts b/lib/prompts/store.ts index 885ae670..efe06c24 100644 --- a/lib/prompts/store.ts +++ b/lib/prompts/store.ts @@ -8,7 +8,7 @@ import { COMPRESS_MESSAGE as COMPRESS_MESSAGE_PROMPT } from "./compress-message" import { CONTEXT_LIMIT_NUDGE } from "./context-limit-nudge" import { TURN_NUDGE } from "./turn-nudge" import { ITERATION_NUDGE } from "./iteration-nudge" -import { MANUAL_MODE_SYSTEM_OVERLAY, SUBAGENT_SYSTEM_OVERLAY } from "./internal-overlays" +import { MANUAL_MODE_SYSTEM_EXTENSION, SUBAGENT_SYSTEM_EXTENSION } from "./extensions/system" export type PromptKey = | "system" @@ -53,8 +53,8 @@ export interface RuntimePrompts { contextLimitNudge: string turnNudge: string iterationNudge: string - manualOverlay: string - subagentOverlay: string + manualExtension: string + subagentExtension: string } const PROMPT_DEFINITIONS: PromptDefinition[] = [ @@ -132,9 +132,9 @@ const BUNDLED_EDITABLE_PROMPTS: Record = { iterationNudge: ITERATION_NUDGE, } -const INTERNAL_PROMPT_OVERLAYS = { - manualOverlay: MANUAL_MODE_SYSTEM_OVERLAY, - subagentOverlay: SUBAGENT_SYSTEM_OVERLAY, +const INTERNAL_PROMPT_EXTENSIONS = { + manualExtension: MANUAL_MODE_SYSTEM_EXTENSION, + subagentExtension: SUBAGENT_SYSTEM_EXTENSION, } function createBundledRuntimePrompts(): RuntimePrompts { @@ -145,8 +145,8 @@ function createBundledRuntimePrompts(): RuntimePrompts { contextLimitNudge: BUNDLED_EDITABLE_PROMPTS.contextLimitNudge, turnNudge: BUNDLED_EDITABLE_PROMPTS.turnNudge, iterationNudge: BUNDLED_EDITABLE_PROMPTS.iterationNudge, - manualOverlay: INTERNAL_PROMPT_OVERLAYS.manualOverlay, - subagentOverlay: INTERNAL_PROMPT_OVERLAYS.subagentOverlay, + manualExtension: INTERNAL_PROMPT_EXTENSIONS.manualExtension, + subagentExtension: INTERNAL_PROMPT_EXTENSIONS.subagentExtension, } } diff --git a/scripts/print.ts b/scripts/print.ts index 9e5f4eea..298d1d0a 100644 --- a/scripts/print.ts +++ b/scripts/print.ts @@ -39,10 +39,10 @@ Usage: Options: --list List available prompt keys --show Print effective prompt text for key - --system Print effective system prompt with no overlays - --system-manual Print system prompt with manual overlay - --system-subagent Print system prompt with subagent overlay - --system-all Print system prompt with both overlays + --system Print effective system prompt with no extensions + --system-manual Print system prompt with manual extension + --system-subagent Print system prompt with subagent extension + --system-all Print system prompt with both extensions Prompt keys: system, compress-range, compress-message, @@ -88,18 +88,18 @@ if (showIndex >= 0) { } if (args.includes("--system-all")) { - console.log(renderSystemPrompt(runtimePrompts, true, true)) + console.log(renderSystemPrompt(runtimePrompts, undefined, true, true)) process.exit(0) } if (args.includes("--system-manual")) { - console.log(renderSystemPrompt(runtimePrompts, true, false)) + console.log(renderSystemPrompt(runtimePrompts, undefined, true)) process.exit(0) } if (args.includes("--system-subagent")) { - console.log(renderSystemPrompt(runtimePrompts, false, true)) + console.log(renderSystemPrompt(runtimePrompts, undefined, false, true)) process.exit(0) } -console.log(renderSystemPrompt(runtimePrompts, false, false)) +console.log(renderSystemPrompt(runtimePrompts)) diff --git a/tests/compress-message.test.ts b/tests/compress-message.test.ts index db03ff1a..f2c62069 100644 --- a/tests/compress-message.test.ts +++ b/tests/compress-message.test.ts @@ -140,7 +140,7 @@ function buildMessages(sessionID: string): WithParts[] { ] } -test("compress message tool appends non-editable format overlay", () => { +test("compress message tool appends non-editable format extension", () => { const tool = createCompressMessageTool({ client: {}, state: createSessionState(), diff --git a/tests/prompts.test.ts b/tests/prompts.test.ts index b1c0db0b..8e1a19be 100644 --- a/tests/prompts.test.ts +++ b/tests/prompts.test.ts @@ -114,10 +114,9 @@ test("prompt store exposes bundled message-mode compress prompt", () => { /Only use raw message IDs of the form `mNNNN`\./, ) assert.match(runtimePrompts.compressMessage, /priority="high"/) - assert.match(runtimePrompts.compressMessage, /prefer higher-priority messages first/i) + assert.match(runtimePrompts.compressMessage, /high-priority messages/i) assert.match(runtimePrompts.compressMessage, /BLOCKED/) assert.match(runtimePrompts.compressMessage, /cannot be compressed/i) - assert.match(runtimePrompts.compressMessage, /Do not use compressed block placeholders/i) assert.doesNotMatch(runtimePrompts.compressMessage, /THE FORMAT OF COMPRESS/) } finally { fixture.cleanup() From c3c9bfab6f93e4b6f6b3b970762a74ecc73f8651 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 30 Mar 2026 21:43:53 -0400 Subject: [PATCH 11/33] fix: improve compress message skip handling --- lib/compress/message-utils.ts | 156 +++++++++++++++++++++++---------- lib/compress/message.ts | 8 +- lib/compress/types.ts | 1 + tests/compress-message.test.ts | 72 +++++++++++++++ 4 files changed, 185 insertions(+), 52 deletions(-) diff --git a/lib/compress/message-utils.ts b/lib/compress/message-utils.ts index fffee3e8..1664e424 100644 --- a/lib/compress/message-utils.ts +++ b/lib/compress/message-utils.ts @@ -12,7 +12,20 @@ import type { SearchContext, } from "./types" -class SoftIssue extends Error {} +interface SkippedIssue { + kind: string + messageId: string +} + +class SoftIssue extends Error { + constructor( + public readonly kind: string, + public readonly messageId: string, + message: string, + ) { + super(message) + } +} export function validateArgs(args: CompressMessageToolArgs): void { if (typeof args.topic !== "string" || args.topic.trim().length === 0) { @@ -41,26 +54,94 @@ export function validateArgs(args: CompressMessageToolArgs): void { } } -export function formatResult(processedCount: number, skippedIssues: string[]): string { +export function formatResult( + processedCount: number, + skippedIssues: string[], + skippedCount: number, +): string { const messageNoun = processedCount === 1 ? "message" : "messages" const processedText = processedCount > 0 ? `Compressed ${processedCount} ${messageNoun} into ${COMPRESSED_BLOCK_HEADER}.` : "Compressed 0 messages." - if (skippedIssues.length === 0) { + if (skippedCount === 0) { return processedText } - const issueNoun = skippedIssues.length === 1 ? "issue" : "issues" + const issueNoun = skippedCount === 1 ? "issue" : "issues" const issueLines = skippedIssues.map((issue) => `- ${issue}`).join("\n") - return `${processedText}\nSkipped ${skippedIssues.length} ${issueNoun}:\n${issueLines}` + return `${processedText}\nSkipped ${skippedCount} ${issueNoun}:\n${issueLines}` } -export function formatIssues(skippedIssues: string[]): string { - const issueNoun = skippedIssues.length === 1 ? "issue" : "issues" +export function formatIssues(skippedIssues: string[], skippedCount: number): string { + const issueNoun = skippedCount === 1 ? "issue" : "issues" const issueLines = skippedIssues.map((issue) => `- ${issue}`).join("\n") - return `Unable to compress any messages. Found ${skippedIssues.length} ${issueNoun}:\n${issueLines}` + return `Unable to compress any messages. Found ${skippedCount} ${issueNoun}:\n${issueLines}` +} + +const ISSUE_TEMPLATES: Record = { + blocked: [ + "refers to a protected message and cannot be compressed.", + "refer to protected messages and cannot be compressed.", + ], + "invalid-format": [ + "is invalid. Use an injected raw message ID of the form mNNNN.", + "are invalid. Use injected raw message IDs of the form mNNNN.", + ], + "block-id": [ + "is invalid here. Block IDs like bN are not allowed; use an mNNNN message ID instead.", + "are invalid here. Block IDs like bN are not allowed; use mNNNN message IDs instead.", + ], + "not-in-context": [ + "is not available in the current conversation context. Choose an injected mNNNN ID visible in context.", + "are not available in the current conversation context. Choose injected mNNNN IDs visible in context.", + ], + protected: [ + "refers to a protected message and cannot be compressed.", + "refer to protected messages and cannot be compressed.", + ], + "already-compressed": [ + "is already part of an active compression.", + "are already part of active compressions.", + ], + duplicate: [ + "was selected more than once in this batch.", + "were each selected more than once in this batch.", + ], +} + +function formatSkippedGroup(kind: string, messageIds: string[]): string { + const templates = ISSUE_TEMPLATES[kind] + const ids = messageIds.join(", ") + const single = messageIds.length === 1 + const prefix = single ? "messageId" : "messageIds" + + if (!templates) { + return `${prefix} ${ids}: unknown issue.` + } + + return `${prefix} ${ids} ${single ? templates[0] : templates[1]}` +} + +function groupSkippedIssues(issues: SkippedIssue[]): string[] { + const groups = new Map() + const order: string[] = [] + + for (const issue of issues) { + let ids = groups.get(issue.kind) + if (!ids) { + ids = [] + groups.set(issue.kind, ids) + order.push(issue.kind) + } + ids.push(issue.messageId) + } + + return order.map((kind) => { + const ids = groups.get(kind)! + return formatSkippedGroup(kind, ids) + }) } export function resolveMessages( @@ -69,16 +150,14 @@ export function resolveMessages( state: SessionState, config: PluginConfig, ): ResolvedMessageCompressionsResult { - const issues: string[] = [] + const issues: SkippedIssue[] = [] const plans: ResolvedMessageCompression[] = [] const seenMessageIds = new Set() for (const entry of args.content) { const normalizedMessageId = entry.messageId.trim() if (seenMessageIds.has(normalizedMessageId)) { - issues.push( - `messageId ${normalizedMessageId} was selected more than once in this batch.`, - ) + issues.push({ kind: "duplicate", messageId: normalizedMessageId }) continue } @@ -96,7 +175,7 @@ export function resolveMessages( plans.push(plan) } catch (error: any) { if (error instanceof SoftIssue) { - issues.push(error.message) + issues.push({ kind: error.kind, messageId: error.messageId }) continue } @@ -106,7 +185,8 @@ export function resolveMessages( return { plans, - skippedIssues: issues, + skippedIssues: groupSkippedIssues(issues), + skippedCount: issues.length, } } @@ -117,36 +197,28 @@ function resolveMessage( config: PluginConfig, ): ResolvedMessageCompression { if (entry.messageId.toUpperCase() === "BLOCKED") { - throw new SoftIssue( - "messageId BLOCKED refers to a protected message and cannot be compressed.", - ) + throw new SoftIssue("blocked", "BLOCKED", "protected message") } const parsed = parseBoundaryId(entry.messageId) if (!parsed) { - throw new Error( - `messageId ${entry.messageId} is invalid. Use an injected raw message ID of the form mNNNN.`, - ) + throw new SoftIssue("invalid-format", entry.messageId, "invalid format") } if (parsed.kind === "compressed-block") { - throw new SoftIssue( - `messageId ${entry.messageId} is invalid here. Block IDs like bN are not allowed; use an mNNNN message ID instead.`, - ) + throw new SoftIssue("block-id", entry.messageId, "block ID used") } const messageId = state.messageIds.byRef.get(parsed.ref) const rawMessage = messageId ? searchContext.rawMessagesById.get(messageId) : undefined - const hasBoundary = - !!rawMessage && - !!messageId && - searchContext.rawIndexById.has(messageId) && - !isIgnoredUserMessage(rawMessage) - if (!hasBoundary) { - throw new SoftIssue( - `messageId ${parsed.ref} is not available in the current conversation context. Choose an injected mNNNN ID visible in context.`, - ) + if ( + !messageId || + !rawMessage || + !searchContext.rawIndexById.has(messageId) || + isIgnoredUserMessage(rawMessage) + ) { + throw new SoftIssue("not-in-context", parsed.ref, "not in context") } const { startReference, endReference } = resolveBoundaryIds( @@ -156,26 +228,14 @@ function resolveMessage( parsed.ref, ) const selection = resolveSelection(searchContext, startReference, endReference) - const rawMessageId = selection.messageIds[0] - - if (!rawMessageId) { - throw new Error(`messageId ${parsed.ref} could not be resolved to a raw message.`) - } - - const message = searchContext.rawMessagesById.get(rawMessageId) - if (!message) { - throw new Error(`messageId ${parsed.ref} is not available in the current conversation.`) - } - if (isProtectedUserMessage(config, message)) { - throw new SoftIssue( - `messageId ${parsed.ref} refers to a protected message and cannot be compressed.`, - ) + if (isProtectedUserMessage(config, rawMessage)) { + throw new SoftIssue("protected", parsed.ref, "protected message") } - const pruneEntry = state.prune.messages.byMessageId.get(rawMessageId) + const pruneEntry = state.prune.messages.byMessageId.get(messageId) if (pruneEntry && pruneEntry.activeBlockIds.length > 0) { - throw new Error(`messageId ${parsed.ref} is already part of an active compression.`) + throw new SoftIssue("already-compressed", parsed.ref, "already compressed") } return { diff --git a/lib/compress/message.ts b/lib/compress/message.ts index 74139857..07658a48 100644 --- a/lib/compress/message.ts +++ b/lib/compress/message.ts @@ -54,15 +54,15 @@ export function createCompressMessageTool(ctx: ToolContext): ReturnType 0) { - throw new Error(formatIssues(skippedIssues)) + if (plans.length === 0 && skippedCount > 0) { + throw new Error(formatIssues(skippedIssues, skippedCount)) } const notifications: NotificationEntry[] = [] @@ -126,7 +126,7 @@ export function createCompressMessageTool(ctx: ToolContext): ReturnType { + const sessionID = `ses_message_compress_reuse_${Date.now()}` + const rawMessages = buildMessages(sessionID) + const state = createSessionState() + const logger = new Logger(false) + const tool = createCompressMessageTool({ + client: { + session: { + messages: async () => ({ data: rawMessages }), + get: async () => ({ data: { parentID: null } }), + }, + }, + state, + logger, + config: buildConfig(), + prompts: { + reload() {}, + getRuntimePrompts() { + return { compressMessage: "", compressRange: "" } + }, + }, + } as any) + + await tool.execute( + { + topic: "First pass", + content: [ + { + messageId: "m0002", + topic: "Code path note", + summary: "Captured the assistant's code-path findings.", + }, + ], + }, + { + ask: async () => {}, + metadata: () => {}, + sessionID, + messageID: "msg-compress-message-first-pass", + }, + ) + + const result = await tool.execute( + { + topic: "Second pass", + content: [ + { + messageId: "m0002", + topic: "Already compressed note", + summary: "Should be skipped because it is already compressed.", + }, + { + messageId: "m0003", + topic: "Task output note", + summary: "Captured the assistant's task-backed follow-up.", + }, + ], + }, + { + ask: async () => {}, + metadata: () => {}, + sessionID, + messageID: "msg-compress-message-second-pass", + }, + ) + + assert.equal(state.prune.messages.blocksById.size, 2) + assert.match(result, /^Compressed 1 message into \[Compressed conversation section\]\./) + assert.match(result, /Skipped 1 issue:/) + assert.match(result, /messageId m0002 is already part of an active compression\./) +}) + test("compress message mode skips invalid batch entries and reports issues", async () => { const sessionID = `ses_message_compress_partial_${Date.now()}` const rawMessages = buildMessages(sessionID) From 44115bc47df984218532bac48bc30032ec4928a9 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 30 Mar 2026 21:43:53 -0400 Subject: [PATCH 12/33] style: format notification indentation --- lib/ui/notification.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts index 8c45becb..e65c070c 100644 --- a/lib/ui/notification.ts +++ b/lib/ui/notification.ts @@ -238,7 +238,7 @@ export async function sendCompressNotification( batchTopic ?? (entries.length === 1 ? (state.prune.messages.blocksById.get(entries[0]?.blockId ?? -1)?.topic ?? - "(unknown topic)") + "(unknown topic)") : "(unknown topic)") const totalActiveSummaryTkns = getActiveSummaryTokenUsage(state) @@ -317,9 +317,9 @@ export async function sendIgnoredMessage( const model = params.providerId && params.modelId ? { - providerID: params.providerId, - modelID: params.modelId, - } + providerID: params.providerId, + modelID: params.modelId, + } : undefined try { From 774bf965ba11cbc279b488d82ae1d9ed0d481e4d Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 30 Mar 2026 22:05:07 -0400 Subject: [PATCH 13/33] inject message IDs into every tool part instead of only the last --- lib/messages/inject/inject.ts | 4 ++-- lib/messages/utils.ts | 20 +++++--------------- tests/message-priority.test.ts | 14 +++++++------- 3 files changed, 14 insertions(+), 24 deletions(-) diff --git a/lib/messages/inject/inject.ts b/lib/messages/inject/inject.ts index d746cdcd..16599e45 100644 --- a/lib/messages/inject/inject.ts +++ b/lib/messages/inject/inject.ts @@ -15,7 +15,7 @@ import { saveSessionState } from "../../state/persistence" import { appendToTextPart, appendToLastTextPart, - appendToLastToolPart, + appendToAllToolParts, createSyntheticTextPart, hasContent, } from "../utils" @@ -196,7 +196,7 @@ export const injectMessageIds = ( continue } - if (appendToLastToolPart(message, tag)) { + if (appendToAllToolParts(message, tag)) { continue } diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts index cfbb9922..b4d2120a 100644 --- a/lib/messages/utils.ts +++ b/lib/messages/utils.ts @@ -107,24 +107,14 @@ export const appendToTextPart = (part: TextPart, injection: string): boolean => return true } -export const appendToLastToolPart = (message: WithParts, tag: string): boolean => { - const toolPart = findLastToolPart(message) - if (!toolPart) { - return false - } - - return appendToToolPart(toolPart, tag) -} - -const findLastToolPart = (message: WithParts): ToolPart | null => { - for (let i = message.parts.length - 1; i >= 0; i--) { - const part = message.parts[i] +export const appendToAllToolParts = (message: WithParts, tag: string): boolean => { + let injected = false + for (const part of message.parts) { if (part.type === "tool") { - return part + injected = appendToToolPart(part, tag) || injected } } - - return null + return injected } export const appendToToolPart = (part: ToolPart, tag: string): boolean => { diff --git a/tests/message-priority.test.ts b/tests/message-priority.test.ts index 31932095..748c2c2d 100644 --- a/tests/message-priority.test.ts +++ b/tests/message-priority.test.ts @@ -130,7 +130,7 @@ function repeatedWord(word: string, count: number): string { return Array.from({ length: count }, () => word).join(" ") } -test("injectMessageIds injects ID once into last tool output for assistant messages", () => { +test("injectMessageIds injects ID into every tool output for assistant messages", () => { const sessionID = "ses_message_priority_tags" const messages: WithParts[] = [ { @@ -220,9 +220,9 @@ test("injectMessageIds injects ID once into last tool output for assistant messa (userTextTwo as any).text, /\n\nm0001<\/dcp-message-id>/, ) - // Assistant messages: ID injected only once into the last tool output + // Assistant messages: ID injected into every tool output assert.doesNotMatch((assistantTextOne as any).text, /dcp-message-id/) - assert.doesNotMatch((assistantToolOne as any).state.output, /dcp-message-id/) + assert.match((assistantToolOne as any).state.output, /m0002<\/dcp-message-id>/) assert.doesNotMatch((assistantTextTwo as any).text, /dcp-message-id/) assert.match((assistantToolTwo as any).state.output, /m0002<\/dcp-message-id>/) }) @@ -280,7 +280,7 @@ test("injectMessageIds marks every protected user text part as BLOCKED in messag ) }) -test("injectMessageIds injects ID once into last tool output in range mode", () => { +test("injectMessageIds injects ID into every tool output in range mode", () => { const sessionID = "ses_range_message_id_tags" const messages: WithParts[] = [ buildMessage("msg-user-1", "user", sessionID, repeatedWord("investigate", 6000), 1), @@ -317,9 +317,9 @@ test("injectMessageIds injects ID once into last tool output in range mode", () const assistantTextTwo = messages[1]?.parts[2] const assistantToolTwo = messages[1]?.parts[3] - // Only the last tool output gets the ID + // Every tool output gets the ID assert.doesNotMatch((assistantTextOne as any).text, /dcp-message-id/) - assert.doesNotMatch((assistantToolOne as any).state.output, /dcp-message-id/) + assert.match((assistantToolOne as any).state.output, /m0002<\/dcp-message-id>/) assert.doesNotMatch((assistantTextTwo as any).text, /dcp-message-id/) assert.match((assistantToolTwo as any).state.output, /m0002<\/dcp-message-id>/) }) @@ -361,7 +361,7 @@ test("message mode marks compress tool messages as high priority even when short const assistantText = messages[1]?.parts[0] const assistantTool = messages[1]?.parts[1] - // ID injected only into the last (only) tool output, not the text part + // ID injected into tool output, not the text part assert.doesNotMatch((assistantText as any).text, /dcp-message-id/) assert.match((assistantTool as any).state.output, /m0002<\/dcp-message-id>/) assert.match( From 0745a4bfa3ac3c0083e1de7721e104c98a4d4189 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Mon, 30 Mar 2026 22:05:10 -0400 Subject: [PATCH 14/33] update(prompts): describe message ID tags on every tool part --- lib/prompts/compress-message.ts | 2 +- lib/prompts/compress-range.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/prompts/compress-message.ts b/lib/prompts/compress-message.ts index 007eb995..eea70d77 100644 --- a/lib/prompts/compress-message.ts +++ b/lib/prompts/compress-message.ts @@ -16,7 +16,7 @@ You specify individual raw messages by ID using the injected IDs visible in the - \`mNNNN\` IDs identify raw messages Each message has an ID inside XML metadata tags like \`m0007\`. -The ID tag appears at the end of the message it belongs to — each ID covers all the content above it back to the previous ID. +The same ID tag appears in every tool output of the message it belongs to — each unique ID identifies one complete message. Treat these tags as message metadata only, not as content to summarize. Use only the inner \`mNNNN\` value as the \`messageId\`. The \`priority\` attribute indicates relative context cost. When using the compress tool, if there are high-priority messages they MUST be compressed if all of their information is not vital to the task at hand. If there are previous messages with compress tool results, these MUST be compressed with a minimal summary. diff --git a/lib/prompts/compress-range.ts b/lib/prompts/compress-range.ts index a730d0a4..6e4c983a 100644 --- a/lib/prompts/compress-range.ts +++ b/lib/prompts/compress-range.ts @@ -45,7 +45,7 @@ You specify boundaries by ID using the injected IDs visible in the conversation: - \`bN\` IDs identify previously compressed blocks Each message has an ID inside XML metadata tags like \`...\`. -The ID tag appears at the end of the message it belongs to — each ID covers all the content above it back to the previous ID. +The same ID tag appears in every tool output of the message it belongs to — each unique ID identifies one complete message. Treat these tags as boundary metadata only, not as tool result content. Rules: From b5d608f96aed3c78a02bdbb0597a5f344145b85f Mon Sep 17 00:00:00 2001 From: Spoon <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:48:11 +0200 Subject: [PATCH 15/33] feat: add compression time to `/dcp stats` refactor refactor: natural time display fix: format check fix: stabilize compression timing correlation refactor: simplify compression timing fix: restore compression timing durability fix: attach compression timing to blocks fix: make compression timing robust --- index.ts | 2 + lib/commands/compression-targets.ts | 2 + lib/commands/stats.ts | 39 ++- lib/compress/message.ts | 6 + lib/compress/range.ts | 6 + lib/compress/state.ts | 37 +++ lib/compress/types.ts | 2 + lib/hooks.ts | 132 +++++++++- lib/state/state.ts | 4 +- lib/state/types.ts | 9 + lib/state/utils.ts | 6 + tests/compress-message.test.ts | 122 ++++++++++ tests/compression-targets.test.ts | 78 ++++++ tests/hooks-permission.test.ts | 357 ++++++++++++++++++++++++++++ 14 files changed, 799 insertions(+), 3 deletions(-) create mode 100644 tests/compression-targets.test.ts diff --git a/index.ts b/index.ts index 8bcd8e34..0e58d1db 100644 --- a/index.ts +++ b/index.ts @@ -13,6 +13,7 @@ import { createChatMessageHandler, createChatMessageTransformHandler, createCommandExecuteHandler, + createEventHandler, createSystemPromptHandler, createTextCompleteHandler, } from "./lib/hooks" @@ -68,6 +69,7 @@ const plugin: Plugin = (async (ctx) => { ) as any, "chat.message": createChatMessageHandler(state, logger, config, hostPermissions), "experimental.text.complete": createTextCompleteHandler(), + event: createEventHandler(state, logger), "command.execute.before": createCommandExecuteHandler( ctx.client, state, diff --git a/lib/commands/compression-targets.ts b/lib/commands/compression-targets.ts index 887ad53e..59bc2555 100644 --- a/lib/commands/compression-targets.ts +++ b/lib/commands/compression-targets.ts @@ -5,6 +5,7 @@ export interface CompressionTarget { runId: number topic: string compressedTokens: number + durationMs: number grouped: boolean blocks: CompressionBlock[] } @@ -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, } diff --git a/lib/commands/stats.ts b/lib/commands/stats.ts index 559c3859..86d0ea50 100644 --- a/lib/commands/stats.ts +++ b/lib/commands/stats.ts @@ -9,6 +9,7 @@ import { sendIgnoredMessage } from "../ui/notification" import { formatTokenCount } from "../ui/utils" import { loadAllSessionStats, type AggregatedStats } from "../state/persistence" import { getCurrentParams } from "../token-utils" +import { getActiveCompressionTargets } from "./compression-targets" export interface StatsCommandContext { client: any @@ -22,6 +23,7 @@ function formatStatsMessage( sessionTokens: number, sessionTools: number, sessionMessages: number, + sessionDurationMs: number, allTime: AggregatedStats, ): string { const lines: string[] = [] @@ -35,6 +37,7 @@ function formatStatsMessage( lines.push(` Tokens pruned: ~${formatTokenCount(sessionTokens)}`) lines.push(` Tools pruned: ${sessionTools}`) lines.push(` Messages pruned: ${sessionMessages}`) + lines.push(` Compression time: ${formatCompressionTime(sessionDurationMs)}`) lines.push("") lines.push("All-time:") lines.push("─".repeat(60)) @@ -46,11 +49,38 @@ function formatStatsMessage( return lines.join("\n") } +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 { const { client, state, logger, sessionId, messages } = ctx // Session stats from in-memory state const sessionTokens = state.stats.totalPruneTokens + const sessionDurationMs = getActiveCompressionTargets(state.prune.messages).reduce( + (total, target) => total + target.durationMs, + 0, + ) const prunedToolIds = new Set(state.prune.tools.keys()) for (const block of state.prune.messages.blocksById.values()) { @@ -72,7 +102,13 @@ export async function handleStatsCommand(ctx: StatsCommandContext): Promise { + const eventTime = + typeof input.event?.time === "number" && Number.isFinite(input.event.time) + ? input.event.time + : typeof input.event?.properties?.time === "number" && + Number.isFinite(input.event.properties.time) + ? input.event.properties.time + : undefined + + if (input.event.type !== "message.part.updated") { + return + } + + const part = input.event.properties?.part + if (part?.type !== "tool" || part.tool !== "compress") { + return + } + + if (part.state.status === "pending") { + if (typeof part.callID !== "string" || typeof part.messageID !== "string") { + return + } + + if (state.compressionStarts.has(part.callID)) { + return + } + + const startedAt = eventTime ?? Date.now() + state.compressionStarts.set(part.callID, { + messageId: part.messageID, + startedAt, + }) + logger.debug("Recorded compression start", { + callID: part.callID, + messageID: part.messageID, + startedAt, + }) + return + } + + if (part.state.status === "running") { + if (typeof part.callID !== "string") { + return + } + + const start = state.compressionStarts.get(part.callID) + if (!start) { + return + } + + const runningAt = + typeof part.state.time?.start === "number" && Number.isFinite(part.state.time.start) + ? part.state.time.start + : eventTime + if (typeof runningAt !== "number") { + return + } + + state.compressionStarts.delete(part.callID) + const durationMs = Math.max(0, runningAt - start.startedAt) + recordCompressionDuration(state, part.callID, durationMs) + + logger.info("Recorded compression time", { + callID: part.callID, + messageID: start.messageId, + durationMs, + }) + return + } + + if (part.state.status === "completed") { + if (typeof part.callID !== "string" || typeof part.messageID !== "string") { + return + } + + if (!state.compressionDurations.has(part.callID)) { + const start = state.compressionStarts.get(part.callID) + const runningAt = + typeof part.state.time?.start === "number" && + Number.isFinite(part.state.time.start) + ? part.state.time.start + : eventTime + + if (start && typeof runningAt === "number") { + state.compressionStarts.delete(part.callID) + const durationMs = Math.max(0, runningAt - start.startedAt) + recordCompressionDuration(state, part.callID, durationMs) + } else { + const toolStart = part.state.time?.start + const toolEnd = part.state.time?.end + if ( + typeof toolStart === "number" && + Number.isFinite(toolStart) && + typeof toolEnd === "number" && + Number.isFinite(toolEnd) + ) { + const durationMs = Math.max(0, toolEnd - toolStart) + recordCompressionDuration(state, part.callID, durationMs) + } + } + } + + const updates = attachCompressionDuration(state, part.callID, part.messageID) + if (updates === 0) { + return + } + + logger.info("Attached compression time to blocks", { + callID: part.callID, + messageID: part.messageID, + blocks: updates, + }) + + saveSessionState(state, logger).catch((error) => { + logger.warn("Failed to persist compression time update", { + error: error instanceof Error ? error.message : String(error), + }) + }) + return + } + + if (typeof part.callID === "string") { + state.compressionStarts.delete(part.callID) + state.compressionDurations.delete(part.callID) + } + } +} + export function createChatMessageHandler( state: SessionState, logger: Logger, diff --git a/lib/state/state.ts b/lib/state/state.ts index c8a00ba1..18d0d4f1 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -1,4 +1,4 @@ -import type { SessionState, ToolParameterEntry, WithParts } from "./types" +import type { CompressionStart, SessionState, ToolParameterEntry, WithParts } from "./types" import type { Logger } from "../logger" import { loadSessionState, saveSessionState } from "./persistence" import { @@ -81,6 +81,8 @@ export function createSessionState(): SessionState { pruneTokenCounter: 0, totalPruneTokens: 0, }, + compressionStarts: new Map(), + compressionDurations: new Map(), toolParameters: new Map(), subAgentResultCache: new Map(), toolIdList: [], diff --git a/lib/state/types.ts b/lib/state/types.ts index 67f1e9e5..0580529a 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -21,6 +21,11 @@ export interface SessionStats { totalPruneTokens: number } +export interface CompressionStart { + messageId: string + startedAt: number +} + export interface PrunedMessageEntry { tokenCount: number allBlockIds: number[] @@ -36,6 +41,7 @@ export interface CompressionBlock { deactivatedByUser: boolean compressedTokens: number summaryTokens: number + durationMs: number mode?: CompressionMode topic: string batchTopic?: string @@ -43,6 +49,7 @@ export interface CompressionBlock { endId: string anchorMessageId: string compressMessageId: string + compressCallId?: string includedBlockIds: number[] consumedBlockIds: number[] parentBlockIds: number[] @@ -96,6 +103,8 @@ export interface SessionState { prune: Prune nudges: Nudges stats: SessionStats + compressionStarts: Map + compressionDurations: Map toolParameters: Map subAgentResultCache: Map toolIdList: string[] diff --git a/lib/state/utils.ts b/lib/state/utils.ts index fcaaa290..953e03fe 100644 --- a/lib/state/utils.ts +++ b/lib/state/utils.ts @@ -178,6 +178,10 @@ export function loadPruneMessagesState( : typeof block.summary === "string" ? countTokens(block.summary) : 0, + durationMs: + typeof block.durationMs === "number" && Number.isFinite(block.durationMs) + ? Math.max(0, block.durationMs) + : 0, mode: block.mode === "range" || block.mode === "message" ? block.mode : undefined, topic: typeof block.topic === "string" ? block.topic : "", batchTopic: @@ -192,6 +196,8 @@ export function loadPruneMessagesState( typeof block.anchorMessageId === "string" ? block.anchorMessageId : "", compressMessageId: typeof block.compressMessageId === "string" ? block.compressMessageId : "", + compressCallId: + typeof block.compressCallId === "string" ? block.compressCallId : undefined, includedBlockIds: toNumberArray(block.includedBlockIds), consumedBlockIds: toNumberArray(block.consumedBlockIds), parentBlockIds: toNumberArray(block.parentBlockIds), diff --git a/tests/compress-message.test.ts b/tests/compress-message.test.ts index 18c6e410..a590c3ec 100644 --- a/tests/compress-message.test.ts +++ b/tests/compress-message.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path" import { tmpdir } from "node:os" import { mkdirSync } from "node:fs" import { createCompressMessageTool } from "../lib/compress/message" +import { createEventHandler } from "../lib/hooks" import { createSessionState, type WithParts } from "../lib/state" import type { PluginConfig } from "../lib/config" import { Logger } from "../lib/logger" @@ -226,6 +227,127 @@ test("compress message mode batches individual message summaries", async () => { assert.match(blocks[1]?.summary || "", /task output body/) }) +test("compress message mode stores call id for later duration attachment", async () => { + const sessionID = `ses_message_compress_duration_${Date.now()}` + const rawMessages = buildMessages(sessionID) + const state = createSessionState() + const logger = new Logger(false) + const handler = createEventHandler(state, logger) + const originalNow = Date.now + Date.now = () => 100 + + try { + await handler({ + event: { + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "compress", + callID: "call-1", + messageID: "msg-compress-message", + sessionID, + state: { + status: "pending", + input: {}, + raw: "", + }, + }, + }, + }, + }) + + await handler({ + event: { + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "compress", + callID: "call-1", + messageID: "msg-compress-message", + sessionID, + state: { + status: "running", + input: {}, + time: { start: 325 }, + }, + }, + }, + }, + }) + + const tool = createCompressMessageTool({ + client: { + session: { + messages: async () => ({ data: rawMessages }), + get: async () => ({ data: { parentID: null } }), + }, + }, + state, + logger, + config: buildConfig(), + prompts: { + reload() {}, + getRuntimePrompts() { + return { compressMessage: "", compressRange: "" } + }, + }, + } as any) + + await tool.execute( + { + topic: "Batch stale notes", + content: [ + { + messageId: "m0002", + topic: "Code path note", + summary: "Captured the assistant's code-path findings.", + }, + ], + }, + { + ask: async () => {}, + metadata: () => {}, + sessionID, + messageID: "msg-compress-message", + callID: "call-1", + }, + ) + + const block = Array.from(state.prune.messages.blocksById.values())[0] + assert.equal(block?.compressCallId, "call-1") + assert.equal(block?.durationMs, 0) + + await handler({ + event: { + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "compress", + callID: "call-1", + messageID: "msg-compress-message", + sessionID, + state: { + status: "completed", + input: {}, + output: "done", + title: "", + metadata: {}, + time: { start: 325, end: 400 }, + }, + }, + }, + }, + }) + + assert.equal(block?.durationMs, 225) + } finally { + Date.now = originalNow + } +}) + test("compress message mode does not partially apply when preparation fails", async () => { const sessionID = `ses_message_compress_prepare_fail_${Date.now()}` const rawMessages = buildMessages(sessionID) diff --git a/tests/compression-targets.test.ts b/tests/compression-targets.test.ts new file mode 100644 index 00000000..77027af1 --- /dev/null +++ b/tests/compression-targets.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { getActiveCompressionTargets } from "../lib/commands/compression-targets" +import { createSessionState, type CompressionBlock } from "../lib/state" + +function buildBlock( + blockId: number, + runId: number, + mode: "range" | "message", + durationMs: number, +): CompressionBlock { + return { + blockId, + runId, + active: true, + deactivatedByUser: false, + compressedTokens: 10, + summaryTokens: 5, + durationMs, + mode, + topic: `topic-${blockId}`, + batchTopic: mode === "message" ? `batch-${runId}` : `topic-${blockId}`, + startId: `m${blockId}`, + endId: `m${blockId}`, + anchorMessageId: `msg-${blockId}`, + compressMessageId: `origin-${runId}`, + includedBlockIds: [], + consumedBlockIds: [], + parentBlockIds: [], + directMessageIds: [`msg-${blockId}`], + directToolIds: [], + effectiveMessageIds: [`msg-${blockId}`], + effectiveToolIds: [], + createdAt: blockId, + summary: `summary-${blockId}`, + } +} + +test("active compression targets count a grouped message run once", () => { + const state = createSessionState() + const first = buildBlock(1, 10, "message", 225) + const second = buildBlock(2, 10, "message", 225) + const third = buildBlock(3, 11, "range", 80) + + state.prune.messages.blocksById.set(1, first) + state.prune.messages.blocksById.set(2, second) + state.prune.messages.blocksById.set(3, third) + state.prune.messages.activeBlockIds.add(1) + state.prune.messages.activeBlockIds.add(2) + state.prune.messages.activeBlockIds.add(3) + + const targets = getActiveCompressionTargets(state.prune.messages) + const totalDurationMs = targets.reduce((total, target) => total + target.durationMs, 0) + + assert.equal(targets.length, 2) + assert.equal(totalDurationMs, 305) +}) + +test("inactive grouped message runs no longer contribute compression time", () => { + const state = createSessionState() + const first = buildBlock(1, 10, "message", 225) + const second = buildBlock(2, 10, "message", 225) + const third = buildBlock(3, 11, "range", 80) + + first.active = false + second.active = false + + state.prune.messages.blocksById.set(1, first) + state.prune.messages.blocksById.set(2, second) + state.prune.messages.blocksById.set(3, third) + state.prune.messages.activeBlockIds.add(3) + + const targets = getActiveCompressionTargets(state.prune.messages) + const totalDurationMs = targets.reduce((total, target) => total + target.durationMs, 0) + + assert.equal(targets.length, 1) + assert.equal(totalDurationMs, 80) +}) diff --git a/tests/hooks-permission.test.ts b/tests/hooks-permission.test.ts index 9a13b7ba..e855ca84 100644 --- a/tests/hooks-permission.test.ts +++ b/tests/hooks-permission.test.ts @@ -5,6 +5,7 @@ import { createChatMessageHandler, createChatMessageTransformHandler, createCommandExecuteHandler, + createEventHandler, createTextCompleteHandler, } from "../lib/hooks" import { Logger } from "../lib/logger" @@ -152,3 +153,359 @@ test("text complete strips hallucinated metadata tags", async () => { assert.equal(output.text, "alpha omega") }) + +test("event hook records compress input generation duration", async () => { + const state = createSessionState() + state.sessionId = "session-1" + const handler = createEventHandler(state, new Logger(false)) + const originalNow = Date.now + Date.now = () => 100 + + try { + await handler({ + event: { + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "compress", + callID: "call-1", + messageID: "message-1", + sessionID: "session-1", + state: { + status: "pending", + input: {}, + raw: "", + }, + }, + }, + }, + }) + + await handler({ + event: { + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "compress", + callID: "call-1", + messageID: "message-1", + sessionID: "session-1", + state: { + status: "running", + input: { topic: "x" }, + time: { start: 325 }, + }, + }, + }, + }, + }) + } finally { + Date.now = originalNow + } + + assert.equal(state.compressionDurations.get("call-1"), 225) + assert.equal(state.compressionStarts.has("call-1"), false) +}) + +test("event hook attaches durations to matching blocks by call id", async () => { + const state = createSessionState() + state.sessionId = "session-1" + const handler = createEventHandler(state, new Logger(false)) + const originalNow = Date.now + Date.now = () => 100 + + try { + await handler({ + event: { + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "compress", + callID: "call-1", + messageID: "message-1", + sessionID: "session-1", + state: { + status: "pending", + input: {}, + raw: "", + }, + }, + }, + }, + }) + + await handler({ + event: { + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "compress", + callID: "call-2", + messageID: "message-1", + sessionID: "session-1", + state: { + status: "pending", + input: {}, + raw: "", + }, + }, + }, + }, + }) + + await handler({ + event: { + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "compress", + callID: "call-1", + messageID: "message-1", + sessionID: "session-1", + state: { + status: "running", + input: {}, + time: { start: 325 }, + }, + }, + }, + }, + }) + + await handler({ + event: { + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "compress", + callID: "call-2", + messageID: "message-1", + sessionID: "session-1", + state: { + status: "running", + input: {}, + time: { start: 410 }, + }, + }, + }, + }, + }) + state.prune.messages.blocksById.set(1, { + blockId: 1, + runId: 1, + active: true, + deactivatedByUser: false, + compressedTokens: 0, + summaryTokens: 0, + durationMs: 0, + mode: "message", + topic: "one", + batchTopic: "one", + startId: "m0001", + endId: "m0001", + anchorMessageId: "msg-a", + compressMessageId: "message-1", + compressCallId: "call-1", + includedBlockIds: [], + consumedBlockIds: [], + parentBlockIds: [], + directMessageIds: [], + directToolIds: [], + effectiveMessageIds: ["msg-a"], + effectiveToolIds: [], + createdAt: 1, + summary: "a", + }) + state.prune.messages.blocksById.set(2, { + blockId: 2, + runId: 2, + active: true, + deactivatedByUser: false, + compressedTokens: 0, + summaryTokens: 0, + durationMs: 0, + mode: "message", + topic: "two", + batchTopic: "two", + startId: "m0002", + endId: "m0002", + anchorMessageId: "msg-b", + compressMessageId: "message-1", + compressCallId: "call-2", + includedBlockIds: [], + consumedBlockIds: [], + parentBlockIds: [], + directMessageIds: [], + directToolIds: [], + effectiveMessageIds: ["msg-b"], + effectiveToolIds: [], + createdAt: 2, + summary: "b", + }) + + await handler({ + event: { + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "compress", + callID: "call-2", + messageID: "message-1", + sessionID: "session-1", + state: { + status: "completed", + input: {}, + output: "done", + title: "", + metadata: {}, + time: { start: 410, end: 500 }, + }, + }, + }, + }, + }) + + await handler({ + event: { + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "compress", + callID: "call-1", + messageID: "message-1", + sessionID: "session-1", + state: { + status: "completed", + input: {}, + output: "done", + title: "", + metadata: {}, + time: { start: 325, end: 500 }, + }, + }, + }, + }, + }) + } finally { + Date.now = originalNow + } + + assert.equal(state.prune.messages.blocksById.get(1)?.durationMs, 225) + assert.equal(state.prune.messages.blocksById.get(2)?.durationMs, 310) + assert.equal(state.compressionDurations.size, 0) +}) + +test("event hook falls back to completed runtime when running duration missing", async () => { + const state = createSessionState() + state.sessionId = "session-1" + const handler = createEventHandler(state, new Logger(false)) + + state.prune.messages.blocksById.set(1, { + blockId: 1, + runId: 1, + active: true, + deactivatedByUser: false, + compressedTokens: 0, + summaryTokens: 0, + durationMs: 0, + mode: "message", + topic: "one", + batchTopic: "one", + startId: "m0001", + endId: "m0001", + anchorMessageId: "msg-a", + compressMessageId: "message-1", + compressCallId: undefined, + includedBlockIds: [], + consumedBlockIds: [], + parentBlockIds: [], + directMessageIds: [], + directToolIds: [], + effectiveMessageIds: ["msg-a"], + effectiveToolIds: [], + createdAt: 1, + summary: "a", + }) + + await handler({ + event: { + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "compress", + callID: "call-3", + messageID: "message-1", + sessionID: "session-1", + state: { + status: "completed", + input: {}, + output: "done", + title: "", + metadata: {}, + time: { start: 500, end: 940 }, + }, + }, + }, + }, + }) + + assert.equal(state.prune.messages.blocksById.get(1)?.durationMs, 440) + assert.equal(state.compressionDurations.size, 0) +}) + +test("event hook ignores non-compress tool parts", async () => { + const state = createSessionState() + state.sessionId = "session-1" + const handler = createEventHandler(state, new Logger(false)) + + await handler({ + event: { + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "bash", + callID: "call-2", + messageID: "message-2", + sessionID: "session-1", + state: { + status: "pending", + input: {}, + raw: "", + }, + }, + }, + }, + }) + + await handler({ + event: { + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "bash", + callID: "call-2", + messageID: "message-2", + sessionID: "session-1", + state: { + status: "running", + input: {}, + time: { start: 220 }, + }, + }, + }, + }, + }) + + assert.equal(state.compressionDurations.size, 0) +}) From b55b57c9e2a8e15e3b96f0c9707fe4d467adcf9f Mon Sep 17 00:00:00 2001 From: Spoon <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Tue, 31 Mar 2026 06:12:09 +0200 Subject: [PATCH 16/33] trim --- lib/compress/message.ts | 1 - lib/compress/range.ts | 1 - lib/compress/state.ts | 14 +----- lib/compress/types.ts | 1 - lib/hooks.ts | 84 +++++++++++++--------------------- lib/state/state.ts | 1 - lib/state/types.ts | 1 - tests/hooks-permission.test.ts | 12 ++--- 8 files changed, 40 insertions(+), 75 deletions(-) diff --git a/lib/compress/message.ts b/lib/compress/message.ts index 905aa7e0..5d81161e 100644 --- a/lib/compress/message.ts +++ b/lib/compress/message.ts @@ -113,7 +113,6 @@ export function createCompressMessageTool(ctx: ToolContext): ReturnType { @@ -389,9 +366,12 @@ export function createEventHandler(state: SessionState, logger: Logger) { return } + if (part.state.status === "running") { + return + } + if (typeof part.callID === "string") { state.compressionStarts.delete(part.callID) - state.compressionDurations.delete(part.callID) } } } diff --git a/lib/state/state.ts b/lib/state/state.ts index 18d0d4f1..97681d6a 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -82,7 +82,6 @@ export function createSessionState(): SessionState { totalPruneTokens: 0, }, compressionStarts: new Map(), - compressionDurations: new Map(), toolParameters: new Map(), subAgentResultCache: new Map(), toolIdList: [], diff --git a/lib/state/types.ts b/lib/state/types.ts index 0580529a..5ec73f78 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -104,7 +104,6 @@ export interface SessionState { nudges: Nudges stats: SessionStats compressionStarts: Map - compressionDurations: Map toolParameters: Map subAgentResultCache: Map toolIdList: string[] diff --git a/tests/hooks-permission.test.ts b/tests/hooks-permission.test.ts index e855ca84..21fb94b4 100644 --- a/tests/hooks-permission.test.ts +++ b/tests/hooks-permission.test.ts @@ -154,7 +154,7 @@ test("text complete strips hallucinated metadata tags", async () => { assert.equal(output.text, "alpha omega") }) -test("event hook records compress input generation duration", async () => { +test("event hook records compression start timing", async () => { const state = createSessionState() state.sessionId = "session-1" const handler = createEventHandler(state, new Logger(false)) @@ -205,8 +205,10 @@ test("event hook records compress input generation duration", async () => { Date.now = originalNow } - assert.equal(state.compressionDurations.get("call-1"), 225) - assert.equal(state.compressionStarts.has("call-1"), false) + assert.deepEqual(state.compressionStarts.get("call-1"), { + messageId: "message-1", + startedAt: 100, + }) }) test("event hook attaches durations to matching blocks by call id", async () => { @@ -400,7 +402,6 @@ test("event hook attaches durations to matching blocks by call id", async () => assert.equal(state.prune.messages.blocksById.get(1)?.durationMs, 225) assert.equal(state.prune.messages.blocksById.get(2)?.durationMs, 310) - assert.equal(state.compressionDurations.size, 0) }) test("event hook falls back to completed runtime when running duration missing", async () => { @@ -459,7 +460,6 @@ test("event hook falls back to completed runtime when running duration missing", }) assert.equal(state.prune.messages.blocksById.get(1)?.durationMs, 440) - assert.equal(state.compressionDurations.size, 0) }) test("event hook ignores non-compress tool parts", async () => { @@ -507,5 +507,5 @@ test("event hook ignores non-compress tool parts", async () => { }, }) - assert.equal(state.compressionDurations.size, 0) + assert.equal(state.compressionStarts.size, 0) }) From 5182362c04740cb6d1f89d85f65c35c19ccaa5b3 Mon Sep 17 00:00:00 2001 From: Spoon <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Tue, 31 Mar 2026 06:16:39 +0200 Subject: [PATCH 17/33] feat: add compression summary tokens amount to `dcp stats` --- lib/commands/stats.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/commands/stats.ts b/lib/commands/stats.ts index 86d0ea50..b934cda4 100644 --- a/lib/commands/stats.ts +++ b/lib/commands/stats.ts @@ -23,6 +23,7 @@ function formatStatsMessage( sessionTokens: number, sessionTools: number, sessionMessages: number, + sessionSummaryTokens: number, sessionDurationMs: number, allTime: AggregatedStats, ): string { @@ -37,6 +38,7 @@ function formatStatsMessage( lines.push(` Tokens pruned: ~${formatTokenCount(sessionTokens)}`) lines.push(` Tools pruned: ${sessionTools}`) lines.push(` Messages pruned: ${sessionMessages}`) + lines.push(` Summary tokens: ~${formatTokenCount(sessionSummaryTokens)}`) lines.push(` Compression time: ${formatCompressionTime(sessionDurationMs)}`) lines.push("") lines.push("All-time:") @@ -77,6 +79,10 @@ export async function handleStatsCommand(ctx: StatsCommandContext): Promise (block.active ? total + block.summaryTokens : total), + 0, + ) const sessionDurationMs = getActiveCompressionTargets(state.prune.messages).reduce( (total, target) => total + target.durationMs, 0, @@ -106,6 +112,7 @@ export async function handleStatsCommand(ctx: StatsCommandContext): Promise Date: Tue, 31 Mar 2026 06:22:20 +0200 Subject: [PATCH 18/33] rm test noise --- tests/compress-message.test.ts | 145 ++++++++------------------------- tests/hooks-permission.test.ts | 105 ------------------------ 2 files changed, 36 insertions(+), 214 deletions(-) diff --git a/tests/compress-message.test.ts b/tests/compress-message.test.ts index a590c3ec..ad0ab394 100644 --- a/tests/compress-message.test.ts +++ b/tests/compress-message.test.ts @@ -4,7 +4,6 @@ import { join } from "node:path" import { tmpdir } from "node:os" import { mkdirSync } from "node:fs" import { createCompressMessageTool } from "../lib/compress/message" -import { createEventHandler } from "../lib/hooks" import { createSessionState, type WithParts } from "../lib/state" import type { PluginConfig } from "../lib/config" import { Logger } from "../lib/logger" @@ -232,120 +231,48 @@ test("compress message mode stores call id for later duration attachment", async const rawMessages = buildMessages(sessionID) const state = createSessionState() const logger = new Logger(false) - const handler = createEventHandler(state, logger) - const originalNow = Date.now - Date.now = () => 100 - - try { - await handler({ - event: { - type: "message.part.updated", - properties: { - part: { - type: "tool", - tool: "compress", - callID: "call-1", - messageID: "msg-compress-message", - sessionID, - state: { - status: "pending", - input: {}, - raw: "", - }, - }, - }, - }, - }) - - await handler({ - event: { - type: "message.part.updated", - properties: { - part: { - type: "tool", - tool: "compress", - callID: "call-1", - messageID: "msg-compress-message", - sessionID, - state: { - status: "running", - input: {}, - time: { start: 325 }, - }, - }, - }, - }, - }) - const tool = createCompressMessageTool({ - client: { - session: { - messages: async () => ({ data: rawMessages }), - get: async () => ({ data: { parentID: null } }), - }, + const tool = createCompressMessageTool({ + client: { + session: { + messages: async () => ({ data: rawMessages }), + get: async () => ({ data: { parentID: null } }), }, - state, - logger, - config: buildConfig(), - prompts: { - reload() {}, - getRuntimePrompts() { - return { compressMessage: "", compressRange: "" } - }, + }, + state, + logger, + config: buildConfig(), + prompts: { + reload() {}, + getRuntimePrompts() { + return { compressMessage: "", compressRange: "" } }, - } as any) + }, + } as any) - await tool.execute( - { - topic: "Batch stale notes", - content: [ - { - messageId: "m0002", - topic: "Code path note", - summary: "Captured the assistant's code-path findings.", - }, - ], - }, - { - ask: async () => {}, - metadata: () => {}, - sessionID, - messageID: "msg-compress-message", - callID: "call-1", - }, - ) - - const block = Array.from(state.prune.messages.blocksById.values())[0] - assert.equal(block?.compressCallId, "call-1") - assert.equal(block?.durationMs, 0) - - await handler({ - event: { - type: "message.part.updated", - properties: { - part: { - type: "tool", - tool: "compress", - callID: "call-1", - messageID: "msg-compress-message", - sessionID, - state: { - status: "completed", - input: {}, - output: "done", - title: "", - metadata: {}, - time: { start: 325, end: 400 }, - }, - }, + await tool.execute( + { + topic: "Batch stale notes", + content: [ + { + messageId: "m0002", + topic: "Code path note", + summary: "Captured the assistant's code-path findings.", }, - }, - }) + ], + }, + { + ask: async () => {}, + metadata: () => {}, + sessionID, + messageID: "msg-compress-message", + callID: "call-1", + }, + ) - assert.equal(block?.durationMs, 225) - } finally { - Date.now = originalNow - } + const block = Array.from(state.prune.messages.blocksById.values())[0] + assert.equal(block?.compressCallId, "call-1") + assert.equal(block?.durationMs, 0) }) test("compress message mode does not partially apply when preparation fails", async () => { diff --git a/tests/hooks-permission.test.ts b/tests/hooks-permission.test.ts index 21fb94b4..8860511c 100644 --- a/tests/hooks-permission.test.ts +++ b/tests/hooks-permission.test.ts @@ -154,63 +154,6 @@ test("text complete strips hallucinated metadata tags", async () => { assert.equal(output.text, "alpha omega") }) -test("event hook records compression start timing", async () => { - const state = createSessionState() - state.sessionId = "session-1" - const handler = createEventHandler(state, new Logger(false)) - const originalNow = Date.now - Date.now = () => 100 - - try { - await handler({ - event: { - type: "message.part.updated", - properties: { - part: { - type: "tool", - tool: "compress", - callID: "call-1", - messageID: "message-1", - sessionID: "session-1", - state: { - status: "pending", - input: {}, - raw: "", - }, - }, - }, - }, - }) - - await handler({ - event: { - type: "message.part.updated", - properties: { - part: { - type: "tool", - tool: "compress", - callID: "call-1", - messageID: "message-1", - sessionID: "session-1", - state: { - status: "running", - input: { topic: "x" }, - time: { start: 325 }, - }, - }, - }, - }, - }) - } finally { - Date.now = originalNow - } - - assert.deepEqual(state.compressionStarts.get("call-1"), { - messageId: "message-1", - startedAt: 100, - }) -}) - test("event hook attaches durations to matching blocks by call id", async () => { const state = createSessionState() state.sessionId = "session-1" @@ -461,51 +404,3 @@ test("event hook falls back to completed runtime when running duration missing", assert.equal(state.prune.messages.blocksById.get(1)?.durationMs, 440) }) - -test("event hook ignores non-compress tool parts", async () => { - const state = createSessionState() - state.sessionId = "session-1" - const handler = createEventHandler(state, new Logger(false)) - - await handler({ - event: { - type: "message.part.updated", - properties: { - part: { - type: "tool", - tool: "bash", - callID: "call-2", - messageID: "message-2", - sessionID: "session-1", - state: { - status: "pending", - input: {}, - raw: "", - }, - }, - }, - }, - }) - - await handler({ - event: { - type: "message.part.updated", - properties: { - part: { - type: "tool", - tool: "bash", - callID: "call-2", - messageID: "message-2", - sessionID: "session-1", - state: { - status: "running", - input: {}, - time: { start: 220 }, - }, - }, - }, - }, - }) - - assert.equal(state.compressionStarts.size, 0) -}) From 37ed1dd09cb983d23caeaee126add3ebaa1aad70 Mon Sep 17 00:00:00 2001 From: Spoon <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Tue, 31 Mar 2026 07:02:13 +0200 Subject: [PATCH 19/33] stats changes --- lib/commands/stats.ts | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/lib/commands/stats.ts b/lib/commands/stats.ts index b934cda4..bea2a6dc 100644 --- a/lib/commands/stats.ts +++ b/lib/commands/stats.ts @@ -21,9 +21,9 @@ export interface StatsCommandContext { function formatStatsMessage( sessionTokens: number, + sessionSummaryTokens: number, sessionTools: number, sessionMessages: number, - sessionSummaryTokens: number, sessionDurationMs: number, allTime: AggregatedStats, ): string { @@ -33,13 +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(` Summary tokens: ~${formatTokenCount(sessionSummaryTokens)}`) - lines.push(` Compression time: ${formatCompressionTime(sessionDurationMs)}`) + 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)) @@ -51,6 +53,19 @@ 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) { @@ -110,9 +125,9 @@ export async function handleStatsCommand(ctx: StatsCommandContext): Promise Date: Tue, 31 Mar 2026 16:07:46 -0400 Subject: [PATCH 20/33] fix: track compression durations across sessions --- lib/compress/state.ts | 25 ------- lib/hooks.ts | 61 +++++++++++------ lib/state/persistence.ts | 44 ++++++------ lib/state/state.ts | 72 +++++++++++++++++++- lib/state/types.ts | 14 +++- lib/state/utils.ts | 55 +++++++++++++-- tests/hooks-permission.test.ts | 120 ++++++++++++++++++++++++++++++++- 7 files changed, 311 insertions(+), 80 deletions(-) diff --git a/lib/compress/state.ts b/lib/compress/state.ts index e8386d9f..15b59e39 100644 --- a/lib/compress/state.ts +++ b/lib/compress/state.ts @@ -26,31 +26,6 @@ export function allocateRunId(state: SessionState): number { return next } -export function attachCompressionDuration( - state: SessionState, - callId: string, - messageId: string, - durationMs: number, -): number { - if (typeof durationMs !== "number" || !Number.isFinite(durationMs)) { - return 0 - } - - let updates = 0 - for (const block of state.prune.messages.blocksById.values()) { - const matchesCall = block.compressCallId === callId - const matchesMessage = !block.compressCallId && block.compressMessageId === messageId - if (!matchesCall && !matchesMessage) { - continue - } - - block.durationMs = durationMs - updates++ - } - - return updates -} - export function wrapCompressedSummary(blockId: number, summary: string): string { const header = COMPRESSED_BLOCK_HEADER const footer = formatMessageIdTag(formatBlockRef(blockId)) diff --git a/lib/hooks.ts b/lib/hooks.ts index 8c590e5b..a954cdb3 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -16,7 +16,6 @@ import { } from "./messages" import { renderSystemPrompt, type PromptStore } from "./prompts" import { buildProtectedToolsExtension } from "./prompts/extensions/system" -import { attachCompressionDuration } from "./compress/state" import { applyPendingManualTrigger, handleContextCommand, @@ -30,7 +29,14 @@ import { } from "./commands" import { type HostPermissionSnapshot } from "./host-permissions" import { compressPermission, syncCompressPermissionState } from "./compress-permission" -import { checkSession, ensureSessionInitialized, saveSessionState, syncToolCache } from "./state" +import { + checkSession, + ensureSessionInitialized, + applyPendingCompressionDurations, + queueCompressionDuration, + saveSessionState, + syncToolCache, +} from "./state" import { cacheSystemPromptTokens } from "./ui/utils" const INTERNAL_AGENT_SIGNATURES = [ @@ -269,6 +275,12 @@ export function createTextCompleteHandler() { export function createEventHandler(state: SessionState, logger: Logger) { return async (input: { event: any }) => { + const eventSessionId = + typeof input.event?.properties?.sessionID === "string" + ? input.event.properties.sessionID + : typeof input.event?.properties?.part?.sessionID === "string" + ? input.event.properties.part.sessionID + : undefined const eventTime = typeof input.event?.time === "number" && Number.isFinite(input.event.time) ? input.event.time @@ -287,20 +299,26 @@ export function createEventHandler(state: SessionState, logger: Logger) { } if (part.state.status === "pending") { - if (typeof part.callID !== "string" || typeof part.messageID !== "string") { + if ( + typeof part.callID !== "string" || + typeof part.messageID !== "string" || + typeof eventSessionId !== "string" + ) { return } - if (state.compressionStarts.has(part.callID)) { + if (state.compressionTiming.startsByCallId.has(part.callID)) { return } const startedAt = eventTime ?? Date.now() - state.compressionStarts.set(part.callID, { + state.compressionTiming.startsByCallId.set(part.callID, { + sessionId: eventSessionId, messageId: part.messageID, startedAt, }) logger.debug("Recorded compression start", { + sessionID: eventSessionId, callID: part.callID, messageID: part.messageID, startedAt, @@ -309,12 +327,16 @@ export function createEventHandler(state: SessionState, logger: Logger) { } if (part.state.status === "completed") { - if (typeof part.callID !== "string" || typeof part.messageID !== "string") { + if ( + typeof part.callID !== "string" || + typeof part.messageID !== "string" || + typeof eventSessionId !== "string" + ) { return } - const start = state.compressionStarts.get(part.callID) - state.compressionStarts.delete(part.callID) + const start = state.compressionTiming.startsByCallId.get(part.callID) + state.compressionTiming.startsByCallId.delete(part.callID) const runningAt = typeof part.state.time?.start === "number" && Number.isFinite(part.state.time.start) @@ -341,28 +363,25 @@ export function createEventHandler(state: SessionState, logger: Logger) { return } - const updates = attachCompressionDuration( - state, - part.callID, - part.messageID, - durationMs, - ) + queueCompressionDuration(state, eventSessionId, part.callID, part.messageID, durationMs) + + const updates = + state.sessionId === eventSessionId + ? applyPendingCompressionDurations(state, eventSessionId) + : 0 if (updates === 0) { return } + await saveSessionState(state, logger) + logger.info("Attached compression time to blocks", { + sessionID: eventSessionId, callID: part.callID, messageID: part.messageID, blocks: updates, durationMs, }) - - saveSessionState(state, logger).catch((error) => { - logger.warn("Failed to persist compression time update", { - error: error instanceof Error ? error.message : String(error), - }) - }) return } @@ -371,7 +390,7 @@ export function createEventHandler(state: SessionState, logger: Logger) { } if (typeof part.callID === "string") { - state.compressionStarts.delete(part.callID) + state.compressionTiming.startsByCallId.delete(part.callID) } } } diff --git a/lib/state/persistence.ts b/lib/state/persistence.ts index 2431eee3..87b774f9 100644 --- a/lib/state/persistence.ts +++ b/lib/state/persistence.ts @@ -10,6 +10,7 @@ import { homedir } from "os" import { join } from "path" import type { CompressionBlock, PrunedMessageEntry, SessionState, SessionStats } from "./types" import type { Logger } from "../logger" +import { serializePruneMessagesState } from "./utils" /** Prune state as stored on disk */ export interface PersistedPruneMessagesState { @@ -58,6 +59,23 @@ function getSessionFilePath(sessionId: string): string { return join(STORAGE_DIR, `${sessionId}.json`) } +async function writePersistedSessionState( + sessionId: string, + state: PersistedSessionState, + logger: Logger, +): Promise { + await ensureStorageDir() + + const filePath = getSessionFilePath(sessionId) + const content = JSON.stringify(state, null, 2) + await fs.writeFile(filePath, content, "utf-8") + + logger.info("Saved session state to disk", { + sessionId, + totalTokensSaved: state.stats.totalPruneTokens, + }) +} + export async function saveSessionState( sessionState: SessionState, logger: Logger, @@ -68,26 +86,11 @@ export async function saveSessionState( return } - await ensureStorageDir() - const state: PersistedSessionState = { sessionName: sessionName, prune: { tools: Object.fromEntries(sessionState.prune.tools), - messages: { - byMessageId: Object.fromEntries(sessionState.prune.messages.byMessageId), - blocksById: Object.fromEntries( - Array.from(sessionState.prune.messages.blocksById.entries()).map( - ([blockId, block]) => [String(blockId), block], - ), - ), - activeBlockIds: Array.from(sessionState.prune.messages.activeBlockIds), - activeByAnchorMessageId: Object.fromEntries( - sessionState.prune.messages.activeByAnchorMessageId, - ), - nextBlockId: sessionState.prune.messages.nextBlockId, - nextRunId: sessionState.prune.messages.nextRunId, - }, + messages: serializePruneMessagesState(sessionState.prune.messages), }, nudges: { contextLimitAnchors: Array.from(sessionState.nudges.contextLimitAnchors), @@ -98,14 +101,7 @@ export async function saveSessionState( lastUpdated: new Date().toISOString(), } - const filePath = getSessionFilePath(sessionState.sessionId) - const content = JSON.stringify(state, null, 2) - await fs.writeFile(filePath, content, "utf-8") - - logger.info("Saved session state to disk", { - sessionId: sessionState.sessionId, - totalTokensSaved: state.stats.totalPruneTokens, - }) + await writePersistedSessionState(sessionState.sessionId, state, logger) } catch (error: any) { logger.error("Failed to save session state", { sessionId: sessionState.sessionId, diff --git a/lib/state/state.ts b/lib/state/state.ts index 97681d6a..c543bdec 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -1,7 +1,8 @@ -import type { CompressionStart, SessionState, ToolParameterEntry, WithParts } from "./types" +import type { CompressionTimingState, SessionState, ToolParameterEntry, WithParts } from "./types" import type { Logger } from "../logger" import { loadSessionState, saveSessionState } from "./persistence" import { + attachCompressionDuration, isSubAgentSession, findLastCompactionTimestamp, countTurns, @@ -13,6 +14,13 @@ import { } from "./utils" import { getLastUserMessage } from "../messages/query" +function createCompressionTimingState(): CompressionTimingState { + return { + startsByCallId: new Map(), + pendingBySessionId: new Map(), + } +} + export const checkSession = async ( client: any, state: SessionState, @@ -43,6 +51,17 @@ export const checkSession = async ( } } + if (state.sessionId === lastSessionId) { + const applied = applyPendingCompressionDurations(state, lastSessionId) + if (applied > 0) { + saveSessionState(state, logger).catch((error) => { + logger.warn("Failed to persist queued compression time updates", { + error: error instanceof Error ? error.message : String(error), + }) + }) + } + } + const lastCompactionTimestamp = findLastCompactionTimestamp(messages) if (lastCompactionTimestamp > state.lastCompaction) { state.lastCompaction = lastCompactionTimestamp @@ -81,7 +100,7 @@ export function createSessionState(): SessionState { pruneTokenCounter: 0, totalPruneTokens: 0, }, - compressionStarts: new Map(), + compressionTiming: createCompressionTimingState(), toolParameters: new Map(), subAgentResultCache: new Map(), toolIdList: [], @@ -178,4 +197,53 @@ export async function ensureSessionInitialized( pruneTokenCounter: persisted.stats?.pruneTokenCounter || 0, totalPruneTokens: persisted.stats?.totalPruneTokens || 0, } + + const applied = applyPendingCompressionDurations(state, sessionId) + if (applied > 0) { + await saveSessionState(state, logger) + } +} + +export function queueCompressionDuration( + state: SessionState, + sessionId: string, + callId: string, + messageId: string, + durationMs: number, +): void { + const queued = state.compressionTiming.pendingBySessionId.get(sessionId) || [] + const filtered = queued.filter((entry) => entry.callId !== callId) + filtered.push({ callId, messageId, durationMs }) + state.compressionTiming.pendingBySessionId.set(sessionId, filtered) +} + +export function applyPendingCompressionDurations(state: SessionState, sessionId: string): number { + const queued = state.compressionTiming.pendingBySessionId.get(sessionId) + if (!queued || queued.length === 0) { + return 0 + } + + let updates = 0 + const remaining = [] + for (const entry of queued) { + const applied = attachCompressionDuration( + state.prune.messages, + entry.callId, + entry.messageId, + entry.durationMs, + ) + if (applied > 0) { + updates += applied + continue + } + remaining.push(entry) + } + + if (remaining.length > 0) { + state.compressionTiming.pendingBySessionId.set(sessionId, remaining) + } else { + state.compressionTiming.pendingBySessionId.delete(sessionId) + } + + return updates } diff --git a/lib/state/types.ts b/lib/state/types.ts index 5ec73f78..ac77bf5b 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -22,10 +22,22 @@ export interface SessionStats { } export interface CompressionStart { + sessionId: string messageId: string startedAt: number } +export interface PendingCompressionDuration { + callId: string + messageId: string + durationMs: number +} + +export interface CompressionTimingState { + startsByCallId: Map + pendingBySessionId: Map +} + export interface PrunedMessageEntry { tokenCount: number allBlockIds: number[] @@ -103,7 +115,7 @@ export interface SessionState { prune: Prune nudges: Nudges stats: SessionStats - compressionStarts: Map + compressionTiming: CompressionTimingState toolParameters: Map subAgentResultCache: Map toolIdList: string[] diff --git a/lib/state/utils.ts b/lib/state/utils.ts index 953e03fe..5a0ad546 100644 --- a/lib/state/utils.ts +++ b/lib/state/utils.ts @@ -8,6 +8,31 @@ import type { import { isIgnoredUserMessage, messageHasCompress } from "../messages/query" import { countTokens } from "../token-utils" +export function attachCompressionDuration( + messagesState: PruneMessagesState, + callId: string, + messageId: string, + durationMs: number, +): number { + if (typeof durationMs !== "number" || !Number.isFinite(durationMs)) { + return 0 + } + + let updates = 0 + for (const block of messagesState.blocksById.values()) { + const matchesCall = block.compressCallId === callId + const matchesMessage = !block.compressCallId && block.compressMessageId === messageId + if (!matchesCall && !matchesMessage) { + continue + } + + block.durationMs = durationMs + updates++ + } + + return updates +} + export const isMessageCompacted = (state: SessionState, msg: WithParts): boolean => { if (msg.info.time.created < state.lastCompaction) { return true @@ -20,12 +45,30 @@ export const isMessageCompacted = (state: SessionState, msg: WithParts): boolean } interface PersistedPruneMessagesState { - byMessageId?: Record - blocksById?: Record - activeBlockIds?: number[] - activeByAnchorMessageId?: Record - nextBlockId?: number - nextRunId?: number + byMessageId: Record + blocksById: Record + activeBlockIds: number[] + activeByAnchorMessageId: Record + nextBlockId: number + nextRunId: number +} + +export function serializePruneMessagesState( + messagesState: PruneMessagesState, +): PersistedPruneMessagesState { + return { + byMessageId: Object.fromEntries(messagesState.byMessageId), + blocksById: Object.fromEntries( + Array.from(messagesState.blocksById.entries()).map(([blockId, block]) => [ + String(blockId), + block, + ]), + ), + activeBlockIds: Array.from(messagesState.activeBlockIds), + activeByAnchorMessageId: Object.fromEntries(messagesState.activeByAnchorMessageId), + nextBlockId: messagesState.nextBlockId, + nextRunId: messagesState.nextRunId, + } } export async function isSubAgentSession(client: any, sessionID: string): Promise { diff --git a/tests/hooks-permission.test.ts b/tests/hooks-permission.test.ts index 8860511c..42ad3ee1 100644 --- a/tests/hooks-permission.test.ts +++ b/tests/hooks-permission.test.ts @@ -9,7 +9,12 @@ import { createTextCompleteHandler, } from "../lib/hooks" import { Logger } from "../lib/logger" -import { createSessionState, type WithParts } from "../lib/state" +import { + createSessionState, + ensureSessionInitialized, + saveSessionState, + type WithParts, +} from "../lib/state" function buildConfig(permission: "allow" | "ask" | "deny" = "allow"): PluginConfig { return { @@ -404,3 +409,116 @@ test("event hook falls back to completed runtime when running duration missing", assert.equal(state.prune.messages.blocksById.get(1)?.durationMs, 440) }) + +test("event hook queues duration updates until the matching session is loaded", async () => { + const logger = new Logger(false) + const targetSessionId = `session-target-${process.pid}-${Date.now()}` + const otherSessionId = `session-other-${process.pid}-${Date.now()}` + const persistedState = createSessionState() + persistedState.sessionId = targetSessionId + persistedState.prune.messages.blocksById.set(1, { + blockId: 1, + runId: 1, + active: true, + deactivatedByUser: false, + compressedTokens: 0, + summaryTokens: 0, + durationMs: 0, + mode: "message", + topic: "one", + batchTopic: "one", + startId: "m0001", + endId: "m0001", + anchorMessageId: "msg-a", + compressMessageId: "message-1", + compressCallId: "call-remote", + includedBlockIds: [], + consumedBlockIds: [], + parentBlockIds: [], + directMessageIds: [], + directToolIds: [], + effectiveMessageIds: ["msg-a"], + effectiveToolIds: [], + createdAt: 1, + summary: "a", + }) + await saveSessionState(persistedState, logger) + + const liveState = createSessionState() + liveState.sessionId = otherSessionId + const handler = createEventHandler(liveState, logger) + + await handler({ + event: { + type: "message.part.updated", + properties: { + sessionID: targetSessionId, + part: { + type: "tool", + tool: "compress", + callID: "call-remote", + messageID: "message-1", + state: { + status: "pending", + input: {}, + raw: "", + }, + }, + }, + time: 100, + }, + }) + + await handler({ + event: { + type: "message.part.updated", + properties: { + sessionID: targetSessionId, + part: { + type: "tool", + tool: "compress", + callID: "call-remote", + messageID: "message-1", + state: { + status: "completed", + input: {}, + output: "done", + title: "", + metadata: {}, + time: { start: 350, end: 500 }, + }, + }, + }, + }, + }) + + assert.equal(liveState.compressionTiming.pendingBySessionId.get(targetSessionId)?.length, 1) + assert.equal(liveState.compressionTiming.startsByCallId.has("call-remote"), false) + + await ensureSessionInitialized( + { + session: { + get: async () => ({ data: { parentID: null } }), + }, + } as any, + liveState, + targetSessionId, + logger, + [ + { + info: { + id: "msg-user-1", + role: "user", + sessionID: targetSessionId, + agent: "assistant", + time: { created: 1 }, + } as WithParts["info"], + parts: [], + }, + ], + false, + ) + + assert.equal(liveState.prune.messages.blocksById.get(1)?.durationMs, 250) + assert.equal(liveState.compressionTiming.pendingBySessionId.has(targetSessionId), false) +}) From caf6009bed324bfcc01e881a11337cf85dd5eceb Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 31 Mar 2026 16:45:42 -0400 Subject: [PATCH 21/33] refactor: move compression duration updates into compress state --- lib/compress/state.ts | 27 ++++++++++++++++++++++++++- lib/state/state.ts | 2 +- lib/state/utils.ts | 25 ------------------------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/lib/compress/state.ts b/lib/compress/state.ts index 15b59e39..06905d65 100644 --- a/lib/compress/state.ts +++ b/lib/compress/state.ts @@ -1,4 +1,4 @@ -import type { CompressionBlock, SessionState } from "../state" +import type { CompressionBlock, PruneMessagesState, SessionState } from "../state" import { formatBlockRef, formatMessageIdTag } from "../message-ids" import type { AppliedCompressionResult, CompressionStateInput, SelectionResolution } from "./types" @@ -26,6 +26,31 @@ export function allocateRunId(state: SessionState): number { return next } +export function attachCompressionDuration( + messagesState: PruneMessagesState, + callId: string, + messageId: string, + durationMs: number, +): number { + if (typeof durationMs !== "number" || !Number.isFinite(durationMs)) { + return 0 + } + + let updates = 0 + for (const block of messagesState.blocksById.values()) { + const matchesCall = block.compressCallId === callId + const matchesMessage = !block.compressCallId && block.compressMessageId === messageId + if (!matchesCall && !matchesMessage) { + continue + } + + block.durationMs = durationMs + updates++ + } + + return updates +} + export function wrapCompressedSummary(blockId: number, summary: string): string { const header = COMPRESSED_BLOCK_HEADER const footer = formatMessageIdTag(formatBlockRef(blockId)) diff --git a/lib/state/state.ts b/lib/state/state.ts index c543bdec..fbb544bc 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -1,8 +1,8 @@ import type { CompressionTimingState, SessionState, ToolParameterEntry, WithParts } from "./types" import type { Logger } from "../logger" +import { attachCompressionDuration } from "../compress/state" import { loadSessionState, saveSessionState } from "./persistence" import { - attachCompressionDuration, isSubAgentSession, findLastCompactionTimestamp, countTurns, diff --git a/lib/state/utils.ts b/lib/state/utils.ts index 5a0ad546..a18552a2 100644 --- a/lib/state/utils.ts +++ b/lib/state/utils.ts @@ -8,31 +8,6 @@ import type { import { isIgnoredUserMessage, messageHasCompress } from "../messages/query" import { countTokens } from "../token-utils" -export function attachCompressionDuration( - messagesState: PruneMessagesState, - callId: string, - messageId: string, - durationMs: number, -): number { - if (typeof durationMs !== "number" || !Number.isFinite(durationMs)) { - return 0 - } - - let updates = 0 - for (const block of messagesState.blocksById.values()) { - const matchesCall = block.compressCallId === callId - const matchesMessage = !block.compressCallId && block.compressMessageId === messageId - if (!matchesCall && !matchesMessage) { - continue - } - - block.durationMs = durationMs - updates++ - } - - return updates -} - export const isMessageCompacted = (state: SessionState, msg: WithParts): boolean => { if (msg.info.time.created < state.lastCompaction) { return true From 4671b5b3249e47c9aed55fe941183249b50ef621 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 31 Mar 2026 16:59:49 -0400 Subject: [PATCH 22/33] refactor: drop redundant timing flush in checkSession --- lib/state/state.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/lib/state/state.ts b/lib/state/state.ts index fbb544bc..9a5c88f6 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -51,17 +51,6 @@ export const checkSession = async ( } } - if (state.sessionId === lastSessionId) { - const applied = applyPendingCompressionDurations(state, lastSessionId) - if (applied > 0) { - saveSessionState(state, logger).catch((error) => { - logger.warn("Failed to persist queued compression time updates", { - error: error instanceof Error ? error.message : String(error), - }) - }) - } - } - const lastCompactionTimestamp = findLastCompactionTimestamp(messages) if (lastCompactionTimestamp > state.lastCompaction) { state.lastCompaction = lastCompactionTimestamp From 8334dec640bbbebbb0d39286ec7ed7a4b65b77fb Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 31 Mar 2026 17:05:15 -0400 Subject: [PATCH 23/33] refactor: inline compression timing state --- lib/state/state.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/lib/state/state.ts b/lib/state/state.ts index 9a5c88f6..91b4dfa5 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -1,4 +1,4 @@ -import type { CompressionTimingState, SessionState, ToolParameterEntry, WithParts } from "./types" +import type { SessionState, ToolParameterEntry, WithParts } from "./types" import type { Logger } from "../logger" import { attachCompressionDuration } from "../compress/state" import { loadSessionState, saveSessionState } from "./persistence" @@ -14,13 +14,6 @@ import { } from "./utils" import { getLastUserMessage } from "../messages/query" -function createCompressionTimingState(): CompressionTimingState { - return { - startsByCallId: new Map(), - pendingBySessionId: new Map(), - } -} - export const checkSession = async ( client: any, state: SessionState, @@ -89,7 +82,10 @@ export function createSessionState(): SessionState { pruneTokenCounter: 0, totalPruneTokens: 0, }, - compressionTiming: createCompressionTimingState(), + compressionTiming: { + startsByCallId: new Map(), + pendingBySessionId: new Map(), + }, toolParameters: new Map(), subAgentResultCache: new Map(), toolIdList: [], From d9a43c2585c34821c026a030187d6401785b23d0 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 31 Mar 2026 17:16:20 -0400 Subject: [PATCH 24/33] refactor: centralize compression timing logic --- lib/compress/timing.ts | 129 +++++++++++++++++++++++++++++++++++++++++ lib/hooks.ts | 63 +++++++------------- lib/state/state.ts | 51 +--------------- lib/state/types.ts | 18 +----- 4 files changed, 154 insertions(+), 107 deletions(-) create mode 100644 lib/compress/timing.ts diff --git a/lib/compress/timing.ts b/lib/compress/timing.ts new file mode 100644 index 00000000..30492400 --- /dev/null +++ b/lib/compress/timing.ts @@ -0,0 +1,129 @@ +import type { SessionState } from "../state/types" +import { attachCompressionDuration } from "./state" + +export interface CompressionStart { + sessionId: string + messageId: string + startedAt: number +} + +export interface PendingCompressionDuration { + callId: string + messageId: string + durationMs: number +} + +export interface CompressionTimingState { + startsByCallId: Map + pendingBySessionId: Map +} + +export function createCompressionTimingState(): CompressionTimingState { + return { + startsByCallId: new Map(), + pendingBySessionId: new Map(), + } +} + +export function recordCompressionStart( + state: SessionState, + callId: string, + sessionId: string, + messageId: string, + startedAt: number, +): boolean { + if (state.compressionTiming.startsByCallId.has(callId)) { + return false + } + + state.compressionTiming.startsByCallId.set(callId, { + sessionId, + messageId, + startedAt, + }) + return true +} + +export function consumeCompressionStart( + state: SessionState, + callId: string, +): CompressionStart | undefined { + const start = state.compressionTiming.startsByCallId.get(callId) + state.compressionTiming.startsByCallId.delete(callId) + return start +} + +export function clearCompressionStart(state: SessionState, callId: string): void { + state.compressionTiming.startsByCallId.delete(callId) +} + +export function resolveCompressionDuration( + start: CompressionStart | undefined, + eventTime: number | undefined, + partTime: { start?: unknown; end?: unknown } | undefined, +): number | undefined { + const runningAt = + typeof partTime?.start === "number" && Number.isFinite(partTime.start) + ? partTime.start + : eventTime + const pendingToRunningMs = + start && typeof runningAt === "number" + ? Math.max(0, runningAt - start.startedAt) + : undefined + + const toolStart = partTime?.start + const toolEnd = partTime?.end + const runtimeMs = + typeof toolStart === "number" && + Number.isFinite(toolStart) && + typeof toolEnd === "number" && + Number.isFinite(toolEnd) + ? Math.max(0, toolEnd - toolStart) + : undefined + + return typeof pendingToRunningMs === "number" ? pendingToRunningMs : runtimeMs +} + +export function queueCompressionDuration( + state: SessionState, + sessionId: string, + callId: string, + messageId: string, + durationMs: number, +): void { + const queued = state.compressionTiming.pendingBySessionId.get(sessionId) || [] + const filtered = queued.filter((entry) => entry.callId !== callId) + filtered.push({ callId, messageId, durationMs }) + state.compressionTiming.pendingBySessionId.set(sessionId, filtered) +} + +export function applyPendingCompressionDurations(state: SessionState, sessionId: string): number { + const queued = state.compressionTiming.pendingBySessionId.get(sessionId) + if (!queued || queued.length === 0) { + return 0 + } + + let updates = 0 + const remaining = [] + for (const entry of queued) { + const applied = attachCompressionDuration( + state.prune.messages, + entry.callId, + entry.messageId, + entry.durationMs, + ) + if (applied > 0) { + updates += applied + continue + } + remaining.push(entry) + } + + if (remaining.length > 0) { + state.compressionTiming.pendingBySessionId.set(sessionId, remaining) + } else { + state.compressionTiming.pendingBySessionId.delete(sessionId) + } + + return updates +} diff --git a/lib/hooks.ts b/lib/hooks.ts index a954cdb3..e0c9853a 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -16,6 +16,14 @@ import { } from "./messages" import { renderSystemPrompt, type PromptStore } from "./prompts" import { buildProtectedToolsExtension } from "./prompts/extensions/system" +import { + applyPendingCompressionDurations, + clearCompressionStart, + consumeCompressionStart, + queueCompressionDuration, + recordCompressionStart, + resolveCompressionDuration, +} from "./compress/timing" import { applyPendingManualTrigger, handleContextCommand, @@ -29,14 +37,7 @@ import { } from "./commands" import { type HostPermissionSnapshot } from "./host-permissions" import { compressPermission, syncCompressPermissionState } from "./compress-permission" -import { - checkSession, - ensureSessionInitialized, - applyPendingCompressionDurations, - queueCompressionDuration, - saveSessionState, - syncToolCache, -} from "./state" +import { checkSession, ensureSessionInitialized, saveSessionState, syncToolCache } from "./state" import { cacheSystemPromptTokens } from "./ui/utils" const INTERNAL_AGENT_SIGNATURES = [ @@ -307,16 +308,18 @@ export function createEventHandler(state: SessionState, logger: Logger) { return } - if (state.compressionTiming.startsByCallId.has(part.callID)) { + const startedAt = eventTime ?? Date.now() + if ( + !recordCompressionStart( + state, + part.callID, + eventSessionId, + part.messageID, + startedAt, + ) + ) { return } - - const startedAt = eventTime ?? Date.now() - state.compressionTiming.startsByCallId.set(part.callID, { - sessionId: eventSessionId, - messageId: part.messageID, - startedAt, - }) logger.debug("Recorded compression start", { sessionID: eventSessionId, callID: part.callID, @@ -335,30 +338,8 @@ export function createEventHandler(state: SessionState, logger: Logger) { return } - const start = state.compressionTiming.startsByCallId.get(part.callID) - state.compressionTiming.startsByCallId.delete(part.callID) - - const runningAt = - typeof part.state.time?.start === "number" && Number.isFinite(part.state.time.start) - ? part.state.time.start - : eventTime - const pendingToRunningMs = - start && typeof runningAt === "number" - ? Math.max(0, runningAt - start.startedAt) - : undefined - - const toolStart = part.state.time?.start - const toolEnd = part.state.time?.end - const runtimeMs = - typeof toolStart === "number" && - Number.isFinite(toolStart) && - typeof toolEnd === "number" && - Number.isFinite(toolEnd) - ? Math.max(0, toolEnd - toolStart) - : undefined - - const durationMs = - typeof pendingToRunningMs === "number" ? pendingToRunningMs : runtimeMs + const start = consumeCompressionStart(state, part.callID) + const durationMs = resolveCompressionDuration(start, eventTime, part.state.time) if (typeof durationMs !== "number") { return } @@ -390,7 +371,7 @@ export function createEventHandler(state: SessionState, logger: Logger) { } if (typeof part.callID === "string") { - state.compressionTiming.startsByCallId.delete(part.callID) + clearCompressionStart(state, part.callID) } } } diff --git a/lib/state/state.ts b/lib/state/state.ts index 91b4dfa5..37f5edf9 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -1,6 +1,6 @@ import type { SessionState, ToolParameterEntry, WithParts } from "./types" import type { Logger } from "../logger" -import { attachCompressionDuration } from "../compress/state" +import { applyPendingCompressionDurations, createCompressionTimingState } from "../compress/timing" import { loadSessionState, saveSessionState } from "./persistence" import { isSubAgentSession, @@ -82,10 +82,7 @@ export function createSessionState(): SessionState { pruneTokenCounter: 0, totalPruneTokens: 0, }, - compressionTiming: { - startsByCallId: new Map(), - pendingBySessionId: new Map(), - }, + compressionTiming: createCompressionTimingState(), toolParameters: new Map(), subAgentResultCache: new Map(), toolIdList: [], @@ -188,47 +185,3 @@ export async function ensureSessionInitialized( await saveSessionState(state, logger) } } - -export function queueCompressionDuration( - state: SessionState, - sessionId: string, - callId: string, - messageId: string, - durationMs: number, -): void { - const queued = state.compressionTiming.pendingBySessionId.get(sessionId) || [] - const filtered = queued.filter((entry) => entry.callId !== callId) - filtered.push({ callId, messageId, durationMs }) - state.compressionTiming.pendingBySessionId.set(sessionId, filtered) -} - -export function applyPendingCompressionDurations(state: SessionState, sessionId: string): number { - const queued = state.compressionTiming.pendingBySessionId.get(sessionId) - if (!queued || queued.length === 0) { - return 0 - } - - let updates = 0 - const remaining = [] - for (const entry of queued) { - const applied = attachCompressionDuration( - state.prune.messages, - entry.callId, - entry.messageId, - entry.durationMs, - ) - if (applied > 0) { - updates += applied - continue - } - remaining.push(entry) - } - - if (remaining.length > 0) { - state.compressionTiming.pendingBySessionId.set(sessionId, remaining) - } else { - state.compressionTiming.pendingBySessionId.delete(sessionId) - } - - return updates -} diff --git a/lib/state/types.ts b/lib/state/types.ts index ac77bf5b..7b0b04da 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -1,3 +1,4 @@ +import type { CompressionTimingState } from "../compress/timing" import { Message, Part } from "@opencode-ai/sdk/v2" export interface WithParts { @@ -21,23 +22,6 @@ export interface SessionStats { totalPruneTokens: number } -export interface CompressionStart { - sessionId: string - messageId: string - startedAt: number -} - -export interface PendingCompressionDuration { - callId: string - messageId: string - durationMs: number -} - -export interface CompressionTimingState { - startsByCallId: Map - pendingBySessionId: Map -} - export interface PrunedMessageEntry { tokenCount: number allBlockIds: number[] From 97c3d0b5a3e81dc0210a8fc27090ba71d840ad1b Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 31 Mar 2026 17:48:09 -0400 Subject: [PATCH 25/33] refactor: simplify compression timing state --- lib/compress/pipeline.ts | 2 ++ lib/compress/state.ts | 5 +-- lib/compress/timing.ts | 57 ++++++++-------------------------- lib/hooks.ts | 39 +++-------------------- lib/state/state.ts | 2 +- tests/hooks-permission.test.ts | 6 ++-- 6 files changed, 25 insertions(+), 86 deletions(-) diff --git a/lib/compress/pipeline.ts b/lib/compress/pipeline.ts index acf92392..5f9875e6 100644 --- a/lib/compress/pipeline.ts +++ b/lib/compress/pipeline.ts @@ -9,6 +9,7 @@ import { sendCompressNotification } from "../ui/notification" import type { ToolContext } from "./types" import { buildSearchContext, fetchSessionMessages } from "./search" import type { SearchContext } from "./types" +import { applyPendingCompressionDurations } from "./timing" interface RunContext { ask(input: { @@ -83,6 +84,7 @@ export async function finalizeSession( batchTopic: string | undefined, ): Promise { ctx.state.manualMode = ctx.state.manualMode ? "active" : false + applyPendingCompressionDurations(ctx.state) await saveSessionState(ctx.state, ctx.logger) const params = getCurrentParams(ctx.state, rawMessages, ctx.logger) diff --git a/lib/compress/state.ts b/lib/compress/state.ts index 06905d65..294cc6ab 100644 --- a/lib/compress/state.ts +++ b/lib/compress/state.ts @@ -29,7 +29,6 @@ export function allocateRunId(state: SessionState): number { export function attachCompressionDuration( messagesState: PruneMessagesState, callId: string, - messageId: string, durationMs: number, ): number { if (typeof durationMs !== "number" || !Number.isFinite(durationMs)) { @@ -38,9 +37,7 @@ export function attachCompressionDuration( let updates = 0 for (const block of messagesState.blocksById.values()) { - const matchesCall = block.compressCallId === callId - const matchesMessage = !block.compressCallId && block.compressMessageId === messageId - if (!matchesCall && !matchesMessage) { + if (block.compressCallId !== callId) { continue } diff --git a/lib/compress/timing.ts b/lib/compress/timing.ts index 30492400..d6fe1e18 100644 --- a/lib/compress/timing.ts +++ b/lib/compress/timing.ts @@ -1,53 +1,37 @@ import type { SessionState } from "../state/types" import { attachCompressionDuration } from "./state" -export interface CompressionStart { - sessionId: string - messageId: string - startedAt: number -} - export interface PendingCompressionDuration { callId: string - messageId: string durationMs: number } export interface CompressionTimingState { - startsByCallId: Map - pendingBySessionId: Map + startsByCallId: Map + pendingByCallId: Map } export function createCompressionTimingState(): CompressionTimingState { return { startsByCallId: new Map(), - pendingBySessionId: new Map(), + pendingByCallId: new Map(), } } export function recordCompressionStart( state: SessionState, callId: string, - sessionId: string, - messageId: string, startedAt: number, ): boolean { if (state.compressionTiming.startsByCallId.has(callId)) { return false } - state.compressionTiming.startsByCallId.set(callId, { - sessionId, - messageId, - startedAt, - }) + state.compressionTiming.startsByCallId.set(callId, startedAt) return true } -export function consumeCompressionStart( - state: SessionState, - callId: string, -): CompressionStart | undefined { +export function consumeCompressionStart(state: SessionState, callId: string): number | undefined { const start = state.compressionTiming.startsByCallId.get(callId) state.compressionTiming.startsByCallId.delete(callId) return start @@ -58,7 +42,7 @@ export function clearCompressionStart(state: SessionState, callId: string): void } export function resolveCompressionDuration( - start: CompressionStart | undefined, + startedAt: number | undefined, eventTime: number | undefined, partTime: { start?: unknown; end?: unknown } | undefined, ): number | undefined { @@ -67,8 +51,8 @@ export function resolveCompressionDuration( ? partTime.start : eventTime const pendingToRunningMs = - start && typeof runningAt === "number" - ? Math.max(0, runningAt - start.startedAt) + typeof startedAt === "number" && typeof runningAt === "number" + ? Math.max(0, runningAt - startedAt) : undefined const toolStart = partTime?.start @@ -86,43 +70,28 @@ export function resolveCompressionDuration( export function queueCompressionDuration( state: SessionState, - sessionId: string, callId: string, - messageId: string, durationMs: number, ): void { - const queued = state.compressionTiming.pendingBySessionId.get(sessionId) || [] - const filtered = queued.filter((entry) => entry.callId !== callId) - filtered.push({ callId, messageId, durationMs }) - state.compressionTiming.pendingBySessionId.set(sessionId, filtered) + state.compressionTiming.pendingByCallId.set(callId, { callId, durationMs }) } -export function applyPendingCompressionDurations(state: SessionState, sessionId: string): number { - const queued = state.compressionTiming.pendingBySessionId.get(sessionId) - if (!queued || queued.length === 0) { +export function applyPendingCompressionDurations(state: SessionState): number { + if (state.compressionTiming.pendingByCallId.size === 0) { return 0 } let updates = 0 - const remaining = [] - for (const entry of queued) { + for (const [callId, entry] of state.compressionTiming.pendingByCallId) { const applied = attachCompressionDuration( state.prune.messages, entry.callId, - entry.messageId, entry.durationMs, ) if (applied > 0) { updates += applied - continue + state.compressionTiming.pendingByCallId.delete(callId) } - remaining.push(entry) - } - - if (remaining.length > 0) { - state.compressionTiming.pendingBySessionId.set(sessionId, remaining) - } else { - state.compressionTiming.pendingBySessionId.delete(sessionId) } return updates diff --git a/lib/hooks.ts b/lib/hooks.ts index e0c9853a..4e75588d 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -276,12 +276,6 @@ export function createTextCompleteHandler() { export function createEventHandler(state: SessionState, logger: Logger) { return async (input: { event: any }) => { - const eventSessionId = - typeof input.event?.properties?.sessionID === "string" - ? input.event.properties.sessionID - : typeof input.event?.properties?.part?.sessionID === "string" - ? input.event.properties.part.sessionID - : undefined const eventTime = typeof input.event?.time === "number" && Number.isFinite(input.event.time) ? input.event.time @@ -300,41 +294,23 @@ export function createEventHandler(state: SessionState, logger: Logger) { } if (part.state.status === "pending") { - if ( - typeof part.callID !== "string" || - typeof part.messageID !== "string" || - typeof eventSessionId !== "string" - ) { + if (typeof part.callID !== "string") { return } const startedAt = eventTime ?? Date.now() - if ( - !recordCompressionStart( - state, - part.callID, - eventSessionId, - part.messageID, - startedAt, - ) - ) { + if (!recordCompressionStart(state, part.callID, startedAt)) { return } logger.debug("Recorded compression start", { - sessionID: eventSessionId, callID: part.callID, - messageID: part.messageID, startedAt, }) return } if (part.state.status === "completed") { - if ( - typeof part.callID !== "string" || - typeof part.messageID !== "string" || - typeof eventSessionId !== "string" - ) { + if (typeof part.callID !== "string") { return } @@ -344,12 +320,9 @@ export function createEventHandler(state: SessionState, logger: Logger) { return } - queueCompressionDuration(state, eventSessionId, part.callID, part.messageID, durationMs) + queueCompressionDuration(state, part.callID, durationMs) - const updates = - state.sessionId === eventSessionId - ? applyPendingCompressionDurations(state, eventSessionId) - : 0 + const updates = applyPendingCompressionDurations(state) if (updates === 0) { return } @@ -357,9 +330,7 @@ export function createEventHandler(state: SessionState, logger: Logger) { await saveSessionState(state, logger) logger.info("Attached compression time to blocks", { - sessionID: eventSessionId, callID: part.callID, - messageID: part.messageID, blocks: updates, durationMs, }) diff --git a/lib/state/state.ts b/lib/state/state.ts index 37f5edf9..3df6f500 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -180,7 +180,7 @@ export async function ensureSessionInitialized( totalPruneTokens: persisted.stats?.totalPruneTokens || 0, } - const applied = applyPendingCompressionDurations(state, sessionId) + const applied = applyPendingCompressionDurations(state) if (applied > 0) { await saveSessionState(state, logger) } diff --git a/tests/hooks-permission.test.ts b/tests/hooks-permission.test.ts index 42ad3ee1..238b0412 100644 --- a/tests/hooks-permission.test.ts +++ b/tests/hooks-permission.test.ts @@ -372,7 +372,7 @@ test("event hook falls back to completed runtime when running duration missing", endId: "m0001", anchorMessageId: "msg-a", compressMessageId: "message-1", - compressCallId: undefined, + compressCallId: "call-3", includedBlockIds: [], consumedBlockIds: [], parentBlockIds: [], @@ -492,7 +492,7 @@ test("event hook queues duration updates until the matching session is loaded", }, }) - assert.equal(liveState.compressionTiming.pendingBySessionId.get(targetSessionId)?.length, 1) + assert.equal(liveState.compressionTiming.pendingByCallId.has("call-remote"), true) assert.equal(liveState.compressionTiming.startsByCallId.has("call-remote"), false) await ensureSessionInitialized( @@ -520,5 +520,5 @@ test("event hook queues duration updates until the matching session is loaded", ) assert.equal(liveState.prune.messages.blocksById.get(1)?.durationMs, 250) - assert.equal(liveState.compressionTiming.pendingBySessionId.has(targetSessionId), false) + assert.equal(liveState.compressionTiming.pendingByCallId.has("call-remote"), false) }) From ecde964e2788630bd4871a403f8b1d6da974f740 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 31 Mar 2026 18:25:59 -0400 Subject: [PATCH 26/33] refactor: inline timing map writes --- lib/compress/timing.ts | 25 ------------------------- lib/hooks.ts | 13 +++++++------ 2 files changed, 7 insertions(+), 31 deletions(-) diff --git a/lib/compress/timing.ts b/lib/compress/timing.ts index d6fe1e18..a8521843 100644 --- a/lib/compress/timing.ts +++ b/lib/compress/timing.ts @@ -18,29 +18,12 @@ export function createCompressionTimingState(): CompressionTimingState { } } -export function recordCompressionStart( - state: SessionState, - callId: string, - startedAt: number, -): boolean { - if (state.compressionTiming.startsByCallId.has(callId)) { - return false - } - - state.compressionTiming.startsByCallId.set(callId, startedAt) - return true -} - export function consumeCompressionStart(state: SessionState, callId: string): number | undefined { const start = state.compressionTiming.startsByCallId.get(callId) state.compressionTiming.startsByCallId.delete(callId) return start } -export function clearCompressionStart(state: SessionState, callId: string): void { - state.compressionTiming.startsByCallId.delete(callId) -} - export function resolveCompressionDuration( startedAt: number | undefined, eventTime: number | undefined, @@ -68,14 +51,6 @@ export function resolveCompressionDuration( return typeof pendingToRunningMs === "number" ? pendingToRunningMs : runtimeMs } -export function queueCompressionDuration( - state: SessionState, - callId: string, - durationMs: number, -): void { - state.compressionTiming.pendingByCallId.set(callId, { callId, durationMs }) -} - export function applyPendingCompressionDurations(state: SessionState): number { if (state.compressionTiming.pendingByCallId.size === 0) { return 0 diff --git a/lib/hooks.ts b/lib/hooks.ts index 4e75588d..7f71265a 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -18,10 +18,7 @@ import { renderSystemPrompt, type PromptStore } from "./prompts" import { buildProtectedToolsExtension } from "./prompts/extensions/system" import { applyPendingCompressionDurations, - clearCompressionStart, consumeCompressionStart, - queueCompressionDuration, - recordCompressionStart, resolveCompressionDuration, } from "./compress/timing" import { @@ -299,9 +296,10 @@ export function createEventHandler(state: SessionState, logger: Logger) { } const startedAt = eventTime ?? Date.now() - if (!recordCompressionStart(state, part.callID, startedAt)) { + if (state.compressionTiming.startsByCallId.has(part.callID)) { return } + state.compressionTiming.startsByCallId.set(part.callID, startedAt) logger.debug("Recorded compression start", { callID: part.callID, startedAt, @@ -320,7 +318,10 @@ export function createEventHandler(state: SessionState, logger: Logger) { return } - queueCompressionDuration(state, part.callID, durationMs) + state.compressionTiming.pendingByCallId.set(part.callID, { + callId: part.callID, + durationMs, + }) const updates = applyPendingCompressionDurations(state) if (updates === 0) { @@ -342,7 +343,7 @@ export function createEventHandler(state: SessionState, logger: Logger) { } if (typeof part.callID === "string") { - clearCompressionStart(state, part.callID) + state.compressionTiming.startsByCallId.delete(part.callID) } } } From 57dc7d6629551e8e7b006c5e42162e638a2eacb4 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 31 Mar 2026 18:33:51 -0400 Subject: [PATCH 27/33] refactor: inline timing state init --- lib/compress/timing.ts | 7 ------- lib/state/state.ts | 7 +++++-- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/lib/compress/timing.ts b/lib/compress/timing.ts index a8521843..4a5efed6 100644 --- a/lib/compress/timing.ts +++ b/lib/compress/timing.ts @@ -11,13 +11,6 @@ export interface CompressionTimingState { pendingByCallId: Map } -export function createCompressionTimingState(): CompressionTimingState { - return { - startsByCallId: new Map(), - pendingByCallId: new Map(), - } -} - export function consumeCompressionStart(state: SessionState, callId: string): number | undefined { const start = state.compressionTiming.startsByCallId.get(callId) state.compressionTiming.startsByCallId.delete(callId) diff --git a/lib/state/state.ts b/lib/state/state.ts index 3df6f500..71cb3aac 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -1,6 +1,6 @@ import type { SessionState, ToolParameterEntry, WithParts } from "./types" import type { Logger } from "../logger" -import { applyPendingCompressionDurations, createCompressionTimingState } from "../compress/timing" +import { applyPendingCompressionDurations } from "../compress/timing" import { loadSessionState, saveSessionState } from "./persistence" import { isSubAgentSession, @@ -82,7 +82,10 @@ export function createSessionState(): SessionState { pruneTokenCounter: 0, totalPruneTokens: 0, }, - compressionTiming: createCompressionTimingState(), + compressionTiming: { + startsByCallId: new Map(), + pendingByCallId: new Map(), + }, toolParameters: new Map(), subAgentResultCache: new Map(), toolIdList: [], From 15c5d207e294ea10c9a2f83f01ae3c60325b38fd Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 31 Mar 2026 18:37:56 -0400 Subject: [PATCH 28/33] refactor: move event hook last --- index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/index.ts b/index.ts index 0e58d1db..e69357e6 100644 --- a/index.ts +++ b/index.ts @@ -58,7 +58,6 @@ const plugin: Plugin = (async (ctx) => { config, prompts, ), - "experimental.chat.messages.transform": createChatMessageTransformHandler( ctx.client, state, @@ -69,7 +68,6 @@ const plugin: Plugin = (async (ctx) => { ) as any, "chat.message": createChatMessageHandler(state, logger, config, hostPermissions), "experimental.text.complete": createTextCompleteHandler(), - event: createEventHandler(state, logger), "command.execute.before": createCommandExecuteHandler( ctx.client, state, @@ -78,6 +76,7 @@ const plugin: Plugin = (async (ctx) => { ctx.directory, hostPermissions, ), + event: createEventHandler(state, logger), tool: { ...(config.compress.permission !== "deny" && { compress: From e7db0fa794863525e64b5a8f285dab5066c13fb2 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 31 Mar 2026 19:32:03 -0400 Subject: [PATCH 29/33] fix: key compression timing by message id --- lib/compress/state.ts | 3 +- lib/compress/timing.ts | 21 +++-- lib/hooks.ts | 24 +++-- tests/hooks-permission.test.ts | 158 ++++++++++++++++++++++++++++++++- 4 files changed, 188 insertions(+), 18 deletions(-) diff --git a/lib/compress/state.ts b/lib/compress/state.ts index 294cc6ab..5672b1c1 100644 --- a/lib/compress/state.ts +++ b/lib/compress/state.ts @@ -28,6 +28,7 @@ export function allocateRunId(state: SessionState): number { export function attachCompressionDuration( messagesState: PruneMessagesState, + messageId: string, callId: string, durationMs: number, ): number { @@ -37,7 +38,7 @@ export function attachCompressionDuration( let updates = 0 for (const block of messagesState.blocksById.values()) { - if (block.compressCallId !== callId) { + if (block.compressMessageId !== messageId || block.compressCallId !== callId) { continue } diff --git a/lib/compress/timing.ts b/lib/compress/timing.ts index 4a5efed6..f99ad612 100644 --- a/lib/compress/timing.ts +++ b/lib/compress/timing.ts @@ -2,6 +2,7 @@ import type { SessionState } from "../state/types" import { attachCompressionDuration } from "./state" export interface PendingCompressionDuration { + messageId: string callId: string durationMs: number } @@ -11,9 +12,18 @@ export interface CompressionTimingState { pendingByCallId: Map } -export function consumeCompressionStart(state: SessionState, callId: string): number | undefined { - const start = state.compressionTiming.startsByCallId.get(callId) - state.compressionTiming.startsByCallId.delete(callId) +export function buildCompressionTimingKey(messageId: string, callId: string): string { + return `${messageId}:${callId}` +} + +export function consumeCompressionStart( + state: SessionState, + messageId: string, + callId: string, +): number | undefined { + const key = buildCompressionTimingKey(messageId, callId) + const start = state.compressionTiming.startsByCallId.get(key) + state.compressionTiming.startsByCallId.delete(key) return start } @@ -50,15 +60,16 @@ export function applyPendingCompressionDurations(state: SessionState): number { } let updates = 0 - for (const [callId, entry] of state.compressionTiming.pendingByCallId) { + for (const [key, entry] of state.compressionTiming.pendingByCallId) { const applied = attachCompressionDuration( state.prune.messages, + entry.messageId, entry.callId, entry.durationMs, ) if (applied > 0) { updates += applied - state.compressionTiming.pendingByCallId.delete(callId) + state.compressionTiming.pendingByCallId.delete(key) } } diff --git a/lib/hooks.ts b/lib/hooks.ts index 7f71265a..b518617a 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -18,6 +18,7 @@ import { renderSystemPrompt, type PromptStore } from "./prompts" import { buildProtectedToolsExtension } from "./prompts/extensions/system" import { applyPendingCompressionDurations, + buildCompressionTimingKey, consumeCompressionStart, resolveCompressionDuration, } from "./compress/timing" @@ -291,16 +292,18 @@ export function createEventHandler(state: SessionState, logger: Logger) { } if (part.state.status === "pending") { - if (typeof part.callID !== "string") { + if (typeof part.callID !== "string" || typeof part.messageID !== "string") { return } const startedAt = eventTime ?? Date.now() - if (state.compressionTiming.startsByCallId.has(part.callID)) { + const key = buildCompressionTimingKey(part.messageID, part.callID) + if (state.compressionTiming.startsByCallId.has(key)) { return } - state.compressionTiming.startsByCallId.set(part.callID, startedAt) + state.compressionTiming.startsByCallId.set(key, startedAt) logger.debug("Recorded compression start", { + messageID: part.messageID, callID: part.callID, startedAt, }) @@ -308,17 +311,19 @@ export function createEventHandler(state: SessionState, logger: Logger) { } if (part.state.status === "completed") { - if (typeof part.callID !== "string") { + if (typeof part.callID !== "string" || typeof part.messageID !== "string") { return } - const start = consumeCompressionStart(state, part.callID) + const key = buildCompressionTimingKey(part.messageID, part.callID) + const start = consumeCompressionStart(state, part.messageID, part.callID) const durationMs = resolveCompressionDuration(start, eventTime, part.state.time) if (typeof durationMs !== "number") { return } - state.compressionTiming.pendingByCallId.set(part.callID, { + state.compressionTiming.pendingByCallId.set(key, { + messageId: part.messageID, callId: part.callID, durationMs, }) @@ -331,6 +336,7 @@ export function createEventHandler(state: SessionState, logger: Logger) { await saveSessionState(state, logger) logger.info("Attached compression time to blocks", { + messageID: part.messageID, callID: part.callID, blocks: updates, durationMs, @@ -342,8 +348,10 @@ export function createEventHandler(state: SessionState, logger: Logger) { return } - if (typeof part.callID === "string") { - state.compressionTiming.startsByCallId.delete(part.callID) + if (typeof part.callID === "string" && typeof part.messageID === "string") { + state.compressionTiming.startsByCallId.delete( + buildCompressionTimingKey(part.messageID, part.callID), + ) } } } diff --git a/tests/hooks-permission.test.ts b/tests/hooks-permission.test.ts index 238b0412..243a8156 100644 --- a/tests/hooks-permission.test.ts +++ b/tests/hooks-permission.test.ts @@ -159,7 +159,7 @@ test("text complete strips hallucinated metadata tags", async () => { assert.equal(output.text, "alpha omega") }) -test("event hook attaches durations to matching blocks by call id", async () => { +test("event hook attaches durations to matching blocks by message and call id", async () => { const state = createSessionState() state.sessionId = "session-1" const handler = createEventHandler(state, new Logger(false)) @@ -492,8 +492,8 @@ test("event hook queues duration updates until the matching session is loaded", }, }) - assert.equal(liveState.compressionTiming.pendingByCallId.has("call-remote"), true) - assert.equal(liveState.compressionTiming.startsByCallId.has("call-remote"), false) + assert.equal(liveState.compressionTiming.pendingByCallId.has("message-1:call-remote"), true) + assert.equal(liveState.compressionTiming.startsByCallId.has("message-1:call-remote"), false) await ensureSessionInitialized( { @@ -520,5 +520,155 @@ test("event hook queues duration updates until the matching session is loaded", ) assert.equal(liveState.prune.messages.blocksById.get(1)?.durationMs, 250) - assert.equal(liveState.compressionTiming.pendingByCallId.has("call-remote"), false) + assert.equal(liveState.compressionTiming.pendingByCallId.has("message-1:call-remote"), false) +}) + +test("event hook keeps same call id distinct across message ids", async () => { + const state = createSessionState() + state.sessionId = "session-1" + const handler = createEventHandler(state, new Logger(false)) + + state.prune.messages.blocksById.set(1, { + blockId: 1, + runId: 1, + active: true, + deactivatedByUser: false, + compressedTokens: 0, + summaryTokens: 0, + durationMs: 0, + mode: "message", + topic: "one", + batchTopic: "one", + startId: "m0001", + endId: "m0001", + anchorMessageId: "msg-a", + compressMessageId: "message-1", + compressCallId: "shared-call", + includedBlockIds: [], + consumedBlockIds: [], + parentBlockIds: [], + directMessageIds: [], + directToolIds: [], + effectiveMessageIds: ["msg-a"], + effectiveToolIds: [], + createdAt: 1, + summary: "a", + }) + state.prune.messages.blocksById.set(2, { + blockId: 2, + runId: 2, + active: true, + deactivatedByUser: false, + compressedTokens: 0, + summaryTokens: 0, + durationMs: 0, + mode: "message", + topic: "two", + batchTopic: "two", + startId: "m0002", + endId: "m0002", + anchorMessageId: "msg-b", + compressMessageId: "message-2", + compressCallId: "shared-call", + includedBlockIds: [], + consumedBlockIds: [], + parentBlockIds: [], + directMessageIds: [], + directToolIds: [], + effectiveMessageIds: ["msg-b"], + effectiveToolIds: [], + createdAt: 2, + summary: "b", + }) + + await handler({ + event: { + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "compress", + callID: "shared-call", + messageID: "message-1", + sessionID: "session-1", + state: { + status: "pending", + input: {}, + raw: "", + }, + }, + }, + time: 100, + }, + }) + + await handler({ + event: { + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "compress", + callID: "shared-call", + messageID: "message-2", + sessionID: "session-1", + state: { + status: "pending", + input: {}, + raw: "", + }, + }, + }, + time: 200, + }, + }) + + await handler({ + event: { + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "compress", + callID: "shared-call", + messageID: "message-2", + sessionID: "session-1", + state: { + status: "completed", + input: {}, + output: "done", + title: "", + metadata: {}, + time: { start: 350, end: 500 }, + }, + }, + }, + }, + }) + + await handler({ + event: { + type: "message.part.updated", + properties: { + part: { + type: "tool", + tool: "compress", + callID: "shared-call", + messageID: "message-1", + sessionID: "session-1", + state: { + status: "completed", + input: {}, + output: "done", + title: "", + metadata: {}, + time: { start: 450, end: 700 }, + }, + }, + }, + }, + }) + + assert.equal(state.prune.messages.blocksById.get(1)?.durationMs, 350) + assert.equal(state.prune.messages.blocksById.get(2)?.durationMs, 150) }) From a122abd7d15f0d0215b4003a63311723f1a95a84 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 31 Mar 2026 21:05:38 -0400 Subject: [PATCH 30/33] fix: tighten compression prompt rules --- lib/prompts/compress-message.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/prompts/compress-message.ts b/lib/prompts/compress-message.ts index eea70d77..9c41a122 100644 --- a/lib/prompts/compress-message.ts +++ b/lib/prompts/compress-message.ts @@ -18,8 +18,8 @@ You specify individual raw messages by ID using the injected IDs visible in the Each message has an ID inside XML metadata tags like \`m0007\`. The same ID tag appears in every tool output of the message it belongs to — each unique ID identifies one complete message. Treat these tags as message metadata only, not as content to summarize. Use only the inner \`mNNNN\` value as the \`messageId\`. -The \`priority\` attribute indicates relative context cost. When using the compress tool, if there are high-priority messages they MUST be compressed if all of their information is not vital to the task at hand. -If there are previous messages with compress tool results, these MUST be compressed with a minimal summary. +The \`priority\` attribute indicates relative context cost. You MUST compress high-priority messages when their full text is no longer necessary for the active task. +If prior compress-tool results are present, always compress and summarize them minimally only as part of a broader compression pass. Do not invoke the compress tool solely to re-compress an earlier compression result. Messages marked as \`BLOCKED\` cannot be compressed. Rules: @@ -30,6 +30,13 @@ Rules: - Do not invent IDs. Use only IDs that are present in context. BATCHING -Select MANY messages in a single tool call when they are independently safe to compress. +Select MANY messages in a single tool call when they are safe to compress. Each entry should summarize exactly one message, and the tool can receive as many entries as needed in one batch. + +GENERAL CLEANUP +Use the topic "general cleanup" for broad cleanup passes. +During general cleanup, compress all medium and high-priority messages that are not relevant to the active task. +Optimize for reducing context footprint, not for grouping messages by topic. +Do not compress away still-active instructions, unresolved questions, or constraints that are likely to matter soon. +Prioritize the earliest messages in the context as they will be the least relevant to the active task. ` From 7cf3694ddb15290f40043fb9cabad6dcb54e7e3a Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Tue, 31 Mar 2026 22:29:12 -0400 Subject: [PATCH 31/33] docs: readme install wording --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f3a9480f..81d31790 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Automatically reduces token usage in OpenCode by managing conversation context. ## Installation -Install with the OpenCode CLI: +Install from the CLI: ```bash opencode plugin @tarquinen/opencode-dcp@latest --global From 00b65c5fe69e7ef74c4ec55dadef547359321716 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 1 Apr 2026 15:42:32 -0400 Subject: [PATCH 32/33] fix: harden runtime imports for web plugin loading --- lib/config.ts | 4 ++-- lib/token-utils.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index d488f8dc..bd19f768 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,7 +1,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from "fs" import { join, dirname } from "path" import { homedir } from "os" -import { parse } from "jsonc-parser" +import * as jsoncParser from "jsonc-parser" import type { PluginInput } from "@opencode-ai/plugin" type Permission = "ask" | "allow" | "deny" @@ -773,7 +773,7 @@ function loadConfigFile(configPath: string): ConfigLoadResult { } try { - const parsed = parse(fileContent, undefined, { allowTrailingComma: true }) + const parsed = jsoncParser.parse(fileContent, undefined, { allowTrailingComma: true }) if (parsed === undefined || parsed === null) { return { data: null, parseError: "Config file is empty or invalid" } } diff --git a/lib/token-utils.ts b/lib/token-utils.ts index 5ca09372..6d514a64 100644 --- a/lib/token-utils.ts +++ b/lib/token-utils.ts @@ -1,7 +1,7 @@ import { SessionState, WithParts } from "./state" import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" import { Logger } from "./logger" -import { countTokens as anthropicCountTokens } from "@anthropic-ai/tokenizer" +import * as anthropicTokenizer from "@anthropic-ai/tokenizer" import { getLastUserMessage } from "./messages/query" export function getCurrentTokenUsage(state: SessionState, messages: WithParts[]): number { @@ -67,7 +67,7 @@ export function getCurrentParams( export function countTokens(text: string): number { if (!text) return 0 try { - return anthropicCountTokens(text) + return anthropicTokenizer.countTokens(text) } catch { return Math.round(text.length / 4) } From 47b4c0467895e13ffe485f297790595576d8eb48 Mon Sep 17 00:00:00 2001 From: Daniel Smolsky Date: Wed, 1 Apr 2026 15:42:32 -0400 Subject: [PATCH 33/33] chore: verify package runtime compatibility --- package.json | 4 +- scripts/verify-package.mjs | 228 +++++++++++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 scripts/verify-package.mjs diff --git a/package.json b/package.json index f273cb86..bfaa3847 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "scripts": { "clean": "rm -rf dist", "build": "npm run clean && tsc", - "prepublishOnly": "npm run build", + "verify:package": "node scripts/verify-package.mjs", + "check:package": "npm run build && npm run verify:package", + "prepublishOnly": "npm run check:package", "dev": "opencode plugin dev", "typecheck": "tsc --noEmit", "test": "node --import tsx --test tests/*.test.ts", diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 00000000..e2547494 --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,228 @@ +import { builtinModules, createRequire } from "node:module" +import { existsSync, readFileSync, statSync } from "node:fs" +import { execFileSync } from "node:child_process" +import path from "node:path" +import process from "node:process" +import { fileURLToPath } from "node:url" + +const require = createRequire(import.meta.url) +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))) + +const builtinNames = new Set([ + ...builtinModules, + ...builtinModules.map((name) => name.replace(/^node:/, "")), +]) + +const requiredRepoFiles = [ + "dist/index.js", + "dist/index.d.ts", + "dist/lib/config.js", + "README.md", + "LICENSE", +] + +const requiredTarballFiles = [ + "package.json", + "dist/index.js", + "dist/index.d.ts", + "dist/lib/config.js", + "README.md", + "LICENSE", +] + +const forbiddenTarballPatterns = [ + /^node_modules\//, + /^lib\//, + /^index\.ts$/, + /^tests\//, + /^scripts\//, + /^docs\//, + /^assets\//, + /^notes\//, + /^\.github\//, + /^package-lock\.json$/, + /^tsconfig\.json$/, +] + +const packageInfoCache = new Map() + +function fail(message) { + console.error(`package verification failed: ${message}`) + process.exit(1) +} + +function assertRepoFilesExist() { + for (const relativePath of requiredRepoFiles) { + if (!existsSync(path.join(root, relativePath))) { + fail(`missing required file: ${relativePath}`) + } + } +} + +function assertPackageJsonShape() { + const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) + + if (pkg.main !== "./dist/index.js") { + fail(`package.json main must remain ./dist/index.js, found ${pkg.main ?? ""}`) + } + + const files = Array.isArray(pkg.files) ? pkg.files : [] + for (const entry of ["dist/", "README.md", "LICENSE"]) { + if (!files.includes(entry)) { + fail(`package.json files must include ${entry}`) + } + } +} + +function getImportStatements(source) { + const pattern = /^\s*import\s+([^\n;]+?)\s+from\s+["']([^"']+)["']/gm + return Array.from(source.matchAll(pattern), (match) => ({ + clause: match[1].trim(), + specifier: match[2], + })) +} + +function getImportKind(clause) { + if (clause.startsWith("type ")) return "type" + if (clause.startsWith("* as ")) return "namespace" + if (clause.startsWith("{")) return "named" + if (clause.includes(",")) { + const [, trailing = ""] = clause.split(",", 2) + return trailing.trim().startsWith("* as ") ? "default+namespace" : "default+named" + } + return "default" +} + +function getPackageName(specifier) { + if (specifier.startsWith("@")) { + const parts = specifier.split("/") + return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : specifier + } + return specifier.split("/")[0] +} + +function resolveLocalImport(importerPath, specifier) { + const basePath = path.resolve(path.dirname(importerPath), specifier) + const candidates = [ + basePath, + `${basePath}.ts`, + `${basePath}.tsx`, + `${basePath}.js`, + `${basePath}.mjs`, + path.join(basePath, "index.ts"), + path.join(basePath, "index.tsx"), + path.join(basePath, "index.js"), + path.join(basePath, "index.mjs"), + ] + + for (const candidate of candidates) { + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate + } + + fail(`unable to resolve local import ${specifier} from ${path.relative(root, importerPath)}`) +} + +function findPackageInfo(packageName, importerPath) { + const cacheKey = `${packageName}::${path.dirname(importerPath)}` + if (packageInfoCache.has(cacheKey)) { + return packageInfoCache.get(cacheKey) + } + + let entry + try { + entry = require.resolve(packageName, { paths: [path.dirname(importerPath)] }) + } catch { + packageInfoCache.set(cacheKey, null) + return null + } + + let current = path.dirname(entry) + while (true) { + const manifest = path.join(current, "package.json") + if (existsSync(manifest)) { + const info = JSON.parse(readFileSync(manifest, "utf8")) + packageInfoCache.set(cacheKey, info) + return info + } + const parent = path.dirname(current) + if (parent === current) { + packageInfoCache.set(cacheKey, null) + return null + } + current = parent + } +} + +function packageLooksCommonJs(pkg) { + if (!pkg) return false + if (pkg.type === "commonjs") return true + + const main = typeof pkg.main === "string" ? pkg.main : "" + return /(?:^|\/)(cjs|umd)(?:\/|$)/.test(main) || main.endsWith(".cjs") +} + +function validateRuntimeImportGraph() { + const pending = [path.join(root, "index.ts")] + const seen = new Set() + + while (pending.length > 0) { + const filePath = pending.pop() + if (!filePath || seen.has(filePath)) continue + seen.add(filePath) + + const source = readFileSync(filePath, "utf8") + for (const entry of getImportStatements(source)) { + if (entry.specifier.startsWith(".")) { + pending.push(resolveLocalImport(filePath, entry.specifier)) + continue + } + + const packageName = getPackageName(entry.specifier) + if (builtinNames.has(packageName)) continue + + const kind = getImportKind(entry.clause) + if (kind === "type" || kind === "namespace") continue + + const pkg = findPackageInfo(packageName, filePath) + if (packageLooksCommonJs(pkg)) { + fail( + `${path.relative(root, filePath)} uses ${kind} import from CommonJS-style package ${packageName}`, + ) + } + } + } +} + +function validatePackedFiles() { + const output = execFileSync("npm", ["pack", "--dry-run", "--json"], { + cwd: root, + encoding: "utf8", + }) + + const [result] = JSON.parse(output) + if (!result || !Array.isArray(result.files)) { + fail("npm pack --dry-run --json did not return file metadata") + } + + const packedPaths = result.files.map((file) => file.path) + for (const required of requiredTarballFiles) { + if (!packedPaths.includes(required)) { + fail(`packed tarball is missing ${required}`) + } + } + + const forbidden = packedPaths.find((file) => + forbiddenTarballPatterns.some((pattern) => pattern.test(file)), + ) + if (forbidden) { + fail(`packed tarball contains forbidden path ${forbidden}`) + } + + console.log(`package verification passed for ${result.name}@${result.version}`) + console.log(`tarball entries: ${result.entryCount}`) +} + +assertRepoFilesExist() +assertPackageJsonShape() +validateRuntimeImportGraph() +validatePackedFiles()