diff --git a/devlog/2026-06-29_merge-blocks/REQ.md b/devlog/2026-06-29_merge-blocks/REQ.md new file mode 100644 index 0000000..2fdad5a --- /dev/null +++ b/devlog/2026-06-29_merge-blocks/REQ.md @@ -0,0 +1,38 @@ +# REQ: merge-blocks Command + +## Problem +Sessions accumulate many small compressed blocks (e.g., 435 blocks in model-editing session). The model can't easily merge them because: +- It doesn't know compress can cover existing blocks +- mark_block is deferred + one-at-a-time (8 calls for 8 blocks) +- It doesn't know block message ranges +- Eventually gives up and focuses on actual task + +## Solution +Three changes: + +### 1. `/acp merge-blocks` Command +Usage: `/acp merge-blocks 421-428` or `/acp merge-blocks 421,422,423` +- Parses block IDs from args (ranges + lists) +- Looks up blocks in state.prune.messages.blocksById +- Finds message range from anchorMessageId fields +- Creates merged summary by concatenating block topics + summaries +- Creates new CompressionBlock covering the entire range +- Sets consumedBlockIds to deactivate old blocks +- Calls syncCompressionBlocks to apply + +### 2. Enhanced Block List Display +When listing blocks (โ‰ค20 case), include topic per block: +`b1: "Proxy cost analysis", b2: "Awork deployment"` + +### 3. Nudge Text Update +When blockCount > 50, guide to use merge-blocks: +`๐Ÿ”€ Use /acp merge-blocks to merge adjacent blocks.` + +## Acceptance Criteria +- [x] `/acp merge-blocks` parses ranges and lists +- [x] Merged block deactivates old blocks via consumedBlockIds +- [x] Enhanced block list shows topics +- [x] Nudge text guides to merge-blocks when >50 blocks +- [x] typecheck clean +- [x] tests pass +- [x] build success diff --git a/devlog/2026-06-29_merge-blocks/WORKLOG.md b/devlog/2026-06-29_merge-blocks/WORKLOG.md new file mode 100644 index 0000000..2fcd572 --- /dev/null +++ b/devlog/2026-06-29_merge-blocks/WORKLOG.md @@ -0,0 +1,26 @@ +# WORKLOG: merge-blocks Command + +## Branch +`ranxianglei/2026-06-29_merge-blocks` from master + +## Changes + +### New File +- `lib/commands/merge-blocks.ts` (350 lines) โ€” merge-blocks command implementation + - Parses block ID args (ranges: `421-428`, lists: `421,422,423`) + - Resolves blocks from state.prune.messages.blocksById + - Creates merged summary from block topics + summaries + - Creates new CompressionBlock with consumedBlockIds + - Calls syncCompressionBlocks to deactivate old blocks + +### Modified Files +- `lib/commands/help.ts` โ€” Added merge-blocks to help text +- `lib/commands/index.ts` โ€” Registered merge-blocks command +- `lib/hooks.ts` โ€” Wired merge-blocks command handler +- `lib/prompts/extensions/nudge.ts` โ€” Enhanced block list (show topics) + merge guidance (>50 blocks) +- `tests/nudge-text.test.ts` โ€” Updated tests for enhanced block list + +## Verification +- typecheck: clean (exit 0) +- tests: all pass (0 fail) +- build: success diff --git a/index.ts b/index.ts index 87a1028..9b50da6 100644 --- a/index.ts +++ b/index.ts @@ -5,6 +5,7 @@ import { createCompressRangeTool, createDecompressTool, createMarkBlockTool, + createMergeBlocksTool, createUnmarkBlockTool, } from "./lib/compress" import { @@ -93,6 +94,7 @@ const server: Plugin = (async (ctx) => { decompress: createDecompressTool(compressToolContext), mark_block: createMarkBlockTool(compressToolContext), unmark_block: createUnmarkBlockTool(compressToolContext), + merge_blocks: createMergeBlocksTool(compressToolContext), }), }, config: async (opencodeConfig) => { @@ -113,7 +115,7 @@ const server: Plugin = (async (ctx) => { const toolsToAdd: string[] = [] if (config.compress.permission !== "deny" && !config.experimental.allowSubAgents) { - toolsToAdd.push("compress", "decompress", "mark_block", "unmark_block") + toolsToAdd.push("compress", "decompress", "mark_block", "unmark_block", "merge_blocks") } if (toolsToAdd.length > 0) { diff --git a/lib/commands/help.ts b/lib/commands/help.ts index 4391b5d..619b810 100644 --- a/lib/commands/help.ts +++ b/lib/commands/help.ts @@ -30,6 +30,7 @@ const TOOL_COMMANDS: Record = { compress: ["/acp compress [focus]", "Trigger manual compress tool execution"], decompress: ["/acp decompress ", "Restore selected compression"], recompress: ["/acp recompress ", "Re-apply a user-decompressed compression"], + "merge-blocks": ["/acp merge-blocks ", "Merge adjacent compressed blocks into one"], } function getVisibleCommands(state: SessionState, config: PluginConfig): [string, string][] { @@ -39,6 +40,7 @@ function getVisibleCommands(state: SessionState, config: PluginConfig): [string, commands.push(TOOL_COMMANDS.compress) commands.push(TOOL_COMMANDS.decompress) commands.push(TOOL_COMMANDS.recompress) + commands.push(TOOL_COMMANDS["merge-blocks"]!) } return commands diff --git a/lib/commands/index.ts b/lib/commands/index.ts index 993420b..324d7e8 100644 --- a/lib/commands/index.ts +++ b/lib/commands/index.ts @@ -6,6 +6,7 @@ export { handleManualToggleCommand, handleManualTriggerCommand, } from "./manual" +export { handleMergeBlocksCommand } from "./merge-blocks" export { handleRecompressCommand } from "./recompress" export { handleStatsCommand } from "./stats" export { handleSweepCommand } from "./sweep" diff --git a/lib/commands/merge-blocks.ts b/lib/commands/merge-blocks.ts new file mode 100644 index 0000000..3ea5b33 --- /dev/null +++ b/lib/commands/merge-blocks.ts @@ -0,0 +1,350 @@ +import type { Logger } from "../logger" +import type { CompressionBlock, SessionState, WithParts } from "../state" +import { syncCompressionBlocks } from "../messages" +import { parseBlockRef, formatBlockRef } from "../message-ids" +import { countTokens, getCurrentParams } from "../token-utils" +import { saveSessionState } from "../state/persistence" +import { sendIgnoredMessage } from "../ui/notification" +import { formatTokenCount } from "../ui/utils" +import { + allocateBlockId, + allocateRunId, + wrapCompressedSummary, + COMPRESSED_BLOCK_HEADER, +} from "../compress/state" + +export interface MergeBlocksCommandContext { + client: any + state: SessionState + logger: Logger + sessionId: string + messages: WithParts[] + args: string[] +} + +function parseBlockIdToken(token: string): number[] | null { + const normalized = token.trim().toLowerCase() + if (normalized.length === 0) { + return null + } + + if (normalized.includes("-")) { + const parts = normalized.split("-") + if (parts.length !== 2) { + return null + } + const start = parseSingleBlockId(parts[0]!) + const end = parseSingleBlockId(parts[1]!) + if (start === null || end === null || end < start) { + return null + } + const ids: number[] = [] + for (let id = start; id <= end; id++) { + ids.push(id) + } + return ids + } + + if (normalized.includes(",")) { + const parts = normalized.split(",").map((p) => p.trim()).filter(Boolean) + if (parts.length === 0) { + return null + } + const ids: number[] = [] + for (const part of parts) { + const id = parseSingleBlockId(part) + if (id === null) { + return null + } + ids.push(id) + } + return ids + } + + const id = parseSingleBlockId(normalized) + if (id === null) { + return null + } + return [id] +} + +function parseSingleBlockId(value: string): number | null { + const normalized = value.trim().toLowerCase() + if (normalized.length === 0) { + return null + } + const blockRef = parseBlockRef(normalized) + if (blockRef !== null) { + return blockRef + } + if (!/^[1-9]\d*$/.test(normalized)) { + return null + } + const parsed = Number.parseInt(normalized, 10) + return Number.isInteger(parsed) && parsed > 0 ? parsed : null +} + +function extractSummaryBody(summary: string): string { + let body = summary + const headerPrefix = COMPRESSED_BLOCK_HEADER + "\n" + if (body.startsWith(headerPrefix)) { + body = body.slice(headerPrefix.length) + } + body = body.replace(/\n]*>b\d+<\/dcp-message-id>$/, "") + return body.trim() +} + +function formatRangeOrList(ids: number[]): string { + if (ids.length === 0) { + return "" + } + if (ids.length === 1) { + return formatBlockRef(ids[0]!) + } + let contiguous = true + for (let i = 1; i < ids.length; i++) { + if (ids[i]! - ids[i - 1]! !== 1) { + contiguous = false + break + } + } + if (contiguous) { + return `${formatBlockRef(ids[0]!)}-${formatBlockRef(ids[ids.length - 1]!)}` + } + return ids.map((id) => formatBlockRef(id)).join(", ") +} + +function formatMergeMessage( + mergedCount: number, + sourceIds: number[], + newBlockId: number, + savedTokens: number, + sourceTokens: number, + newTokens: number, +): string { + const lines: string[] = [] + const rangeLabel = formatRangeOrList(sourceIds) + lines.push(`Merged ${mergedCount} block${mergedCount === 1 ? "" : "s"} (${rangeLabel}) into ${formatBlockRef(newBlockId)}.`) + lines.push( + `Summary: ~${formatTokenCount(savedTokens)} saved (~${formatTokenCount(sourceTokens)} โ†’ ~${formatTokenCount(newTokens)}).`, + ) + lines.push(`Deactivated: ${sourceIds.map((id) => formatBlockRef(id)).join(", ")}.`) + return lines.join("\n") +} + +export async function handleMergeBlocksCommand(ctx: MergeBlocksCommandContext): Promise { + const { client, state, logger, sessionId, messages, args } = ctx + + const params = getCurrentParams(state, messages, logger) + + if (args.length === 0) { + await sendIgnoredMessage( + client, + sessionId, + "Usage: /acp merge-blocks \nExamples: /acp merge-blocks 421-428 ยท /acp merge-blocks 421,422,423", + params, + logger, + ) + return + } + + if (args.length > 1) { + await sendIgnoredMessage( + client, + sessionId, + "Invalid arguments. Usage: /acp merge-blocks ", + params, + logger, + ) + return + } + + const requestedIds = parseBlockIdToken(args[0]!) + if (requestedIds === null || requestedIds.length === 0) { + await sendIgnoredMessage( + client, + sessionId, + "Could not parse block ids. Usage: /acp merge-blocks 421-428 or /acp merge-blocks 421,422,423", + params, + logger, + ) + return + } + + syncCompressionBlocks(state, logger, messages) + const messagesState = state.prune.messages + + const sourceBlocks: CompressionBlock[] = [] + const missingIds: number[] = [] + const inactiveIds: number[] = [] + const seen = new Set() + + for (const id of requestedIds) { + if (seen.has(id)) { + continue + } + seen.add(id) + const block = messagesState.blocksById.get(id) + if (!block) { + missingIds.push(id) + continue + } + if (!block.active) { + inactiveIds.push(id) + continue + } + sourceBlocks.push(block) + } + + if (missingIds.length > 0) { + const refs = missingIds.map((id) => formatBlockRef(id)).join(", ") + await sendIgnoredMessage( + client, + sessionId, + `Block${missingIds.length === 1 ? "" : "s"} ${refs} not found.`, + params, + logger, + ) + return + } + + if (inactiveIds.length > 0) { + const refs = inactiveIds.map((id) => formatBlockRef(id)).join(", ") + await sendIgnoredMessage( + client, + sessionId, + `Block${inactiveIds.length === 1 ? "" : "s"} ${refs} not active. Only active blocks can be merged.`, + params, + logger, + ) + return + } + + if (sourceBlocks.length < 2) { + await sendIgnoredMessage( + client, + sessionId, + "Need at least two blocks to merge. Nothing to do.", + params, + logger, + ) + return + } + + sourceBlocks.sort((a, b) => a.blockId - b.blockId) + const sourceIds = sourceBlocks.map((block) => block.blockId) + + // Each source contributes a header carrying the (bN) placeholder so the + // merged summary can be re-summarized later without losing block lineage. + const sections = sourceBlocks.map((block) => { + const topic = (block.topic || "(no topic)").replace(/\s+/g, " ").trim() + const body = extractSummaryBody(block.summary) + const header = `(b${block.blockId}) ${topic}` + return body.length > 0 ? `${header}\n${body}` : header + }) + const mergedBody = sections.join("\n---\n") + + const newBlockId = allocateBlockId(state) + const newSummary = wrapCompressedSummary(newBlockId, mergedBody) + const newSummaryTokens = countTokens(newSummary) + + const oldest = sourceBlocks[0]! + const newest = sourceBlocks[sourceBlocks.length - 1]! + + const effectiveMessageIds = new Set() + const effectiveToolIds = new Set() + for (const block of sourceBlocks) { + for (const id of block.effectiveMessageIds) effectiveMessageIds.add(id) + for (const id of block.effectiveToolIds) effectiveToolIds.add(id) + } + + const sourceTokens = sourceBlocks.reduce( + (sum, block) => sum + (block.summaryTokens || Math.round(block.summary.length / 4)), + 0, + ) + + const mergedTopic = buildMergedTopic(sourceBlocks) + const createdAt = Date.now() + + const mergedBlock: CompressionBlock = { + blockId: newBlockId, + runId: allocateRunId(state), + active: true, + deactivatedByUser: false, + compressedTokens: 0, + summaryTokens: newSummaryTokens, + durationMs: 0, + mode: "range", + topic: mergedTopic, + batchTopic: mergedTopic, + startId: oldest.startId, + endId: newest.endId, + anchorMessageId: oldest.anchorMessageId, + compressMessageId: "", + compressCallId: undefined, + includedBlockIds: [...sourceIds], + consumedBlockIds: [...sourceIds], + parentBlockIds: [], + directMessageIds: [], + directToolIds: [], + effectiveMessageIds: [...effectiveMessageIds], + effectiveToolIds: [...effectiveToolIds], + createdAt, + summary: newSummary, + survivedCount: 0, + generation: "old", + } + + // Insert the merged block before deactivating sources so syncCompressionBlocks + // can wire up activeByAnchorMessageId / activeBlockIds correctly via the + // consumedBlockIds mechanism. + messagesState.blocksById.set(newBlockId, mergedBlock) + + syncCompressionBlocks(state, logger, messages) + + const savedTokens = Math.max(0, sourceTokens - newSummaryTokens) + + await saveSessionState(state, logger) + + const message = formatMergeMessage( + sourceBlocks.length, + sourceIds, + newBlockId, + savedTokens, + sourceTokens, + newSummaryTokens, + ) + await sendIgnoredMessage(client, sessionId, message, params, logger) + + logger.info("Merge-blocks command completed", { + newBlockId, + sourceBlockIds: sourceIds, + savedTokens, + sourceTokens, + newSummaryTokens, + }) +} + +/** + * Build a concise topic for the merged block. When all sources share the same + * topic, reuse it; otherwise join the unique topics with " + ". + */ +function buildMergedTopic(sourceBlocks: CompressionBlock[]): string { + const uniqueTopics: string[] = [] + const seen = new Set() + for (const block of sourceBlocks) { + const topic = (block.topic || "(no topic)").replace(/\s+/g, " ").trim() + if (topic.length === 0 || seen.has(topic)) { + continue + } + seen.add(topic) + uniqueTopics.push(topic) + } + if (uniqueTopics.length === 0) { + return "Merged blocks" + } + if (uniqueTopics.length === 1) { + return uniqueTopics[0]! + } + return `Merged: ${uniqueTopics.join(" + ")}` +} diff --git a/lib/compress/index.ts b/lib/compress/index.ts index b4fe6e7..7c6405f 100644 --- a/lib/compress/index.ts +++ b/lib/compress/index.ts @@ -3,3 +3,4 @@ export { createCompressMessageTool } from "./message" export { createCompressRangeTool } from "./range" export { createDecompressTool } from "./decompress" export { createMarkBlockTool, createUnmarkBlockTool } from "./mark-block" +export { createMergeBlocksTool } from "./merge-blocks" diff --git a/lib/compress/merge-blocks.ts b/lib/compress/merge-blocks.ts new file mode 100644 index 0000000..38610ed --- /dev/null +++ b/lib/compress/merge-blocks.ts @@ -0,0 +1,342 @@ +import { tool } from "@opencode-ai/plugin" +import type { ToolContext } from "./types" +import type { CompressionBlock, SessionState, WithParts } from "../state" +import type { Logger } from "../logger" +import { ensureSessionInitialized } from "../state" +import { saveSessionState } from "../state/persistence" +import { assignMessageRefs, formatBlockRef, parseBlockRef } from "../message-ids" +import { fetchSessionMessages } from "./search" +import { syncCompressionBlocks } from "../messages" +import { countTokens } from "../token-utils" +import { + allocateBlockId, + allocateRunId, + wrapCompressedSummary, + COMPRESSED_BLOCK_HEADER, +} from "./state" + +function parseSingleBlockId(value: string): number | null { + const normalized = value.trim().toLowerCase() + if (normalized.length === 0) { + return null + } + const blockRef = parseBlockRef(normalized) + if (blockRef !== null) { + return blockRef + } + if (!/^[1-9]\d*$/.test(normalized)) { + return null + } + const parsed = Number.parseInt(normalized, 10) + return Number.isInteger(parsed) && parsed > 0 ? parsed : null +} + +function parseBlockIdToken(token: string): number[] | null { + const normalized = token.trim().toLowerCase() + if (normalized.length === 0) { + return null + } + + const commaParts = normalized.split(",").map((p) => p.trim()).filter(Boolean) + if (commaParts.length === 0) { + return null + } + + const ids: number[] = [] + for (const part of commaParts) { + if (part.includes("-")) { + const rangeParts = part.split("-") + if (rangeParts.length !== 2) { + return null + } + const start = parseSingleBlockId(rangeParts[0]!) + const end = parseSingleBlockId(rangeParts[1]!) + if (start === null || end === null || end < start) { + return null + } + for (let id = start; id <= end; id++) { + ids.push(id) + } + } else { + const id = parseSingleBlockId(part) + if (id === null) { + return null + } + ids.push(id) + } + } + + return [...new Set(ids)].sort((a, b) => a - b) +} + +// --- Summary helpers --- + +function extractSummaryBody(summary: string): string { + let body = summary + const headerPrefix = COMPRESSED_BLOCK_HEADER + "\n" + if (body.startsWith(headerPrefix)) { + body = body.slice(headerPrefix.length) + } + body = body.replace(/\n]*>b\d+<\/dcp-message-id>$/, "") + return body.trim() +} + +function buildMergedTopic(sourceBlocks: CompressionBlock[]): string { + const uniqueTopics: string[] = [] + const seen = new Set() + for (const block of sourceBlocks) { + const topic = (block.topic || "(no topic)").replace(/\s+/g, " ").trim() + if (topic.length === 0 || seen.has(topic)) { + continue + } + seen.add(topic) + uniqueTopics.push(topic) + } + if (uniqueTopics.length === 0) { + return "Merged blocks" + } + if (uniqueTopics.length === 1) { + return uniqueTopics[0]! + } + return `Merged: ${uniqueTopics.join(" + ")}` +} + +function formatRangeOrList(ids: number[]): string { + if (ids.length === 0) { + return "" + } + if (ids.length === 1) { + return formatBlockRef(ids[0]!) + } + let contiguous = true + for (let i = 1; i < ids.length; i++) { + if (ids[i]! - ids[i - 1]! !== 1) { + contiguous = false + break + } + } + if (contiguous) { + return `${formatBlockRef(ids[0]!)}-${formatBlockRef(ids[ids.length - 1]!)}` + } + return ids.map((id) => formatBlockRef(id)).join(", ") +} + +function formatTokenCount(tokens: number): string { + return tokens >= 1000 ? `${(tokens / 1000).toFixed(1).replace(".0", "")}K` : `${tokens}` +} + +const MERGE_BLOCKS_DESCRIPTION = `Merge multiple compressed blocks into a single block. + +Use this when you have many small compressed blocks covering related topics. +Merging reduces block count and total summary overhead. + +Argument: blockIds โ€” block references to merge. Supports ranges ("b421-b428"), +comma-separated lists ("b421,b422,b423"), and mixed ("b398-b407,b416-b419"). + +Argument: summary โ€” OPTIONAL but recommended. Write a SHORT unified summary +that captures the key information from all source blocks. This produces much +better compression than auto-concatenation. Include (bN) placeholders for +any source blocks referenced in the summary. If omitted, summaries are +auto-concatenated (less effective). + +All source blocks must be active. After merge, source blocks are deactivated +and a new combined block is created. You can still decompress source blocks +later if needed (before GC). + +Example: merge_blocks with blockIds "b12-b14" and summary "## ACP optimization +results\\nCombined findings from (b12), (b13), (b14)..."` + +function buildSchema() { + return { + blockIds: tool.schema + .string() + .describe( + 'Block references to merge. Supports ranges ("b421-b428"), lists ("b421,b422,b423"), and mixed ("b398-b407,b416-b419").', + ), + summary: tool.schema + .string() + .optional() + .describe( + "Short unified summary covering all source blocks. Include (bN) placeholders for referenced blocks. If omitted, auto-concatenated (less effective).", + ), + } +} + +interface RunContext { + ask(input: { + permission: string + patterns: string[] + always: string[] + metadata: Record + }): Promise + metadata(input: { title: string }): void + sessionID: string + messageID: string +} + +async function prepareMergeSession( + ctx: ToolContext, + toolCtx: RunContext, +): Promise { + await toolCtx.ask({ + permission: "compress", + patterns: ["*"], + always: ["*"], + metadata: {}, + }) + + toolCtx.metadata({ title: "Merge blocks" }) + + const rawMessages = await fetchSessionMessages(ctx.client, toolCtx.sessionID) + + await ensureSessionInitialized( + ctx.client, + ctx.state, + toolCtx.sessionID, + ctx.logger, + rawMessages, + ctx.config.manualMode.enabled, + ) + + assignMessageRefs(ctx.state, rawMessages) + return rawMessages +} + +export function createMergeBlocksTool(ctx: ToolContext): ReturnType { + return tool({ + description: MERGE_BLOCKS_DESCRIPTION, + args: buildSchema(), + async execute(args, toolCtx) { + const rawMessages = await prepareMergeSession(ctx, toolCtx as RunContext) + + const requestedIds = parseBlockIdToken(String(args.blockIds)) + if (requestedIds === null || requestedIds.length === 0) { + return `Error: Could not parse block IDs "${args.blockIds}". Use format "b421-b428" or "b421,b422,b423".` + } + + syncCompressionBlocks(ctx.state, ctx.logger, rawMessages) + const messagesState = ctx.state.prune.messages + + const sourceBlocks: CompressionBlock[] = [] + const missingIds: number[] = [] + const inactiveIds: number[] = [] + const seen = new Set() + + for (const id of requestedIds) { + if (seen.has(id)) { + continue + } + seen.add(id) + const block = messagesState.blocksById.get(id) + if (!block) { + missingIds.push(id) + continue + } + if (!block.active) { + inactiveIds.push(id) + continue + } + sourceBlocks.push(block) + } + + if (missingIds.length > 0) { + const refs = missingIds.map((id) => formatBlockRef(id)).join(", ") + return `Error: Block${missingIds.length === 1 ? "" : "s"} ${refs} not found.` + } + + if (inactiveIds.length > 0) { + const refs = inactiveIds.map((id) => formatBlockRef(id)).join(", ") + return `Error: Block${inactiveIds.length === 1 ? "" : "s"} ${refs} not active. Only active blocks can be merged.` + } + + if (sourceBlocks.length < 2) { + return `Need at least two blocks to merge. Nothing to do.` + } + + sourceBlocks.sort((a, b) => a.blockId - b.blockId) + const sourceIds = sourceBlocks.map((block) => block.blockId) + + const mergedBody = args.summary + ? String(args.summary) + : sourceBlocks.map((block) => { + const topic = (block.topic || "(no topic)").replace(/\s+/g, " ").trim() + const body = extractSummaryBody(block.summary) + const header = `(b${block.blockId}) ${topic}` + return body.length > 0 ? `${header}\n${body}` : header + }).join("\n---\n") + + const newBlockId = allocateBlockId(ctx.state) + const newSummary = wrapCompressedSummary(newBlockId, mergedBody) + const newSummaryTokens = countTokens(newSummary) + + const oldest = sourceBlocks[0]! + const newest = sourceBlocks[sourceBlocks.length - 1]! + + const effectiveMessageIds = new Set() + const effectiveToolIds = new Set() + for (const block of sourceBlocks) { + for (const id of block.effectiveMessageIds) effectiveMessageIds.add(id) + for (const id of block.effectiveToolIds) effectiveToolIds.add(id) + } + + const sourceTokens = sourceBlocks.reduce( + (sum, block) => sum + (block.summaryTokens || Math.round(block.summary.length / 4)), + 0, + ) + + const mergedTopic = buildMergedTopic(sourceBlocks) + const createdAt = Date.now() + + const mergedBlock: CompressionBlock = { + blockId: newBlockId, + runId: allocateRunId(ctx.state), + active: true, + deactivatedByUser: false, + compressedTokens: 0, + summaryTokens: newSummaryTokens, + durationMs: 0, + mode: "range", + topic: mergedTopic, + batchTopic: mergedTopic, + startId: oldest.startId, + endId: newest.endId, + anchorMessageId: oldest.anchorMessageId, + compressMessageId: toolCtx.messageID, + compressCallId: undefined, + includedBlockIds: [...sourceIds], + consumedBlockIds: [...sourceIds], + parentBlockIds: [], + directMessageIds: [], + directToolIds: [], + effectiveMessageIds: [...effectiveMessageIds], + effectiveToolIds: [...effectiveToolIds], + createdAt, + summary: newSummary, + survivedCount: 0, + generation: "old", + } + + messagesState.blocksById.set(newBlockId, mergedBlock) + syncCompressionBlocks(ctx.state, ctx.logger, rawMessages) + + const savedTokens = Math.max(0, sourceTokens - newSummaryTokens) + await saveSessionState(ctx.state, ctx.logger) + + ctx.logger.info("merge_blocks tool completed", { + newBlockId, + sourceBlockIds: sourceIds, + savedTokens, + sourceTokens, + newSummaryTokens, + }) + + const rangeLabel = formatRangeOrList(sourceIds) + const lines: string[] = [ + `Merged ${sourceBlocks.length} block${sourceBlocks.length === 1 ? "" : "s"} (${rangeLabel}) into ${formatBlockRef(newBlockId)}.`, + `Summary: ~${formatTokenCount(savedTokens)} saved (~${formatTokenCount(sourceTokens)} โ†’ ~${formatTokenCount(newSummaryTokens)}).`, + `Deactivated: ${sourceIds.map((id) => formatBlockRef(id)).join(", ")}.`, + ] + return lines.join("\n") + }, + }) +} diff --git a/lib/hooks.ts b/lib/hooks.ts index ff083d4..e30d9f7 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -32,6 +32,7 @@ import { handleHelpCommand, handleManualToggleCommand, handleManualTriggerCommand, + handleMergeBlocksCommand, handleRecompressCommand, handleStatsCommand, handleSweepCommand, @@ -407,6 +408,14 @@ export function createCommandExecuteHandler( throw new Error("__DCP_RECOMPRESS_HANDLED__") } + if (subcommand === "merge-blocks") { + await handleMergeBlocksCommand({ + ...commandCtx, + args: subArgs, + }) + throw new Error("__DCP_MERGE_BLOCKS_HANDLED__") + } + await handleHelpCommand(commandCtx) throw new Error("__DCP_HELP_HANDLED__") } diff --git a/lib/prompts/extensions/nudge.ts b/lib/prompts/extensions/nudge.ts index a260c6c..f94ff3b 100644 --- a/lib/prompts/extensions/nudge.ts +++ b/lib/prompts/extensions/nudge.ts @@ -7,6 +7,17 @@ export interface BlockGuidanceContext { includeHint?: boolean } +const MERGE_NUDGE_THRESHOLD = 50 + +function summarizeTokensForBlock(block: CompressionBlock): number { + return block.summaryTokens || Math.round(block.summary.length / 4) +} + +function quoteTopic(topic: string | undefined): string { + const trimmed = (topic || "").replace(/\s+/g, " ").trim() + return `"${trimmed.length > 0 ? trimmed : "(no topic)"}"` +} + export function buildCompressedBlockGuidance( state: SessionState, gcConfig?: GCConfig, @@ -16,21 +27,48 @@ export function buildCompressedBlockGuidance( .filter((id) => Number.isInteger(id) && id > 0) .sort((a, b) => a - b) - const refs = activeBlockIds.map((id) => `b${id}`) - const blockCount = refs.length + const blockCount = activeBlockIds.length let blockList: string + let savingsSuffix = "" + if (blockCount <= 20) { - blockList = blockCount > 0 ? refs.join(", ") : "none" + if (blockCount === 0) { + blockList = "none" + } else { + const entries = activeBlockIds.map((id) => { + const block = state.prune.messages.blocksById.get(id) + return `b${id}: ${quoteTopic(block?.topic)}` + }) + blockList = entries.join(", ") + } } else { - const recent = refs.slice(-20).join(", ") - blockList = `${recent} (+${blockCount - 20} older, use decompress to access by ID)` + const recentIds = activeBlockIds.slice(-20) + const recentFirst = recentIds[0]! + const recentLast = recentIds[recentIds.length - 1]! + const olderCount = blockCount - recentIds.length + const recentRange = + recentIds.length > 1 && recentLast - recentFirst === recentIds.length - 1 + ? `b${recentFirst}-b${recentLast}` + : recentIds.map((id) => `b${id}`).join(", ") + const olderLabel = olderCount > 0 ? ` + ${olderCount} older` : "" + blockList = `${recentRange}${olderLabel}` + + let totalTokens = 0 + for (const id of activeBlockIds) { + const block = state.prune.messages.blocksById.get(id) + if (block) { + totalTokens += summarizeTokensForBlock(block) + } + } + const totalK = totalTokens >= 1000 ? `${(totalTokens / 1000).toFixed(1).replace(".0", "")}K` : `${totalTokens}` + savingsSuffix = `. Total: ~${totalK} tokens compressed` } const includeHint = context?.includeHint ?? true const lines = [ "Compressed block context:", - `- Active compressed blocks: ${blockCount} (${blockList})`, + `- Active compressed blocks: ${blockCount} (${blockList}${savingsSuffix})`, "- If your selected compression range includes any listed block, include each required placeholder exactly once in the summary using `(bN)`.", ] @@ -38,8 +76,10 @@ export function buildCompressedBlockGuidance( lines.push("- ๐Ÿ’ก When you've finished using tool outputs, compress them โ€” you can decompress later if needed. Lean context improves accuracy.") } - if (blockCount > 50) { - lines.push(`- ๐Ÿ”€ You have ${blockCount} blocks โ€” consider merging adjacent same-topic blocks instead of finding new content to compress. This permanently reduces per-turn overhead.`) + if (blockCount > MERGE_NUDGE_THRESHOLD) { + lines.push( + `- ๐Ÿ”€ You have ${blockCount} blocks โ€” use the merge_blocks tool to merge adjacent same-topic blocks. Example: merge_blocks with blockIds "${activeBlockIds[0]}-${activeBlockIds[1]}".`, + ) } // [FIX Bug 35] Only show aging warnings when context usage is above 50%. diff --git a/tests/nudge-text.test.ts b/tests/nudge-text.test.ts index abb7aee..a30c8c8 100644 --- a/tests/nudge-text.test.ts +++ b/tests/nudge-text.test.ts @@ -71,31 +71,112 @@ test("CONTEXT_LIMIT_NUDGE frames compression as a step with decompress safety ne assert.doesNotMatch(CONTEXT_LIMIT_NUDGE, /\b(MUST|CRITICAL)\b/) }) -test("buildCompressedBlockGuidance lists every active block ID when there are 20 or fewer", () => { +test("buildCompressedBlockGuidance lists every active block ID with topic when there are 20 or fewer", () => { const state = createSessionState() for (const id of [1, 2, 3]) { state.prune.messages.activeBlockIds.add(id) + state.prune.messages.blocksById.set(id, { + blockId: id, + runId: id, + active: true, + deactivatedByUser: false, + compressedTokens: 100, + summaryTokens: 100, + durationMs: 0, + mode: "range", + topic: `topic-${id}`, + startId: "m0", + endId: "m5", + anchorMessageId: `a-${id}`, + compressMessageId: `c-${id}`, + compressCallId: undefined, + includedBlockIds: [], + consumedBlockIds: [], + parentBlockIds: [], + directMessageIds: [], + directToolIds: [], + effectiveMessageIds: [], + effectiveToolIds: [], + createdAt: 1000, + deactivatedAt: undefined, + deactivatedByBlockId: undefined, + summary: "summary body", + survivedCount: 0, + generation: "young", + }) } const guidance = buildCompressedBlockGuidance(state) - assert.match(guidance, /b1, b2, b3/) + assert.match(guidance, /b1: "topic-1", b2: "topic-2", b3: "topic-3"/) assert.doesNotMatch(guidance, /older, use decompress to access by ID/) }) -test("buildCompressedBlockGuidance summarizes older blocks when there are more than 20 active", () => { +test("buildCompressedBlockGuidance summarizes older blocks with range + savings when there are more than 20 active", () => { const state = createSessionState() // 25 blocks (ids 1..25): the 20 most recent are 6..25, leaving 5 older. for (let id = 1; id <= 25; id++) { state.prune.messages.activeBlockIds.add(id) + state.prune.messages.blocksById.set(id, { + blockId: id, + runId: id, + active: true, + deactivatedByUser: false, + compressedTokens: 100, + summaryTokens: 200, + durationMs: 0, + mode: "range", + topic: `t-${id}`, + startId: "m0", + endId: "m5", + anchorMessageId: `a-${id}`, + compressMessageId: `c-${id}`, + compressCallId: undefined, + includedBlockIds: [], + consumedBlockIds: [], + parentBlockIds: [], + directMessageIds: [], + directToolIds: [], + effectiveMessageIds: [], + effectiveToolIds: [], + createdAt: 1000, + deactivatedAt: undefined, + deactivatedByBlockId: undefined, + summary: "summary body", + survivedCount: 0, + generation: "young", + }) } const guidance = buildCompressedBlockGuidance(state) - assert.match(guidance, /\(\+5 older, use decompress to access by ID\)/) + assert.match(guidance, /b6-b25 \+ 5 older\. Total: ~5K tokens compressed/) assert.match(guidance, /b25/) }) +test("buildCompressedBlockGuidance shows merge hint when more than 50 blocks active", () => { + const state = createSessionState() + for (let id = 1; id <= 60; id++) { + state.prune.messages.activeBlockIds.add(id) + } + + const guidance = buildCompressedBlockGuidance(state) + + assert.match(guidance, /merge_blocks tool/) + assert.match(guidance, /You have 60 blocks/) +}) + +test("buildCompressedBlockGuidance omits merge hint when block count is at or below 50", () => { + const state = createSessionState() + for (let id = 1; id <= 50; id++) { + state.prune.messages.activeBlockIds.add(id) + } + + const guidance = buildCompressedBlockGuidance(state) + + assert.doesNotMatch(guidance, /merge_blocks/) +}) + test("buildContextUsageGuidance low tier says 'Be frugal' and leaks no threshold numbers", () => { const guidance = buildContextUsageGuidance(buildConfig(), LOW_USAGE, MODEL_CONTEXT_LIMIT)