diff --git a/README.md b/README.md index 5f7d03e0..81d31790 100644 --- a/README.md +++ b/README.md @@ -9,18 +9,13 @@ Automatically reduces token usage in OpenCode by managing conversation context. ## Installation -Add to your OpenCode config: +Install from the CLI: -```jsonc -// opencode.jsonc -{ - "plugin": ["@tarquinen/opencode-dcp@latest"], -} +```bash +opencode plugin @tarquinen/opencode-dcp@latest --global ``` -Using `@latest` ensures you always get the newest version automatically when OpenCode starts. - -Restart OpenCode. The plugin will automatically start optimizing your sessions. +This installs the package and adds it to your global OpenCode config. ## How It Works diff --git a/index.ts b/index.ts index 8bcd8e34..e69357e6 100644 --- a/index.ts +++ b/index.ts @@ -13,6 +13,7 @@ import { createChatMessageHandler, createChatMessageTransformHandler, createCommandExecuteHandler, + createEventHandler, createSystemPromptHandler, createTextCompleteHandler, } from "./lib/hooks" @@ -57,7 +58,6 @@ const plugin: Plugin = (async (ctx) => { config, prompts, ), - "experimental.chat.messages.transform": createChatMessageTransformHandler( ctx.client, state, @@ -76,6 +76,7 @@ const plugin: Plugin = (async (ctx) => { ctx.directory, hostPermissions, ), + event: createEventHandler(state, logger), tool: { ...(config.compress.permission !== "deny" && { compress: 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/context.ts b/lib/commands/context.ts index cf765566..c4f0408e 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, 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/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..e93af727 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 { buildCompressedBlockGuidance } from "../messages/inject/utils" -import { isIgnoredUserMessage } from "../messages/utils" +import { getCurrentParams } from "../token-utils" +import { buildCompressedBlockGuidance } from "../prompts/extensions/nudge" +import { isIgnoredUserMessage } from "../messages/query" const MANUAL_MODE_ON = "Manual mode is now ON. Use /dcp compress to trigger context tools manually." 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..bea2a6dc 100644 --- a/lib/commands/stats.ts +++ b/lib/commands/stats.ts @@ -8,7 +8,8 @@ import type { SessionState, WithParts } from "../state" import { sendIgnoredMessage } from "../ui/notification" import { formatTokenCount } from "../ui/utils" import { loadAllSessionStats, type AggregatedStats } from "../state/persistence" -import { getCurrentParams } from "../strategies/utils" +import { getCurrentParams } from "../token-utils" +import { getActiveCompressionTargets } from "./compression-targets" export interface StatsCommandContext { client: any @@ -20,8 +21,10 @@ export interface StatsCommandContext { function formatStatsMessage( sessionTokens: number, + sessionSummaryTokens: number, sessionTools: number, sessionMessages: number, + sessionDurationMs: number, allTime: AggregatedStats, ): string { const lines: string[] = [] @@ -30,11 +33,15 @@ function formatStatsMessage( lines.push("│ DCP Statistics │") lines.push("╰───────────────────────────────────────────────────────────╯") lines.push("") - lines.push("Session:") + lines.push("Compression:") lines.push("─".repeat(60)) - lines.push(` Tokens pruned: ~${formatTokenCount(sessionTokens)}`) - lines.push(` Tools pruned: ${sessionTools}`) - lines.push(` Messages pruned: ${sessionMessages}`) + lines.push( + ` Tokens in|out: ~${formatTokenCount(sessionTokens)} | ~${formatTokenCount(sessionSummaryTokens)}`, + ) + lines.push(` Ratio: ${formatCompressionRatio(sessionTokens, sessionSummaryTokens)}`) + lines.push(` Time: ${formatCompressionTime(sessionDurationMs)}`) + lines.push(` Messages: ${sessionMessages}`) + lines.push(` Tools: ${sessionTools}`) lines.push("") lines.push("All-time:") lines.push("─".repeat(60)) @@ -46,11 +53,55 @@ function formatStatsMessage( return lines.join("\n") } +function formatCompressionRatio(inputTokens: number, outputTokens: number): string { + if (inputTokens <= 0) { + return "0:1" + } + + if (outputTokens <= 0) { + return "∞:1" + } + + const ratio = Math.max(1, Math.round(inputTokens / outputTokens)) + return `${ratio}:1` +} + +function formatCompressionTime(ms: number): string { + const safeMs = Math.max(0, Math.round(ms)) + if (safeMs < 1000) { + return `${safeMs} ms` + } + + const totalSeconds = safeMs / 1000 + if (totalSeconds < 60) { + return `${totalSeconds.toFixed(1)} s` + } + + const wholeSeconds = Math.floor(totalSeconds) + const hours = Math.floor(wholeSeconds / 3600) + const minutes = Math.floor((wholeSeconds % 3600) / 60) + const seconds = wholeSeconds % 60 + + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}s` + } + + return `${minutes}m ${seconds}s` +} + export async function handleStatsCommand(ctx: StatsCommandContext): Promise { const { client, state, logger, sessionId, messages } = ctx // Session stats from in-memory state const sessionTokens = state.stats.totalPruneTokens + const sessionSummaryTokens = Array.from(state.prune.messages.blocksById.values()).reduce( + (total, block) => (block.active ? total + block.summaryTokens : total), + 0, + ) + const sessionDurationMs = getActiveCompressionTargets(state.prune.messages).reduce( + (total, target) => total + target.durationMs, + 0, + ) const prunedToolIds = new Set(state.prune.tools.keys()) for (const block of state.prune.messages.blocksById.values()) { @@ -72,15 +123,24 @@ export async function handleStatsCommand(ctx: StatsCommandContext): Promise { + 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..1664e424 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 { @@ -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 91109612..5d81161e 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 "../strategies/utils" -import { MESSAGE_FORMAT_OVERLAY } from "../prompts/internal-overlays" +import { countTokens } from "../token-utils" +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,26 +43,30 @@ 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[] = [] @@ -107,6 +111,7 @@ export function createCompressMessageTool(ctx: ToolContext): ReturnType { 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/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..cc2ceaa9 100644 --- a/lib/compress/range.ts +++ b/lib/compress/range.ts @@ -1,7 +1,7 @@ import { tool } from "@opencode-ai/plugin" import type { ToolContext } from "./types" -import { countTokens } from "../strategies/utils" -import { RANGE_FORMAT_OVERLAY } from "../prompts/internal-overlays" +import { countTokens } from "../token-utils" +import { RANGE_FORMAT_EXTENSION } from "../prompts/extensions/tool" import { finalizeSession, prepareSession, type NotificationEntry } from "./pipeline" import { appendProtectedTools, appendProtectedUserMessages } from "./protected-content" import { @@ -54,11 +54,15 @@ export function createCompressRangeTool(ctx: ToolContext): ReturnType { diff --git a/lib/compress/state.ts b/lib/compress/state.ts index 6ba6b7e9..5672b1c1 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,29 @@ export function allocateRunId(state: SessionState): number { return next } +export function attachCompressionDuration( + messagesState: PruneMessagesState, + messageId: string, + callId: string, + durationMs: number, +): number { + if (typeof durationMs !== "number" || !Number.isFinite(durationMs)) { + return 0 + } + + let updates = 0 + for (const block of messagesState.blocksById.values()) { + if (block.compressMessageId !== messageId || block.compressCallId !== callId) { + 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)) @@ -93,6 +116,7 @@ export function applyCompressionState( deactivatedByUser: false, compressedTokens: 0, summaryTokens: input.summaryTokens, + durationMs: 0, mode: input.mode, topic: input.topic, batchTopic: input.batchTopic, @@ -100,6 +124,7 @@ export function applyCompressionState( endId: input.endId, anchorMessageId, compressMessageId: input.compressMessageId, + compressCallId: input.compressCallId, includedBlockIds: included, consumedBlockIds: consumed, parentBlockIds: [], diff --git a/lib/compress/timing.ts b/lib/compress/timing.ts new file mode 100644 index 00000000..f99ad612 --- /dev/null +++ b/lib/compress/timing.ts @@ -0,0 +1,77 @@ +import type { SessionState } from "../state/types" +import { attachCompressionDuration } from "./state" + +export interface PendingCompressionDuration { + messageId: string + callId: string + durationMs: number +} + +export interface CompressionTimingState { + startsByCallId: Map + pendingByCallId: Map +} + +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 +} + +export function resolveCompressionDuration( + startedAt: number | 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 = + typeof startedAt === "number" && typeof runningAt === "number" + ? Math.max(0, runningAt - 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 applyPendingCompressionDurations(state: SessionState): number { + if (state.compressionTiming.pendingByCallId.size === 0) { + return 0 + } + + let updates = 0 + 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(key) + } + } + + return updates +} diff --git a/lib/compress/types.ts b/lib/compress/types.ts index 62280193..f0eb5d0c 100644 --- a/lib/compress/types.ts +++ b/lib/compress/types.ts @@ -73,6 +73,7 @@ export interface ResolvedRangeCompression { export interface ResolvedMessageCompressionsResult { plans: ResolvedMessageCompression[] skippedIssues: string[] + skippedCount: number } export interface ParsedBlockPlaceholder { @@ -102,5 +103,6 @@ export interface CompressionStateInput { mode: CompressionMode runId: number compressMessageId: string + compressCallId?: string summaryTokens: number } 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/hooks.ts b/lib/hooks.ts index b8e2ed79..b518617a 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -15,6 +15,13 @@ import { syncCompressionBlocks, } from "./messages" import { renderSystemPrompt, type PromptStore } from "./prompts" +import { buildProtectedToolsExtension } from "./prompts/extensions/system" +import { + applyPendingCompressionDurations, + buildCompressionTimingKey, + consumeCompressionStart, + resolveCompressionDuration, +} from "./compress/timing" import { applyPendingManualTrigger, handleContextCommand, @@ -27,8 +34,8 @@ import { handleSweepCommand, } from "./commands" import { type HostPermissionSnapshot } from "./host-permissions" -import { compressPermission, syncCompressPermissionState } from "./shared-utils" -import { checkSession, ensureSessionInitialized, syncToolCache } from "./state" +import { compressPermission, syncCompressPermissionState } from "./compress-permission" +import { checkSession, ensureSessionInitialized, saveSessionState, syncToolCache } from "./state" import { cacheSystemPromptTokens } from "./ui/utils" const INTERNAL_AGENT_SIGNATURES = [ @@ -75,6 +82,7 @@ export function createSystemPromptHandler( const runtimePrompts = prompts.getRuntimePrompts() const newPrompt = renderSystemPrompt( runtimePrompts, + buildProtectedToolsExtension(config.compress.protectedTools), !!state.manualMode, state.isSubAgent && config.experimental.allowSubAgents, ) @@ -264,6 +272,90 @@ export function createTextCompleteHandler() { } } +export function createEventHandler(state: SessionState, logger: Logger) { + return async (input: { event: any }) => { + 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 + } + + const startedAt = eventTime ?? Date.now() + const key = buildCompressionTimingKey(part.messageID, part.callID) + if (state.compressionTiming.startsByCallId.has(key)) { + return + } + state.compressionTiming.startsByCallId.set(key, startedAt) + logger.debug("Recorded compression start", { + messageID: part.messageID, + callID: part.callID, + startedAt, + }) + return + } + + if (part.state.status === "completed") { + if (typeof part.callID !== "string" || typeof part.messageID !== "string") { + return + } + + 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(key, { + messageId: part.messageID, + callId: part.callID, + durationMs, + }) + + const updates = applyPendingCompressionDurations(state) + if (updates === 0) { + return + } + + await saveSessionState(state, logger) + + logger.info("Attached compression time to blocks", { + messageID: part.messageID, + callID: part.callID, + blocks: updates, + durationMs, + }) + return + } + + if (part.state.status === "running") { + return + } + + if (typeof part.callID === "string" && typeof part.messageID === "string") { + state.compressionTiming.startsByCallId.delete( + buildCompressionTimingKey(part.messageID, part.callID), + ) + } + } +} + export function createChatMessageHandler( state: SessionState, logger: Logger, 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..16599e45 100644 --- a/lib/messages/inject/inject.ts +++ b/lib/messages/inject/inject.ts @@ -4,16 +4,20 @@ 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, appendToLastTextPart, - appendToLastToolPart, + appendToAllToolParts, createSyntheticTextPart, hasContent, - isIgnoredUserMessage, - isProtectedUserMessage, } from "../utils" import { addAnchor, @@ -192,7 +196,7 @@ export const injectMessageIds = ( continue } - if (appendToLastToolPart(message, tag)) { + if (appendToAllToolParts(message, tag)) { continue } diff --git a/lib/messages/inject/utils.ts b/lib/messages/inject/utils.ts index a216c6b6..6d35e4c5 100644 --- a/lib/messages/inject/utils.ts +++ b/lib/messages/inject/utils.ts @@ -1,6 +1,10 @@ import type { SessionState, WithParts } from "../../state" import type { PluginConfig } from "../../config" -import { renderMessagePriorityGuidance } from "../../prompts/message-priority-guidance" +import { + appendGuidanceToDcpTag, + buildCompressedBlockGuidance, + renderMessagePriorityGuidance, +} from "../../prompts/extensions/nudge" import type { RuntimePrompts } from "../../prompts/store" import type { UserMessage } from "@opencode-ai/sdk/v2" import { @@ -13,10 +17,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" @@ -189,38 +192,6 @@ export function addAnchor( return anchorMessageIds.size !== previousSize } -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") -} - -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/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..b4d2120a 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 @@ -108,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 => { @@ -171,34 +160,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/prompts/compress-message.ts b/lib/prompts/compress-message.ts index 94d7ff85..9c41a122 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: @@ -15,9 +16,10 @@ 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. Prefer higher-priority closed messages before lower-priority ones. +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: @@ -25,19 +27,16 @@ 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. +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. -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)\` +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. ` 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: 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/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/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/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/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. 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/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 f401e3de..71cb3aac 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -1,5 +1,6 @@ import type { SessionState, ToolParameterEntry, WithParts } from "./types" import type { Logger } from "../logger" +import { applyPendingCompressionDurations } from "../compress/timing" import { loadSessionState, saveSessionState } from "./persistence" import { isSubAgentSession, @@ -11,7 +12,7 @@ import { loadPruneMap, collectTurnNudgeAnchors, } from "./utils" -import { getLastUserMessage } from "../shared-utils" +import { getLastUserMessage } from "../messages/query" export const checkSession = async ( client: any, @@ -81,6 +82,10 @@ export function createSessionState(): SessionState { pruneTokenCounter: 0, totalPruneTokens: 0, }, + compressionTiming: { + startsByCallId: new Map(), + pendingByCallId: new Map(), + }, toolParameters: new Map(), subAgentResultCache: new Map(), toolIdList: [], @@ -177,4 +182,9 @@ export async function ensureSessionInitialized( pruneTokenCounter: persisted.stats?.pruneTokenCounter || 0, totalPruneTokens: persisted.stats?.totalPruneTokens || 0, } + + const applied = applyPendingCompressionDurations(state) + if (applied > 0) { + await saveSessionState(state, logger) + } } 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/types.ts b/lib/state/types.ts index 67f1e9e5..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 { @@ -36,6 +37,7 @@ export interface CompressionBlock { deactivatedByUser: boolean compressedTokens: number summaryTokens: number + durationMs: number mode?: CompressionMode topic: string batchTopic?: string @@ -43,6 +45,7 @@ export interface CompressionBlock { endId: string anchorMessageId: string compressMessageId: string + compressCallId?: string includedBlockIds: number[] consumedBlockIds: number[] parentBlockIds: number[] @@ -96,6 +99,7 @@ export interface SessionState { prune: Prune nudges: Nudges stats: SessionStats + compressionTiming: CompressionTimingState toolParameters: Map subAgentResultCache: Map toolIdList: string[] diff --git a/lib/state/utils.ts b/lib/state/utils.ts index 08cd2096..a18552a2 100644 --- a/lib/state/utils.ts +++ b/lib/state/utils.ts @@ -5,17 +5,45 @@ 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 - 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 { @@ -168,6 +196,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: @@ -182,6 +214,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/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 77% rename from lib/strategies/utils.ts rename to lib/token-utils.ts index 3136a61c..6d514a64 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 { countTokens as anthropicCountTokens } from "@anthropic-ai/tokenizer" -import { getLastUserMessage } from "../shared-utils" +import { Logger } from "./logger" +import * as anthropicTokenizer from "@anthropic-ai/tokenizer" +import { getLastUserMessage } from "./messages/query" export function getCurrentTokenUsage(state: SessionState, messages: WithParts[]): number { for (let i = messages.length - 1; i >= 0; i--) { @@ -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) } @@ -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/lib/ui/notification.ts b/lib/ui/notification.ts index 9980c551..e65c070c 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) { @@ -257,10 +260,10 @@ export async function sendCompressNotification( sessionMessageIds, activePrunedMessages, newlyCompressedMessageIds, - 70, + 50, ) 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/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/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/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/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() diff --git a/tests/compress-message.test.ts b/tests/compress-message.test.ts index a48a2f9b..ad0ab394 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(), @@ -226,6 +226,55 @@ 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 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) +}) + 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) @@ -525,11 +574,85 @@ 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/) }) +test("compress message mode skips messages that are already actively compressed", async () => { + 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) 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/) }) 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..243a8156 100644 --- a/tests/hooks-permission.test.ts +++ b/tests/hooks-permission.test.ts @@ -5,10 +5,16 @@ import { createChatMessageHandler, createChatMessageTransformHandler, createCommandExecuteHandler, + createEventHandler, 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 { @@ -152,3 +158,517 @@ test("text complete strips hallucinated metadata tags", async () => { assert.equal(output.text, "alpha omega") }) + +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)) + 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) +}) + +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: "call-3", + 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) +}) + +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.pendingByCallId.has("message-1:call-remote"), true) + assert.equal(liveState.compressionTiming.startsByCallId.has("message-1: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.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) +}) diff --git a/tests/message-priority.test.ts b/tests/message-priority.test.ts index ca4ce3a3..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[] = [ { @@ -212,11 +212,17 @@ 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>/) - // Assistant messages: ID injected only once into the last tool output + 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 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>/) }) @@ -274,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), @@ -311,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>/) }) @@ -355,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( 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/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() diff --git a/tests/token-counting.test.ts b/tests/token-counting.test.ts index dff1ff7b..03e9439c 100644 --- a/tests/token-counting.test.ts +++ b/tests/token-counting.test.ts @@ -2,11 +2,13 @@ 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/strategies/utils" +} from "../lib/token-utils" function buildToolMessage(part: Record): WithParts { return { @@ -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]) +}) 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 {