From c7fb04d31b50ab928634f7b9a844f179a6380c9e Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Thu, 9 Jul 2026 20:27:05 +0800 Subject: [PATCH 01/12] feat: acp_status now shows visible context breakdown + compressed blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit acp_status previously only showed the compressed block list. The model had no way to see what's consuming context in the visible (uncompressed) portion — it could only see this via the automatic context breakdown injection, which fires intermittently. Now acp_status returns two sections: 1. VISIBLE CONTEXT — token breakdown by category (tool/code/text/summaries) with percentages. In detailed mode, also shows largest items per category (same data as the inject.ts breakdown). 2. COMPRESSED BLOCKS — the existing block list (unchanged). The visible breakdown uses estimateContextComposition() from inject/utils, reusing the same logic that powers the automatic nudge injection. System prompt updated to describe the new dual-section output. Tests updated to match new header format. --- lib/compress/status.ts | 111 ++++++++++++++++++++++++++++++++++----- lib/prompts/system.ts | 4 +- tests/acp-status.test.ts | 8 +-- 3 files changed, 104 insertions(+), 19 deletions(-) diff --git a/lib/compress/status.ts b/lib/compress/status.ts index 4a02bf7..ab8ad42 100644 --- a/lib/compress/status.ts +++ b/lib/compress/status.ts @@ -1,18 +1,25 @@ import { tool } from "@opencode-ai/plugin" import type { ToolContext } from "./types" import { formatAge } from "../ui/utils" -import type { CompressionBlock } from "../state/types" +import type { CompressionBlock, WithParts } from "../state/types" +import { estimateContextComposition, type ContextComposition } from "../messages/inject/utils" +import { fetchSessionMessages } from "./search" -const ACP_STATUS_TOOL_DESCRIPTION = `Show detailed status of all active compressed context blocks. Returns block IDs, sizes, ages, topics, and the message-ID ranges each block consumed — use this to see what has been compressed away and to choose safe compress boundaries. +const ACP_STATUS_TOOL_DESCRIPTION = `Show full context status: visible (uncompressed) breakdown + compressed block list. + +Returns two sections: +1. VISIBLE CONTEXT — token breakdown by category (tool outputs, code, text, summaries) with largest items per category. Helps you decide what to compress. +2. COMPRESSED BLOCKS — active compression blocks with sizes, ages, topics, and message-ID ranges consumed. Helps you choose safe boundaries and track what was compressed away. Use this tool when: +- You want to see what's consuming context (tool outputs? code? text?) - You are unsure which mNNNNN refs are still compressible - Before choosing compress boundaries, if any prior compressions exist - You want to see block sizes before deciding to decompress - A compress call failed with "not available" (the ID was likely consumed) Args: -- mode: "summary" (default) — one line per block with size/range/topic. "detailed" — adds age, generation, effective message count, consumed block lineage. +- mode: "summary" (default) — compact one-line per category and per block. "detailed" — adds largest items per category, plus block age/generation/effective message count/consumed lineage. - sort: "recent" (default) | "size" (largest compressed first) | "age" (oldest surviving first, nearing GC). - limit: max blocks to show (default 30).` @@ -73,6 +80,66 @@ function renderDetailedRow(block: CompressionBlock, idWidth: number): string { return ` ${idStr} ${sizeStr} ${ageStr} ${rangeStr} age=${survived} ${gen} eff=${effCount}${consumedLineage} "${topic}"` } +function pct(n: number, total: number): number { + return total > 0 ? Math.round((n / total) * 100) : 0 +} + +function renderVisibleBreakdown( + composition: ContextComposition, + mode: "summary" | "detailed", +): string[] { + const lines: string[] = [] + const total = composition.total + const toolPct = pct(composition.toolTokens, total) + const codePct = pct(composition.codeTokens, total) + const textPct = pct(composition.textTokens, total) + const summaryPct = pct(composition.summaryTokens, total) + + lines.push("VISIBLE CONTEXT (uncompressed)") + lines.push( + ` ${formatTokens(total)} total | ${formatTokens(composition.toolTokens)} tool (${toolPct}%) | ${formatTokens(composition.codeTokens)} code (${codePct}%) | ${formatTokens(composition.textTokens)} text (${textPct}%) | ${formatTokens(composition.summaryTokens)} summaries (${summaryPct}%)`, + ) + + if (mode === "detailed") { + if (composition.largestToolRanges.length > 0) { + lines.push( + ` Largest tool outputs: ${composition.largestToolRanges.map((r) => `${r.ref} (${formatTokens(r.tokens)})`).join(", ")}`, + ) + } + if (composition.largestCodeRanges.length > 0) { + lines.push( + ` Largest code messages: ${composition.largestCodeRanges.map((r) => `${r.ref} (${formatTokens(r.tokens)})`).join(", ")}`, + ) + } + if (composition.largestMessageRanges.length > 0) { + lines.push( + ` Largest text messages: ${composition.largestMessageRanges.map((r) => `${r.ref} (${formatTokens(r.tokens)})`).join(", ")}`, + ) + } + if ( + composition.largestToolRanges.length === 0 && + composition.largestCodeRanges.length === 0 && + composition.largestMessageRanges.length === 0 + ) { + lines.push(" (no large items — all messages are small)") + } + } + + return lines +} + +function filterVisibleMessages( + rawMessages: WithParts[], + ctx: ToolContext, +): WithParts[] { + const pruneMap = ctx.state.prune.messages.byMessageId + return rawMessages.filter((msg) => { + const msgId = (msg.info as any)?.id || "" + const entry = pruneMap.get(msgId) + return !entry || entry.activeBlockIds.length === 0 + }) +} + export function createAcpStatusTool(ctx: ToolContext): ReturnType { ctx.prompts.reload() @@ -86,23 +153,39 @@ export function createAcpStatusTool(ctx: ToolContext): ReturnType { sort: tool.schema .string() .optional() - .describe('Sort order: "recent" (default), "size", or "age"'), + .describe('Sort order for blocks: "recent" (default), "size", or "age"'), limit: tool.schema .number() .optional() .describe("Maximum blocks to show (default 30)"), }, - async execute(args) { + async execute(args, toolCtx) { const mode = args.mode === "detailed" ? "detailed" : "summary" const sort: "recent" | "size" | "age" = args.sort === "size" || args.sort === "age" ? args.sort : "recent" const limit = Number.isFinite(args.limit) && args.limit! > 0 ? Math.min(args.limit!, 200) : 30 + const lines: string[] = [] + + try { + const rawMessages = await fetchSessionMessages(ctx.client, toolCtx.sessionID) + const visibleMessages = filterVisibleMessages(rawMessages, ctx) + const composition = estimateContextComposition(visibleMessages, ctx.state) + + lines.push(...renderVisibleBreakdown(composition, mode)) + } catch { + lines.push("VISIBLE CONTEXT (uncompressed)") + lines.push(" (unable to fetch messages for breakdown)") + } + + lines.push("") const messages = ctx.state.prune.messages const activeIds = Array.from(messages.activeBlockIds).sort((a, b) => a - b) if (activeIds.length === 0) { - return "No compressed blocks. Context is fully visible." + lines.push("COMPRESSED BLOCKS") + lines.push(" No compressed blocks. Context is fully visible.") + return lines.join("\n") } const allBlocks = activeIds @@ -110,7 +193,9 @@ export function createAcpStatusTool(ctx: ToolContext): ReturnType { .filter((b): b is NonNullable => b !== undefined && b.active) if (allBlocks.length === 0) { - return "No compressed blocks. Context is fully visible." + lines.push("COMPRESSED BLOCKS") + lines.push(" No compressed blocks. Context is fully visible.") + return lines.join("\n") } const totalSummary = allBlocks.reduce((s, b) => s + (b.summaryTokens || 0), 0) @@ -121,10 +206,10 @@ export function createAcpStatusTool(ctx: ToolContext): ReturnType { const idWidth = Math.max(...shown.map((b) => String(b.blockId).length)) - const lines: string[] = [ - `ACP Status — ${allBlocks.length} active compressed block${allBlocks.length === 1 ? "" : "s"} (${formatTokens(totalSummary)} summary, ${formatTokens(totalCompressed)} original compressed)`, - "", - ] + lines.push( + `COMPRESSED BLOCKS — ${allBlocks.length} active (${formatTokens(totalSummary)} summary, ${formatTokens(totalCompressed)} original compressed)`, + ) + lines.push("") for (const b of shown) { lines.push( @@ -140,8 +225,8 @@ export function createAcpStatusTool(ctx: ToolContext): ReturnType { lines.push("") const sortHint = sort === "recent" - ? 'sorted by recent. Use acp_status({sort:"size"}) for largest, {sort:"age"} for near-GC.' - : `sorted by ${sort}.` + ? 'Blocks sorted by recent. Use acp_status({sort:"size"}) for largest, {sort:"age"} for near-GC.' + : `Blocks sorted by ${sort}.` lines.push(`${sortHint} Use decompress to restore a block's full content, or search_context to search within compressed blocks.`) return lines.join("\n") diff --git a/lib/prompts/system.ts b/lib/prompts/system.ts index 089a644..fd19043 100644 --- a/lib/prompts/system.ts +++ b/lib/prompts/system.ts @@ -24,7 +24,7 @@ You have four context-management tools: - \`compress\` — Replace a contiguous range of older conversation with a single detailed summary you write. Use when content is genuinely consumed (no longer needed for the current task step). Example: \`compress({ topic: "API exploration", content: [{ startId: "m00150", endId: "m00220", summary: "..." }] })\`. - \`decompress\` — Restore a previously compressed block's full original content, optionally to a file for large blocks. Use when a summary lacks the exact detail you need. Example: \`decompress({ blockId: "b5" })\` or \`decompress({ blockId: "b5", toFile: "path" })\`. - \`search_context\` — Search compressed block summaries (and optionally visible messages) by keyword. Use BEFORE decompressing to find the right block. Example: \`search_context({ query: "auth token refresh" })\`. -- \`acp_status\` — List all active compressed blocks with their sizes, ages, and the message ranges they consumed. Use when you are unsure which IDs are still compressible, or before choosing compress boundaries. Example: \`acp_status({ mode: "summary", sort: "recent" })\`. +- \`acp_status\` — Show full context status: visible (uncompressed) token breakdown by category (tool outputs, code, text, summaries) with largest items, plus the active compressed block list. Use to see what's consuming context and what to compress. Example: \`acp_status({ mode: "detailed", sort: "size" })\`. COMPRESSION PHILOSOPHY @@ -63,7 +63,7 @@ Periodically, as context grows, the system appends a short status line in a synt This line is INFORMATION, not an instruction. Seeing it does not mean you should compress. Compress only when one of the WHEN TO COMPRESS conditions actually holds. Between these lines, context is not under additional pressure — you do not need to seek things to compress. -If you are unsure which \`mNNNNN\` refs are still compressible, or which blocks have already consumed which ranges, call \`acp_status\` first. It returns the block IDs, their sizes, and the message-ID ranges each covers. +If you are unsure which \`mNNNNN\` refs are still compressible, or which blocks have already consumed which ranges, call \`acp_status\` first. It returns the visible context breakdown (tool/code/text/summary tokens with largest items) and the compressed block list (block IDs, sizes, message-ID ranges each covers). CONTEXT BREAKDOWN diff --git a/tests/acp-status.test.ts b/tests/acp-status.test.ts index 55adfc1..9584993 100644 --- a/tests/acp-status.test.ts +++ b/tests/acp-status.test.ts @@ -109,14 +109,14 @@ async function runStatus( test("acp_status: empty state returns no-blocks message", async () => { const result = await runStatus([], new Map()) - assert.equal(result, "No compressed blocks. Context is fully visible.") + assert.match(result, /No compressed blocks/) }) test("acp_status: single block shows correct header with summary and original sizes", async () => { const blocks = blocksMap(makeBlock({ blockId: 1, summaryTokens: 750, compressedTokens: 5000, topic: "My topic" })) const result = await runStatus([1], blocks) - assert.match(result, /ACP Status — 1 active compressed block \(750 summary, 5\.0K original compressed\)/) + assert.match(result, /COMPRESSED BLOCKS — 1 active \(/) assert.match(result, /b1/) assert.match(result, /"My topic"/) }) @@ -128,7 +128,7 @@ test("acp_status: plural header for multiple blocks", async () => { ) const result = await runStatus([1, 2], blocks) - assert.match(result, /2 active compressed blocks/) + assert.match(result, /2 active \(/) assert.match(result, /1\.1K summary/) }) @@ -277,5 +277,5 @@ test("acp_status: invalid sort falls back to recent", async () => { const blocks = blocksMap(makeBlock({ blockId: 1 })) const result = await runStatus([1], blocks, { sort: "bogus" as any }) - assert.match(result, /sorted by recent/) + assert.match(result, /Blocks sorted by recent/) }) From 6847cb976e8d9b44d03b8eb48980ef2c7d372698 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Thu, 9 Jul 2026 21:33:49 +0800 Subject: [PATCH 02/12] fix: acp_status summary tokens + tool output descriptions - Inject compression block summaries into visible messages so summary tokens are correctly counted (was showing 0% summaries because prune step injects summaries at runtime, not in DB) - Add tool name + key arg to largest tool output descriptions in detailed mode (was just 'm01315 (5.0K)' with no context) - Detailed mode now shows each largest tool output on its own line with description: 'm01315 (5.0K) bash: git push github...' --- lib/compress/status.ts | 63 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/lib/compress/status.ts b/lib/compress/status.ts index ab8ad42..10cfa78 100644 --- a/lib/compress/status.ts +++ b/lib/compress/status.ts @@ -19,7 +19,7 @@ Use this tool when: - A compress call failed with "not available" (the ID was likely consumed) Args: -- mode: "summary" (default) — compact one-line per category and per block. "detailed" — adds largest items per category, plus block age/generation/effective message count/consumed lineage. +- mode: "summary" (default) — compact one-line per category and per block. "detailed" — adds largest items per category with descriptions, plus block age/generation/effective message count/consumed lineage. - sort: "recent" (default) | "size" (largest compressed first) | "age" (oldest surviving first, nearing GC). - limit: max blocks to show (default 30).` @@ -84,8 +84,32 @@ function pct(n: number, total: number): number { return total > 0 ? Math.round((n / total) * 100) : 0 } +function describeToolMessage(msg: WithParts): string { + for (const part of msg.parts || []) { + if (part.type === "tool") { + const toolPart = part as any + const toolName = toolPart.tool || "?" + const input = toolPart.input + if (input && typeof input === "object") { + if (input.filePath) return `${toolName}: ${String(input.filePath).slice(0, 50)}` + if (input.command) return `${toolName}: ${String(input.command).slice(0, 50)}` + if (input.query) return `${toolName}: ${String(input.query).slice(0, 50)}` + if (input.pattern) return `${toolName}: ${String(input.pattern).slice(0, 50)}` + } + return toolName + } + } + const textPart = (msg.parts || []).find((p) => p.type === "text") as any + if (textPart?.text) { + return textPart.text.slice(0, 50).replace(/\n/g, " ") + } + return "?" +} + function renderVisibleBreakdown( composition: ContextComposition, + visibleMessages: WithParts[], + state: any, mode: "summary" | "detailed", ): string[] { const lines: string[] = [] @@ -101,10 +125,18 @@ function renderVisibleBreakdown( ) if (mode === "detailed") { + const byRef = state?.messageIds?.byRef if (composition.largestToolRanges.length > 0) { - lines.push( - ` Largest tool outputs: ${composition.largestToolRanges.map((r) => `${r.ref} (${formatTokens(r.tokens)})`).join(", ")}`, - ) + const items = composition.largestToolRanges.slice(0, 10).map((r) => { + const rawId = byRef?.get(r.ref) || "" + const msg = visibleMessages.find((m) => (m.info as any)?.id === rawId) + const desc = msg ? describeToolMessage(msg) : "" + return `${r.ref} (${formatTokens(r.tokens)}) ${desc}` + }) + lines.push(` Largest tool outputs:`) + for (const item of items) { + lines.push(` ${item}`) + } } if (composition.largestCodeRanges.length > 0) { lines.push( @@ -128,16 +160,29 @@ function renderVisibleBreakdown( return lines } -function filterVisibleMessages( +function buildVisibleMessages( rawMessages: WithParts[], ctx: ToolContext, ): WithParts[] { const pruneMap = ctx.state.prune.messages.byMessageId - return rawMessages.filter((msg) => { + const visible = rawMessages.filter((msg) => { const msgId = (msg.info as any)?.id || "" const entry = pruneMap.get(msgId) return !entry || entry.activeBlockIds.length === 0 }) + + const activeBlocks = Array.from(ctx.state.prune.messages.activeBlockIds) + .map((id) => ctx.state.prune.messages.blocksById.get(id)) + .filter((b): b is NonNullable => b !== undefined && b.active) + + for (const block of activeBlocks) { + visible.push({ + info: { id: `msg_acp_summary_b${block.blockId}` } as any, + parts: [{ type: "text", text: block.summary || "[Compressed conversation section]" } as any], + } as any) + } + + return visible } export function createAcpStatusTool(ctx: ToolContext): ReturnType { @@ -169,10 +214,10 @@ export function createAcpStatusTool(ctx: ToolContext): ReturnType { try { const rawMessages = await fetchSessionMessages(ctx.client, toolCtx.sessionID) - const visibleMessages = filterVisibleMessages(rawMessages, ctx) + const visibleMessages = buildVisibleMessages(rawMessages, ctx) const composition = estimateContextComposition(visibleMessages, ctx.state) - lines.push(...renderVisibleBreakdown(composition, mode)) + lines.push(...renderVisibleBreakdown(composition, visibleMessages, ctx.state, mode)) } catch { lines.push("VISIBLE CONTEXT (uncompressed)") lines.push(" (unable to fetch messages for breakdown)") @@ -225,7 +270,7 @@ export function createAcpStatusTool(ctx: ToolContext): ReturnType { lines.push("") const sortHint = sort === "recent" - ? 'Blocks sorted by recent. Use acp_status({sort:"size"}) for largest, {sort:"age"} for near-GC.' + ? 'Blocks sorted by recent. Use acp_status({sort:"size"}) for largest, {sort:"age"}) for near-GC.' : `Blocks sorted by ${sort}.` lines.push(`${sortHint} Use decompress to restore a block's full content, or search_context to search within compressed blocks.`) From cf74b99e479a021ed6c27a6954b197c6620ee847 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Thu, 9 Jul 2026 21:53:00 +0800 Subject: [PATCH 03/12] feat: per-tool-type breakdown in acp_status + inject nudges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ContextComposition now includes toolTypeBreakdown: {tool, tokens}[] - acp_status shows 'Top tools: bash (44%), write (21%), ...' (3 in summary mode, 5 in detailed mode) - inject.ts nudge text and mini breakdown both include 'Top tools:' line - describeToolMessage fixed: reads input from part.state.input (not part.input) — now shows command/filePath/query/pattern args - Summary mode streamlined: category line + top tools only (no largest items) - Detailed mode: category + top tools + largest items with descriptions --- lib/compress/status.ts | 31 ++++++++++++++++++------------- lib/messages/inject/inject.ts | 9 +++++++++ lib/messages/inject/utils.ts | 9 +++++++++ 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/lib/compress/status.ts b/lib/compress/status.ts index 10cfa78..8977aee 100644 --- a/lib/compress/status.ts +++ b/lib/compress/status.ts @@ -89,19 +89,20 @@ function describeToolMessage(msg: WithParts): string { if (part.type === "tool") { const toolPart = part as any const toolName = toolPart.tool || "?" - const input = toolPart.input + const input = toolPart.state?.input if (input && typeof input === "object") { - if (input.filePath) return `${toolName}: ${String(input.filePath).slice(0, 50)}` - if (input.command) return `${toolName}: ${String(input.command).slice(0, 50)}` - if (input.query) return `${toolName}: ${String(input.query).slice(0, 50)}` - if (input.pattern) return `${toolName}: ${String(input.pattern).slice(0, 50)}` + if (input.command) return `${toolName}: ${String(input.command).slice(0, 60)}` + if (input.filePath) return `${toolName}: ${String(input.filePath).slice(0, 60)}` + if (input.query) return `${toolName}: ${String(input.query).slice(0, 60)}` + if (input.pattern) return `${toolName}: ${String(input.pattern).slice(0, 60)}` + if (input.content) return `${toolName}: ${String(input.content).slice(0, 40)}` } return toolName } } const textPart = (msg.parts || []).find((p) => p.type === "text") as any if (textPart?.text) { - return textPart.text.slice(0, 50).replace(/\n/g, " ") + return textPart.text.slice(0, 60).replace(/\n/g, " ") } return "?" } @@ -124,6 +125,17 @@ function renderVisibleBreakdown( ` ${formatTokens(total)} total | ${formatTokens(composition.toolTokens)} tool (${toolPct}%) | ${formatTokens(composition.codeTokens)} code (${codePct}%) | ${formatTokens(composition.textTokens)} text (${textPct}%) | ${formatTokens(composition.summaryTokens)} summaries (${summaryPct}%)`, ) + const topTypes = composition.toolTypeBreakdown.slice(0, mode === "detailed" ? 5 : 3) + if (topTypes.length > 0) { + const parts = topTypes.map((t) => { + const tp = pct(t.tokens, total) + return mode === "detailed" + ? `${t.tool} (${formatTokens(t.tokens)}, ${tp}%)` + : `${t.tool} (${tp}%)` + }) + lines.push(` Top tools: ${parts.join(", ")}`) + } + if (mode === "detailed") { const byRef = state?.messageIds?.byRef if (composition.largestToolRanges.length > 0) { @@ -148,13 +160,6 @@ function renderVisibleBreakdown( ` Largest text messages: ${composition.largestMessageRanges.map((r) => `${r.ref} (${formatTokens(r.tokens)})`).join(", ")}`, ) } - if ( - composition.largestToolRanges.length === 0 && - composition.largestCodeRanges.length === 0 && - composition.largestMessageRanges.length === 0 - ) { - lines.push(" (no large items — all messages are small)") - } } return lines diff --git a/lib/messages/inject/inject.ts b/lib/messages/inject/inject.ts index 982dcd2..80cdd4c 100644 --- a/lib/messages/inject/inject.ts +++ b/lib/messages/inject/inject.ts @@ -241,6 +241,11 @@ export const injectCompressNudges = ( : "" let breakdown = `${efficiencyNote}\nBreakdown: ${fmt(composition.toolTokens)} tool (${pct(composition.toolTokens)}%) | ${fmt(composition.summaryTokens)} summaries (${pct(composition.summaryTokens)}%) | ${fmt(composition.codeTokens)} code (${pct(composition.codeTokens)}%) | ${fmt(plainTextTokens)} text (${pct(plainTextTokens)}%)${growthStr}` + const topToolTypes = composition.toolTypeBreakdown.slice(0, 3) + if (topToolTypes.length > 0) { + breakdown += `\nTop tools: ${topToolTypes.map((t) => `${t.tool} (${pct(t.tokens)}%)`).join(", ")}` + } + const topBlocks = Array.from(state.prune.messages.blocksById.values()) .filter((b) => b.active) .sort((a, b) => b.compressedTokens - a.compressedTokens) @@ -303,6 +308,10 @@ export const injectCompressNudges = ( .sort((a, b) => b.compressedTokens - a.compressedTokens) .slice(0, 3) let mini = `\nBreakdown: ${fmt2(composition.toolTokens)} tool outputs (${pct2(composition.toolTokens)}%) | ${fmt2(composition.summaryTokens)} summaries (${pct2(composition.summaryTokens)}%) | ${fmt2(composition.messageTokens)} messages (${pct2(composition.messageTokens)}%)` + const miniTopTypes = composition.toolTypeBreakdown.slice(0, 3) + if (miniTopTypes.length > 0) { + mini += `\nTop tools: ${miniTopTypes.map((t) => `${t.tool} (${pct2(t.tokens)}%)`).join(", ")}` + } if (topBlocks.length > 0) { mini += `\nTop blocks: ${topBlocks.map((b) => `b${b.blockId} ${fmt2(b.compressedTokens)}→${fmt2(b.summaryTokens)}`).join(", ")}` } diff --git a/lib/messages/inject/utils.ts b/lib/messages/inject/utils.ts index d28b124..e410a8b 100644 --- a/lib/messages/inject/utils.ts +++ b/lib/messages/inject/utils.ts @@ -561,6 +561,7 @@ export interface ContextComposition { largestToolRanges: { ref: string; tokens: number }[] largestCodeRanges: { ref: string; tokens: number }[] largestMessageRanges: { ref: string; tokens: number }[] + toolTypeBreakdown: { tool: string; tokens: number }[] } function estimateCodeTokens(text: string): number { @@ -589,6 +590,7 @@ export function estimateContextComposition( const perTool: { ref: string; tokens: number }[] = [] const perCode: { ref: string; tokens: number }[] = [] const perText: { ref: string; tokens: number }[] = [] + const toolTypeMap = new Map() for (const msg of messages) { const text = (msg.parts || []) @@ -625,6 +627,8 @@ export function estimateContextComposition( msgTotal += tokens toolTokens += tokens msgTool += tokens + const toolName = (part as any)?.tool || (part as any)?.type || "unknown" + toolTypeMap.set(toolName, (toolTypeMap.get(toolName) || 0) + tokens) } } @@ -642,6 +646,10 @@ export function estimateContextComposition( perCode.sort((a, b) => b.tokens - a.tokens) perText.sort((a, b) => b.tokens - a.tokens) + const toolTypeBreakdown = Array.from(toolTypeMap.entries()) + .map(([tool, tokens]) => ({ tool, tokens })) + .sort((a, b) => b.tokens - a.tokens) + return { toolTokens, codeTokens, @@ -653,5 +661,6 @@ export function estimateContextComposition( largestToolRanges: perTool.slice(0, 15), largestCodeRanges: perCode.slice(0, 5), largestMessageRanges: perText.slice(0, 5), + toolTypeBreakdown, } } From 75e68259d1d2b8614329fad2fe92718699f41100 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Thu, 9 Jul 2026 22:01:52 +0800 Subject: [PATCH 04/12] fix: show min 1% for non-zero token categories When a category has tokens > 0 but the percentage rounds to 0% (e.g. 93 code tokens out of 113K total), show 1% instead of 0% to avoid the misleading impression that the category is empty. --- lib/compress/status.ts | 3 ++- lib/messages/inject/inject.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/compress/status.ts b/lib/compress/status.ts index 8977aee..ca3b728 100644 --- a/lib/compress/status.ts +++ b/lib/compress/status.ts @@ -81,7 +81,8 @@ function renderDetailedRow(block: CompressionBlock, idWidth: number): string { } function pct(n: number, total: number): number { - return total > 0 ? Math.round((n / total) * 100) : 0 + if (n <= 0 || total <= 0) return 0 + return Math.max(1, Math.round((n / total) * 100)) } function describeToolMessage(msg: WithParts): string { diff --git a/lib/messages/inject/inject.ts b/lib/messages/inject/inject.ts index 80cdd4c..617645a 100644 --- a/lib/messages/inject/inject.ts +++ b/lib/messages/inject/inject.ts @@ -228,7 +228,7 @@ export const injectCompressNudges = ( if (suffixMessage && composition.total > 0) { const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n)) - const pct = (n: number) => Math.round((n / composition.total) * 100) + const pct = (n: number) => n > 0 ? Math.max(1, Math.round((n / composition.total) * 100)) : 0 const growth = currentTokens !== undefined && state.nudges.lastPerMessageNudgeTokens !== undefined ? currentTokens - state.nudges.lastPerMessageNudgeTokens : 0 const growthStr = growth > 0 ? ` (+${fmt(growth)} since last nudge)` : "" @@ -302,7 +302,7 @@ export const injectCompressNudges = ( injectContextUsage(suffixMessage, config, currentTokens, modelContextLimit) if (composition.total > 0) { const fmt2 = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n)) - const pct2 = (n: number) => Math.round((n / composition.total) * 100) + const pct2 = (n: number) => n > 0 ? Math.max(1, Math.round((n / composition.total) * 100)) : 0 const topBlocks = Array.from(state.prune.messages.blocksById.values()) .filter((b) => b.active) .sort((a, b) => b.compressedTokens - a.compressedTokens) From bdf30c3c4b1c78078cb5b2422d01fddfc454bd6a Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Thu, 9 Jul 2026 22:12:16 +0800 Subject: [PATCH 05/12] =?UTF-8?q?refactor:=20simplify=20nudge=20injection?= =?UTF-8?q?=20=E2=80=94=20remove=20mini=20breakdown=20+=20Top=20blocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @dog design direction: - Remove mini breakdown entirely (was redundant with full nudge) - Remove Top blocks from nudge (compressed stats belong in acp_status) - Nudge now shows only: Breakdown + Top tools + Largest items (refs only) - Largest items already had no descriptions (model sees content inline) - Mini breakdown code (~18 lines) removed from inject.ts - toolOutputReminder now just appends the ⚠️ warning without breakdown --- lib/messages/inject/inject.ts | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/lib/messages/inject/inject.ts b/lib/messages/inject/inject.ts index 617645a..10439b6 100644 --- a/lib/messages/inject/inject.ts +++ b/lib/messages/inject/inject.ts @@ -246,13 +246,6 @@ export const injectCompressNudges = ( breakdown += `\nTop tools: ${topToolTypes.map((t) => `${t.tool} (${pct(t.tokens)}%)`).join(", ")}` } - const topBlocks = Array.from(state.prune.messages.blocksById.values()) - .filter((b) => b.active) - .sort((a, b) => b.compressedTokens - a.compressedTokens) - .slice(0, 3) - if (topBlocks.length > 0) { - breakdown += `\nTop blocks: ${topBlocks.map((b) => `b${b.blockId} ${fmt(b.compressedTokens)}→${fmt(b.summaryTokens)}`).join(", ")}` - } if (composition.largestToolRanges.length > 0) { breakdown += `\nLargest tool outputs: ${composition.largestToolRanges.map((r) => `${r.ref} (${fmt(r.tokens)})`).join(", ")}` } @@ -298,26 +291,6 @@ export const injectCompressNudges = ( } if (toolOutputReminder && suffixMessage) { - if (!decision.shouldNudge) { - injectContextUsage(suffixMessage, config, currentTokens, modelContextLimit) - if (composition.total > 0) { - const fmt2 = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n)) - const pct2 = (n: number) => n > 0 ? Math.max(1, Math.round((n / composition.total) * 100)) : 0 - const topBlocks = Array.from(state.prune.messages.blocksById.values()) - .filter((b) => b.active) - .sort((a, b) => b.compressedTokens - a.compressedTokens) - .slice(0, 3) - let mini = `\nBreakdown: ${fmt2(composition.toolTokens)} tool outputs (${pct2(composition.toolTokens)}%) | ${fmt2(composition.summaryTokens)} summaries (${pct2(composition.summaryTokens)}%) | ${fmt2(composition.messageTokens)} messages (${pct2(composition.messageTokens)}%)` - const miniTopTypes = composition.toolTypeBreakdown.slice(0, 3) - if (miniTopTypes.length > 0) { - mini += `\nTop tools: ${miniTopTypes.map((t) => `${t.tool} (${pct2(t.tokens)}%)`).join(", ")}` - } - if (topBlocks.length > 0) { - mini += `\nTop blocks: ${topBlocks.map((b) => `b${b.blockId} ${fmt2(b.compressedTokens)}→${fmt2(b.summaryTokens)}`).join(", ")}` - } - appendToLastTextPart(suffixMessage, mini) - } - } appendToLastTextPart(suffixMessage, toolOutputReminder) } From e0b6582fb9d0eef571c74d630b866647c715d171 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Thu, 9 Jul 2026 22:22:28 +0800 Subject: [PATCH 06/12] feat: add tool type to Largest tool outputs in nudge - largestToolRanges now includes {tool} field (first tool name per message) - Nudge shows: 'm01425 (3.4K) write, m01439 (2.9K) bash, ...' - Limited to 10 items in nudge (was unbounded from composition's 15) --- lib/messages/inject/inject.ts | 2 +- lib/messages/inject/utils.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/messages/inject/inject.ts b/lib/messages/inject/inject.ts index 10439b6..a73e126 100644 --- a/lib/messages/inject/inject.ts +++ b/lib/messages/inject/inject.ts @@ -247,7 +247,7 @@ export const injectCompressNudges = ( } if (composition.largestToolRanges.length > 0) { - breakdown += `\nLargest tool outputs: ${composition.largestToolRanges.map((r) => `${r.ref} (${fmt(r.tokens)})`).join(", ")}` + breakdown += `\nLargest tool outputs: ${composition.largestToolRanges.slice(0, 10).map((r) => `${r.ref} (${fmt(r.tokens)})${r.tool ? " " + r.tool : ""}`).join(", ")}` } if (composition.largestCodeRanges.length > 0) { breakdown += `\nLargest code messages: ${composition.largestCodeRanges.map((r) => `${r.ref} (${fmt(r.tokens)})`).join(", ")}` diff --git a/lib/messages/inject/utils.ts b/lib/messages/inject/utils.ts index e410a8b..7bf6082 100644 --- a/lib/messages/inject/utils.ts +++ b/lib/messages/inject/utils.ts @@ -558,7 +558,7 @@ export interface ContextComposition { textTokens: number total: number largestRanges: { ref: string; tokens: number }[] - largestToolRanges: { ref: string; tokens: number }[] + largestToolRanges: { ref: string; tokens: number; tool?: string }[] largestCodeRanges: { ref: string; tokens: number }[] largestMessageRanges: { ref: string; tokens: number }[] toolTypeBreakdown: { tool: string; tokens: number }[] @@ -587,7 +587,7 @@ export function estimateContextComposition( let summaryTokens = 0 let messageTokens = 0 const perMessage: { ref: string; tokens: number }[] = [] - const perTool: { ref: string; tokens: number }[] = [] + const perTool: { ref: string; tokens: number; tool?: string }[] = [] const perCode: { ref: string; tokens: number }[] = [] const perText: { ref: string; tokens: number }[] = [] const toolTypeMap = new Map() @@ -604,6 +604,7 @@ export function estimateContextComposition( let msgTool = 0 let msgCode = 0 let msgText = 0 + let msgToolName = "" for (const part of msg.parts || []) { if (part.type === "text" && typeof (part as any).text === "string") { @@ -629,13 +630,14 @@ export function estimateContextComposition( msgTool += tokens const toolName = (part as any)?.tool || (part as any)?.type || "unknown" toolTypeMap.set(toolName, (toolTypeMap.get(toolName) || 0) + tokens) + if (!msgToolName) msgToolName = toolName } } if (!isSummary) { const ref = state?.messageIds?.byRawId?.get(msgId) || "?" if (msgTotal > 500) perMessage.push({ ref, tokens: msgTotal }) - if (msgTool > 500) perTool.push({ ref, tokens: msgTool }) + if (msgTool > 500) perTool.push({ ref, tokens: msgTool, tool: msgToolName }) if (msgCode > 300) perCode.push({ ref, tokens: msgCode }) if (msgText > 500 && msgCode === 0) perText.push({ ref, tokens: msgText }) } From 921317f3484120b01ddef0b177d117baf514ce2a Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Thu, 9 Jul 2026 22:39:31 +0800 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20acp=5Fstatus=20drilldown=20?= =?UTF-8?q?=E2=80=94=20scope/tool/sort=20params=20replace=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New parameter design: - scope: 'compressed' | 'uncompressed' — drill into one section - tool: filter by tool type (e.g., 'todowrite', 'bash') — uncompressed only - sort: 'size' | 'time' | 'tool' — list sort order - No args = overview (both visible + compressed) Removed old mode: 'summary' | 'detailed' — drilldown replaces verbosity flag. scope=compressed always shows full block details (age, generation, consumed lineage). scope=uncompressed shows per-message list with refs + tokens + tool type. Overview works even when message fetch fails (shows blocks section). --- lib/compress/status.ts | 438 ++++++++++++++++++++++++--------------- tests/acp-status.test.ts | 193 ++++++++++------- 2 files changed, 387 insertions(+), 244 deletions(-) diff --git a/lib/compress/status.ts b/lib/compress/status.ts index ca3b728..d94d0c5 100644 --- a/lib/compress/status.ts +++ b/lib/compress/status.ts @@ -2,34 +2,31 @@ import { tool } from "@opencode-ai/plugin" import type { ToolContext } from "./types" import { formatAge } from "../ui/utils" import type { CompressionBlock, WithParts } from "../state/types" -import { estimateContextComposition, type ContextComposition } from "../messages/inject/utils" +import { estimateContextComposition } from "../messages/inject/utils" import { fetchSessionMessages } from "./search" -const ACP_STATUS_TOOL_DESCRIPTION = `Show full context status: visible (uncompressed) breakdown + compressed block list. +const ACP_STATUS_TOOL_DESCRIPTION = `Show context status — overview or drill down into compressed/uncompressed sections. -Returns two sections: -1. VISIBLE CONTEXT — token breakdown by category (tool outputs, code, text, summaries) with largest items per category. Helps you decide what to compress. -2. COMPRESSED BLOCKS — active compression blocks with sizes, ages, topics, and message-ID ranges consumed. Helps you choose safe boundaries and track what was compressed away. +No args: Overview of both visible (uncompressed) context and compressed blocks. +scope:"uncompressed": Drill into all visible messages — list each with ref, tokens, tool type. Add tool:"bash" to filter by tool type. +scope:"compressed": Drill into compressed blocks — list each with full details (age, generation, consumed lineage). -Use this tool when: -- You want to see what's consuming context (tool outputs? code? text?) -- You are unsure which mNNNNN refs are still compressible -- Before choosing compress boundaries, if any prior compressions exist -- You want to see block sizes before deciding to decompress -- A compress call failed with "not available" (the ID was likely consumed) +Sort options: "size" (default, largest first), "time" (chronological), "tool" (group by tool type). -Args: -- mode: "summary" (default) — compact one-line per category and per block. "detailed" — adds largest items per category with descriptions, plus block age/generation/effective message count/consumed lineage. -- sort: "recent" (default) | "size" (largest compressed first) | "age" (oldest surviving first, nearing GC). -- limit: max blocks to show (default 30).` +Use this tool to: +- See what's consuming context (overview) +- Find all messages of a specific tool type to batch-compress +- Check block details before decompressing +- Find compression candidates when context grows` function formatTokens(n: number): string { if (!Number.isFinite(n) || n <= 0) return "0" return n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n) } -function formatSizePair(compressed: number, summary: number): string { - return `${formatTokens(compressed)}→${formatTokens(summary)}` +function pct(n: number, total: number): number { + if (n <= 0 || total <= 0) return 0 + return Math.max(1, Math.round((n / total) * 100)) } function formatIdRange(block: CompressionBlock): string { @@ -40,51 +37,6 @@ function formatIdRange(block: CompressionBlock): string { return `${start}–${end}` } -function sortBlocks( - blocks: CompressionBlock[], - sort: "recent" | "size" | "age", -): CompressionBlock[] { - const copy = [...blocks] - if (sort === "size") { - copy.sort((a, b) => (b.compressedTokens || 0) - (a.compressedTokens || 0)) - } else if (sort === "age") { - copy.sort((a, b) => (b.survivedCount || 0) - (a.survivedCount || 0)) - } else { - copy.sort((a, b) => b.createdAt - a.createdAt) - } - return copy -} - -function renderSummaryRow(block: CompressionBlock, idWidth: number): string { - const idStr = `b${block.blockId}`.padEnd(idWidth + 1) - const sizeStr = formatSizePair(block.compressedTokens, block.summaryTokens).padStart(13) - const ageStr = formatAge(block.createdAt).padStart(10) - const rangeStr = formatIdRange(block).padStart(19) - const topic = block.topic || "(no topic)" - return ` ${idStr} ${sizeStr} ${ageStr} ${rangeStr} "${topic}"` -} - -function renderDetailedRow(block: CompressionBlock, idWidth: number): string { - const idStr = `b${block.blockId}`.padEnd(idWidth + 1) - const sizeStr = formatSizePair(block.compressedTokens, block.summaryTokens).padStart(13) - const ageStr = formatAge(block.createdAt).padStart(10) - const rangeStr = formatIdRange(block).padStart(19) - const survived = block.survivedCount ?? 0 - const gen = block.generation ?? "young" - const effCount = block.effectiveMessageIds?.length ?? 0 - const consumedLineage = - block.consumedBlockIds && block.consumedBlockIds.length > 0 - ? ` nested=[${block.consumedBlockIds.map((n) => `b${n}`).join(",")}]` - : "" - const topic = block.topic || "(no topic)" - return ` ${idStr} ${sizeStr} ${ageStr} ${rangeStr} age=${survived} ${gen} eff=${effCount}${consumedLineage} "${topic}"` -} - -function pct(n: number, total: number): number { - if (n <= 0 || total <= 0) return 0 - return Math.max(1, Math.round((n / total) * 100)) -} - function describeToolMessage(msg: WithParts): string { for (const part of msg.parts || []) { if (part.type === "tool") { @@ -108,65 +60,238 @@ function describeToolMessage(msg: WithParts): string { return "?" } -function renderVisibleBreakdown( - composition: ContextComposition, - visibleMessages: WithParts[], - state: any, - mode: "summary" | "detailed", -): string[] { - const lines: string[] = [] - const total = composition.total - const toolPct = pct(composition.toolTokens, total) - const codePct = pct(composition.codeTokens, total) - const textPct = pct(composition.textTokens, total) - const summaryPct = pct(composition.summaryTokens, total) - - lines.push("VISIBLE CONTEXT (uncompressed)") - lines.push( - ` ${formatTokens(total)} total | ${formatTokens(composition.toolTokens)} tool (${toolPct}%) | ${formatTokens(composition.codeTokens)} code (${codePct}%) | ${formatTokens(composition.textTokens)} text (${textPct}%) | ${formatTokens(composition.summaryTokens)} summaries (${summaryPct}%)`, - ) - - const topTypes = composition.toolTypeBreakdown.slice(0, mode === "detailed" ? 5 : 3) - if (topTypes.length > 0) { - const parts = topTypes.map((t) => { - const tp = pct(t.tokens, total) - return mode === "detailed" - ? `${t.tool} (${formatTokens(t.tokens)}, ${tp}%)` - : `${t.tool} (${tp}%)` - }) - lines.push(` Top tools: ${parts.join(", ")}`) +interface VisibleMessageInfo { + ref: string + tokens: number + tool: string + index: number +} + +function collectVisibleMessages( + rawMessages: WithParts[], + ctx: ToolContext, +): { messages: VisibleMessageInfo[]; summaryTokens: number } { + const pruneMap = ctx.state.prune.messages.byMessageId + const byRawId = ctx.state.messageIds.byRawId + const result: VisibleMessageInfo[] = [] + let summaryTokens = 0 + + const activeBlocks = Array.from(ctx.state.prune.messages.activeBlockIds) + .map((id) => ctx.state.prune.messages.blocksById.get(id)) + .filter((b): b is NonNullable => b !== undefined && b.active) + + for (const block of activeBlocks) { + summaryTokens += block.summaryTokens || 0 } - if (mode === "detailed") { - const byRef = state?.messageIds?.byRef - if (composition.largestToolRanges.length > 0) { - const items = composition.largestToolRanges.slice(0, 10).map((r) => { - const rawId = byRef?.get(r.ref) || "" - const msg = visibleMessages.find((m) => (m.info as any)?.id === rawId) - const desc = msg ? describeToolMessage(msg) : "" - return `${r.ref} (${formatTokens(r.tokens)}) ${desc}` - }) - lines.push(` Largest tool outputs:`) - for (const item of items) { - lines.push(` ${item}`) + rawMessages.forEach((msg, idx) => { + const msgId = (msg.info as any)?.id || "" + const entry = pruneMap.get(msgId) + if (entry && entry.activeBlockIds.length > 0) return + + const ref = byRawId.get(msgId) + if (!ref) return + + let tokens = 0 + let toolName = "" + + for (const part of msg.parts || []) { + if (part.type === "text" && typeof (part as any).text === "string") { + tokens += Math.round(((part as any).text as string).length / 4) + } else if (part.type !== "text" && part.type !== "reasoning") { + const raw = JSON.stringify(part) + tokens += Math.round(raw.length / 4) + if (!toolName) { + toolName = (part as any)?.tool || (part as any)?.type || "unknown" + } } } - if (composition.largestCodeRanges.length > 0) { - lines.push( - ` Largest code messages: ${composition.largestCodeRanges.map((r) => `${r.ref} (${formatTokens(r.tokens)})`).join(", ")}`, - ) + + if (tokens > 0) { + result.push({ ref, tokens, tool: toolName || "text", index: idx }) } - if (composition.largestMessageRanges.length > 0) { - lines.push( - ` Largest text messages: ${composition.largestMessageRanges.map((r) => `${r.ref} (${formatTokens(r.tokens)})`).join(", ")}`, - ) + }) + + return { messages: result, summaryTokens } +} + +function renderOverview( + visibleMessages: VisibleMessageInfo[], + summaryTokens: number, + blocks: CompressionBlock[], + fetchFailed: boolean, +): string[] { + const lines: string[] = [] + + if (fetchFailed) { + lines.push("VISIBLE CONTEXT (uncompressed)") + lines.push(" (unable to fetch messages for breakdown)") + } else { + const totalTool = visibleMessages + .filter((m) => m.tool !== "text" && m.tool !== "step-finish") + .reduce((s, m) => s + m.tokens, 0) + const totalText = visibleMessages + .filter((m) => m.tool === "text") + .reduce((s, m) => s + m.tokens, 0) + const total = totalTool + totalText + summaryTokens + + const toolPct = pct(totalTool, total) + const textPct = pct(totalText, total) + const summaryPct = pct(summaryTokens, total) + + lines.push("VISIBLE CONTEXT (uncompressed)") + lines.push( + ` ${formatTokens(total)} total | ${formatTokens(totalTool)} tool (${toolPct}%) | ${formatTokens(totalText)} text (${textPct}%) | ${formatTokens(summaryTokens)} summaries (${summaryPct}%)`, + ) + + const toolTypeMap = new Map() + for (const m of visibleMessages) { + toolTypeMap.set(m.tool, (toolTypeMap.get(m.tool) || 0) + m.tokens) } + const topTypes = Array.from(toolTypeMap.entries()) + .map(([tool, tokens]) => ({ tool, tokens })) + .sort((a, b) => b.tokens - a.tokens) + .slice(0, 3) + if (topTypes.length > 0) { + lines.push(` Top tools: ${topTypes.map((t) => `${t.tool} (${pct(t.tokens, total)}%)`).join(", ")}`) + } + } + + lines.push("") + + if (blocks.length === 0) { + lines.push("COMPRESSED BLOCKS") + lines.push(" No compressed blocks.") + } else { + const totalSummary = blocks.reduce((s, b) => s + (b.summaryTokens || 0), 0) + const totalCompressed = blocks.reduce((s, b) => s + (b.compressedTokens || 0), 0) + lines.push( + `COMPRESSED BLOCKS — ${blocks.length} active (${formatTokens(totalSummary)} summary, ${formatTokens(totalCompressed)} original)`, + ) + lines.push("") + const sorted = [...blocks].sort((a, b) => b.createdAt - a.createdAt) + for (const b of sorted.slice(0, 30)) { + const ageStr = formatAge(b.createdAt) + const range = formatIdRange(b) + const topic = b.topic || "(no topic)" + lines.push(` b${b.blockId} ${formatTokens(b.compressedTokens)}→${formatTokens(b.summaryTokens)} ${ageStr} ${range} "${topic}"`) + } + } + + lines.push("") + lines.push('Drill down: acp_status({scope:"uncompressed"}) or {scope:"compressed"})') + + return lines +} + +function renderUncompressedDrilldown( + visibleMessages: VisibleMessageInfo[], + toolFilter: string | undefined, + sort: string, + limit: number, +): string[] { + const lines: string[] = [] + let filtered = visibleMessages + + if (toolFilter) { + filtered = filtered.filter((m) => m.tool === toolFilter) + } + + if (sort === "time") { + filtered.sort((a, b) => a.index - b.index) + } else if (sort === "tool") { + filtered.sort((a, b) => a.tool.localeCompare(b.tool) || b.tokens - a.tokens) + } else { + filtered.sort((a, b) => b.tokens - a.tokens) + } + + const totalTokens = filtered.reduce((s, m) => s + m.tokens, 0) + const allTokens = visibleMessages.reduce((s, m) => s + m.tokens, 0) + + const header = toolFilter + ? `UNCOMPRESSED — ${toolFilter}: ${formatTokens(totalTokens)} | ${filtered.length} msgs | ${pct(totalTokens, allTokens)}% of visible` + : `UNCOMPRESSED — ${formatTokens(totalTokens)} | ${filtered.length} msgs` + + lines.push(header) + lines.push(`Sorted by ${sort}`) + lines.push("") + + const shown = filtered.slice(0, limit) + for (const m of shown) { + lines.push(` ${m.ref} (${formatTokens(m.tokens)}) ${m.tool}`) + } + + if (filtered.length > shown.length) { + lines.push("") + lines.push(`${shown.length} of ${filtered.length} shown (${filtered.length - shown.length} hidden).`) + } + + if (filtered.length > 1 && sort !== "time") { + const refs = filtered.map((m) => m.index) + const minIdx = Math.min(...refs) + const maxIdx = Math.max(...refs) + const span = maxIdx - minIdx + const avgGap = span / (filtered.length - 1) + const minRef = filtered.find((m) => m.index === minIdx)?.ref || "?" + const maxRef = filtered.find((m) => m.index === maxIdx)?.ref || "?" + lines.push("") + lines.push(`Spread: ${minRef}–${maxRef} (avg gap ${avgGap.toFixed(0)} msgs)`) + } + + return lines +} + +function renderCompressedDrilldown( + blocks: CompressionBlock[], + sort: string, + limit: number, +): string[] { + const lines: string[] = [] + let sorted = [...blocks] + + if (sort === "time") { + sorted.sort((a, b) => a.createdAt - b.createdAt) + } else if (sort === "age") { + sorted.sort((a, b) => (b.survivedCount || 0) - (a.survivedCount || 0)) + } else { + sorted.sort((a, b) => (b.compressedTokens || 0) - (a.compressedTokens || 0)) } + const totalSummary = sorted.reduce((s, b) => s + (b.summaryTokens || 0), 0) + const totalCompressed = sorted.reduce((s, b) => s + (b.compressedTokens || 0), 0) + + lines.push(`COMPRESSED — ${sorted.length} blocks | ${formatTokens(totalCompressed)} original → ${formatTokens(totalSummary)} summary`) + lines.push(`Sorted by ${sort === "time" ? "time" : sort === "age" ? "age" : "size"}`) + lines.push("") + + const shown = sorted.slice(0, limit) + for (const b of shown) { + const survived = b.survivedCount ?? 0 + const gen = b.generation ?? "young" + const effCount = b.effectiveMessageIds?.length ?? 0 + const consumed = + b.consumedBlockIds && b.consumedBlockIds.length > 0 + ? ` nested=[${b.consumedBlockIds.map((n) => `b${n}`).join(",")}]` + : "" + const topic = b.topic || "(no topic)" + lines.push( + ` b${b.blockId} ${formatTokens(b.compressedTokens)}→${formatTokens(b.summaryTokens)} ${formatAge(b.createdAt)} ${formatIdRange(b)} age=${survived} ${gen} eff=${effCount}${consumed}`, + ) + lines.push(` "${topic}"`) + } + + if (sorted.length > shown.length) { + lines.push("") + lines.push(`${shown.length} of ${sorted.length} shown.`) + } + + lines.push("") + lines.push("Use decompress to restore a block's content, or search_context to search within blocks.") + return lines } -function buildVisibleMessages( +function buildVisibleWithSummaries( rawMessages: WithParts[], ctx: ToolContext, ): WithParts[] { @@ -197,89 +322,62 @@ export function createAcpStatusTool(ctx: ToolContext): ReturnType { return tool({ description: ACP_STATUS_TOOL_DESCRIPTION, args: { - mode: tool.schema + scope: tool.schema + .string() + .optional() + .describe('Drill down: "compressed" or "uncompressed". No arg = overview of both.'), + tool: tool.schema .string() .optional() - .describe('Output detail level: "summary" (default) or "detailed"'), + .describe('Filter by tool type (only with scope:"uncompressed"). e.g., "bash", "todowrite", "write"'), sort: tool.schema .string() .optional() - .describe('Sort order for blocks: "recent" (default), "size", or "age"'), + .describe('Sort order: "size" (default), "time", or "tool"'), limit: tool.schema .number() .optional() - .describe("Maximum blocks to show (default 30)"), + .describe("Max items to list (default 30)"), }, async execute(args, toolCtx) { - const mode = args.mode === "detailed" ? "detailed" : "summary" - const sort: "recent" | "size" | "age" = - args.sort === "size" || args.sort === "age" ? args.sort : "recent" + const scope = args.scope === "compressed" || args.scope === "uncompressed" ? args.scope : undefined + const toolFilter = typeof args.tool === "string" ? args.tool : undefined + const sort = args.sort === "time" || args.sort === "tool" || args.sort === "age" ? args.sort : "size" const limit = Number.isFinite(args.limit) && args.limit! > 0 ? Math.min(args.limit!, 200) : 30 - const lines: string[] = [] - - try { - const rawMessages = await fetchSessionMessages(ctx.client, toolCtx.sessionID) - const visibleMessages = buildVisibleMessages(rawMessages, ctx) - const composition = estimateContextComposition(visibleMessages, ctx.state) - - lines.push(...renderVisibleBreakdown(composition, visibleMessages, ctx.state, mode)) - } catch { - lines.push("VISIBLE CONTEXT (uncompressed)") - lines.push(" (unable to fetch messages for breakdown)") - } - - lines.push("") - const messages = ctx.state.prune.messages - const activeIds = Array.from(messages.activeBlockIds).sort((a, b) => a - b) - - if (activeIds.length === 0) { - lines.push("COMPRESSED BLOCKS") - lines.push(" No compressed blocks. Context is fully visible.") - return lines.join("\n") - } - + const msgState = ctx.state.prune.messages + const activeIds = Array.from(msgState.activeBlockIds).sort((a, b) => a - b) const allBlocks = activeIds - .map((id) => messages.blocksById.get(id)) + .map((id) => msgState.blocksById.get(id)) .filter((b): b is NonNullable => b !== undefined && b.active) - if (allBlocks.length === 0) { - lines.push("COMPRESSED BLOCKS") - lines.push(" No compressed blocks. Context is fully visible.") + const lines: string[] = [] + + if (scope === "compressed") { + lines.push(...renderCompressedDrilldown(allBlocks, sort, limit)) return lines.join("\n") } - const totalSummary = allBlocks.reduce((s, b) => s + (b.summaryTokens || 0), 0) - const totalCompressed = allBlocks.reduce((s, b) => s + (b.compressedTokens || 0), 0) - const sorted = sortBlocks(allBlocks, sort) - const shown = sorted.slice(0, limit) - const truncated = sorted.length - shown.length + let visibleMsgs: VisibleMessageInfo[] = [] + let summaryTokens = 0 + let fetchFailed = false - const idWidth = Math.max(...shown.map((b) => String(b.blockId).length)) - - lines.push( - `COMPRESSED BLOCKS — ${allBlocks.length} active (${formatTokens(totalSummary)} summary, ${formatTokens(totalCompressed)} original compressed)`, - ) - lines.push("") - - for (const b of shown) { - lines.push( - mode === "detailed" ? renderDetailedRow(b, idWidth) : renderSummaryRow(b, idWidth), - ) + try { + const rawMessages = await fetchSessionMessages(ctx.client, toolCtx.sessionID) + const result = collectVisibleMessages(rawMessages, ctx) + visibleMsgs = result.messages + summaryTokens = result.summaryTokens + } catch { + fetchFailed = true } - if (truncated > 0) { - lines.push("") - lines.push(`${shown.length} of ${sorted.length} blocks shown (${truncated} hidden). Raise limit or change sort to see more.`) + if (scope === "uncompressed") { + if (fetchFailed) return "(unable to fetch messages)" + lines.push(...renderUncompressedDrilldown(visibleMsgs, toolFilter, sort, limit)) + } else { + lines.push(...renderOverview(visibleMsgs, summaryTokens, allBlocks, fetchFailed)) } - lines.push("") - const sortHint = - sort === "recent" - ? 'Blocks sorted by recent. Use acp_status({sort:"size"}) for largest, {sort:"age"}) for near-GC.' - : `Blocks sorted by ${sort}.` - lines.push(`${sortHint} Use decompress to restore a block's full content, or search_context to search within compressed blocks.`) - return lines.join("\n") }, }) diff --git a/tests/acp-status.test.ts b/tests/acp-status.test.ts index 9584993..7229a7a 100644 --- a/tests/acp-status.test.ts +++ b/tests/acp-status.test.ts @@ -79,9 +79,21 @@ function makeState(activeIds: number[], blocks: Map): } } -function makeToolContext(activeIds: number[], blocks: Map): ToolContext { +function makeMockClient(messages: any[] = []): any { return { - client: {}, + session: { + messages: async () => ({ data: messages }), + }, + } +} + +function makeToolContext( + activeIds: number[], + blocks: Map, + client?: any, +): ToolContext { + return { + client: client ?? {}, state: makeState(activeIds, blocks), logger: { enabled: false } as any, config: {} as any, @@ -100,11 +112,12 @@ function blocksMap(...blocks: CompressionBlock[]): Map async function runStatus( activeIds: number[], blocks: Map, - args: { mode?: string; sort?: string; limit?: number } = {}, + args: { scope?: string; tool?: string; sort?: string; limit?: number } = {}, + client?: any, ): Promise { - const ctx = makeToolContext(activeIds, blocks) + const ctx = makeToolContext(activeIds, blocks, client) const statusTool = createAcpStatusTool(ctx) - return statusTool.execute(args as any, {} as any) + return statusTool.execute(args as any, { sessionID: SID } as any) } test("acp_status: empty state returns no-blocks message", async () => { @@ -116,7 +129,7 @@ test("acp_status: single block shows correct header with summary and original si const blocks = blocksMap(makeBlock({ blockId: 1, summaryTokens: 750, compressedTokens: 5000, topic: "My topic" })) const result = await runStatus([1], blocks) - assert.match(result, /COMPRESSED BLOCKS — 1 active \(/) + assert.match(result, /COMPRESSED BLOCKS/) assert.match(result, /b1/) assert.match(result, /"My topic"/) }) @@ -128,7 +141,7 @@ test("acp_status: plural header for multiple blocks", async () => { ) const result = await runStatus([1, 2], blocks) - assert.match(result, /2 active \(/) + assert.match(result, /2 active/) assert.match(result, /1\.1K summary/) }) @@ -139,7 +152,7 @@ test("acp_status: block with no topic shows (no topic)", async () => { assert.match(result, /\(no topic\)/) }) -test("acp_status: summary row shows compressed→summary size pair", async () => { +test("acp_status: overview shows compressed→summary size pair", async () => { const blocks = blocksMap( makeBlock({ blockId: 1, summaryTokens: 500, compressedTokens: 800 }), makeBlock({ blockId: 2, summaryTokens: 2000, compressedTokens: 15000 }), @@ -150,7 +163,7 @@ test("acp_status: summary row shows compressed→summary size pair", async () => assert.match(result, /15\.0K→2\.0K/) }) -test("acp_status: summary row shows mNNNNN–mNNNNN range from startId/endId", async () => { +test("acp_status: overview shows mNNNNN–mNNNNN range from startId/endId", async () => { const blocks = blocksMap( makeBlock({ blockId: 1, startId: "m00010", endId: "m00025" }), ) @@ -169,113 +182,145 @@ test("acp_status: range shows single ID when startId === endId", async () => { assert.doesNotMatch(result, /m00005–m00005/) }) -test("acp_status: idWidth based on displayed blocks, not activeIds", async () => { - const blocks = blocksMap( - makeBlock({ blockId: 1, summaryTokens: 100, compressedTokens: 500 }), - ) - const result = await runStatus([1, 99], blocks) - - assert.match(result, /b1/) - assert.doesNotMatch(result, /b99/) -}) - -test("acp_status: includes usage hint at end", async () => { +test("acp_status: overview includes drill-down hint", async () => { const blocks = blocksMap(makeBlock({ blockId: 1 })) const result = await runStatus([1], blocks) - assert.match(result, /Use decompress/) - assert.match(result, /search_context/) + assert.match(result, /Drill down/) + assert.match(result, /scope/) }) -test("acp_status: default sort is recent (newest createdAt first)", async () => { - const now = Date.now() +test("acp_status: scope=compressed shows detailed block info", async () => { const blocks = blocksMap( - makeBlock({ blockId: 1, createdAt: now - 10_000, topic: "older" }), - makeBlock({ blockId: 2, createdAt: now - 1_000, topic: "newer" }), + makeBlock({ + blockId: 1, + survivedCount: 3, + generation: "old", + effectiveMessageIds: ["a", "b", "c", "d"], + consumedBlockIds: [2, 3], + }), ) - const result = await runStatus([1, 2], blocks) + const result = await runStatus([1], blocks, { scope: "compressed" }) - const olderPos = result.indexOf("older") - const newerPos = result.indexOf("newer") - assert.ok(newerPos < olderPos, "newer block should appear before older block") + assert.match(result, /COMPRESSED/) + assert.match(result, /age=3/) + assert.match(result, /old/) + assert.match(result, /eff=4/) + assert.match(result, /nested=\[b2,b3\]/) }) -test("acp_status: sort=size orders largest compressedTokens first", async () => { +test("acp_status: scope=compressed sort=size orders largest first", async () => { const blocks = blocksMap( makeBlock({ blockId: 1, compressedTokens: 500, topic: "small" }), makeBlock({ blockId: 2, compressedTokens: 50_000, topic: "large" }), ) - const result = await runStatus([1, 2], blocks, { sort: "size" }) + const result = await runStatus([1, 2], blocks, { scope: "compressed", sort: "size" }) const smallPos = result.indexOf("small") const largePos = result.indexOf("large") assert.ok(largePos < smallPos, "largest block should appear first") - assert.match(result, /sorted by size/) + assert.match(result, /Sorted by size/) }) -test("acp_status: sort=age orders highest survivedCount first", async () => { +test("acp_status: scope=compressed sort=age orders highest survivedCount first", async () => { + const blocks = blocksMap( + makeBlock({ blockId: 1, survivedCount: 1, topic: "alpha-topic" }), + makeBlock({ blockId: 2, survivedCount: 14, topic: "beta-topic" }), + ) + const result = await runStatus([1, 2], blocks, { scope: "compressed", sort: "age" }) + + const alphaPos = result.indexOf("alpha-topic") + const betaPos = result.indexOf("beta-topic") + assert.ok(betaPos < alphaPos, "highest survivedCount block should appear first") +}) + +test("acp_status: scope=compressed sort=time orders oldest createdAt first", async () => { + const now = Date.now() const blocks = blocksMap( - makeBlock({ blockId: 1, survivedCount: 1, topic: "young" }), - makeBlock({ blockId: 2, survivedCount: 14, topic: "old" }), + makeBlock({ blockId: 1, createdAt: now - 10_000, topic: "older" }), + makeBlock({ blockId: 2, createdAt: now - 1_000, topic: "newer" }), ) - const result = await runStatus([1, 2], blocks, { sort: "age" }) + const result = await runStatus([1, 2], blocks, { scope: "compressed", sort: "time" }) - const youngPos = result.indexOf("young") - const oldPos = result.indexOf("old") - assert.ok(oldPos < youngPos, "oldest block should appear first") - assert.match(result, /sorted by age/) + const olderPos = result.indexOf("older") + const newerPos = result.indexOf("newer") + assert.ok(olderPos < newerPos, "oldest block should appear first") }) -test("acp_status: limit caps the number of shown blocks", async () => { +test("acp_status: scope=compressed limit caps shown blocks", async () => { const blocks: CompressionBlock[] = [] for (let i = 1; i <= 5; i++) { blocks.push(makeBlock({ blockId: i, topic: `block-${i}` })) } const map = blocksMap(...blocks) - const result = await runStatus([1, 2, 3, 4, 5], map, { limit: 2 }) + const result = await runStatus([1, 2, 3, 4, 5], map, { scope: "compressed", limit: 2 }) - assert.match(result, /2 of 5 blocks shown/) - assert.match(result, /3 hidden/) + assert.match(result, /2 of 5 shown/) }) -test("acp_status: detailed mode includes survived/generation/effective fields", async () => { - const blocks = blocksMap( - makeBlock({ - blockId: 1, - survivedCount: 3, - generation: "old", - effectiveMessageIds: ["a", "b", "c", "d"], - consumedBlockIds: [2, 3], - }), - ) - const result = await runStatus([1], blocks, { mode: "detailed" }) +test("acp_status: scope=compressed includes decompress hint", async () => { + const blocks = blocksMap(makeBlock({ blockId: 1 })) + const result = await runStatus([1], blocks, { scope: "compressed" }) - assert.match(result, /age=3/) - assert.match(result, /old/) - assert.match(result, /eff=4/) - assert.match(result, /nested=\[b2,b3\]/) + assert.match(result, /Use decompress/) + assert.match(result, /search_context/) }) -test("acp_status: summary mode (default) does not include detailed fields", async () => { - const blocks = blocksMap( - makeBlock({ blockId: 1, survivedCount: 3, generation: "old" }), - ) - const result = await runStatus([1], blocks) +test("acp_status: scope=uncompressed shows message list header", async () => { + const mockMsgs = [ + { info: { id: "raw-1", role: "assistant" }, parts: [{ type: "text", text: "hello world" }] }, + ] + const mockClient = makeMockClient(mockMsgs) + const state = makeState([], new Map()) + state.messageIds.byRawId.set("raw-1", "m00001") + const ctx: ToolContext = { + client: mockClient, + state, + logger: { enabled: false } as any, + config: {} as any, + prompts: { reload: () => {} } as any, + } + const statusTool = createAcpStatusTool(ctx) + const result = await statusTool.execute({ scope: "uncompressed" } as any, { sessionID: SID } as any) - assert.doesNotMatch(result, /age=3/) - assert.doesNotMatch(result, /eff=/) + assert.match(result, /UNCOMPRESSED/) + assert.match(result, /Sorted by/) }) -test("acp_status: invalid mode falls back to summary", async () => { - const blocks = blocksMap(makeBlock({ blockId: 1, survivedCount: 3 })) - const result = await runStatus([1], blocks, { mode: "bogus" as any }) +test("acp_status: scope=uncompressed with tool filter shows filter in header", async () => { + const mockMsgs = [ + { + info: { id: "raw-1", role: "assistant" }, + parts: [{ type: "tool", tool: "bash", state: { input: { command: "ls" } } }], + }, + ] + const mockClient = makeMockClient(mockMsgs) + const state = makeState([], new Map()) + state.messageIds.byRawId.set("raw-1", "m00001") + const ctx: ToolContext = { + client: mockClient, + state, + logger: { enabled: false } as any, + config: {} as any, + prompts: { reload: () => {} } as any, + } + const statusTool = createAcpStatusTool(ctx) + const result = await statusTool.execute({ scope: "uncompressed", tool: "bash" } as any, { sessionID: SID } as any) + + assert.match(result, /UNCOMPRESSED — bash:/) +}) + +test("acp_status: invalid scope falls back to overview", async () => { + const blocks = blocksMap(makeBlock({ blockId: 1 })) + const result = await runStatus([1], blocks, { scope: "bogus" as any }) - assert.doesNotMatch(result, /age=3/) + assert.match(result, /COMPRESSED BLOCKS/) + assert.match(result, /Drill down/) }) -test("acp_status: invalid sort falls back to recent", async () => { +test("acp_status: invalid sort falls back to size", async () => { const blocks = blocksMap(makeBlock({ blockId: 1 })) - const result = await runStatus([1], blocks, { sort: "bogus" as any }) + const result = await runStatus([1], blocks, { scope: "compressed", sort: "bogus" as any }) - assert.match(result, /Blocks sorted by recent/) + assert.match(result, /Sorted by size/) }) From 891456009474411503f8c517b7a10504b860d944 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Thu, 9 Jul 2026 22:47:42 +0800 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20tool=20type=20detection=20?= =?UTF-8?q?=E2=80=94=20only=20count=20type:'tool'=20parts,=20skip=20step-s?= =?UTF-8?q?tart/step-finish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: part.type check was 'not text and not reasoning' which caught step-start and step-finish metadata parts as tool parts. These have no .tool field, so toolName fell back to part.type ('step-start'). Fix: only process part.type === 'tool' as actual tool parts. This correctly identifies bash/write/edit/todowrite etc. Applied to both: - collectVisibleMessages() in status.ts - estimateContextComposition() in utils.ts (affects nudge too) --- lib/compress/status.ts | 4 ++-- lib/messages/inject/utils.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/compress/status.ts b/lib/compress/status.ts index d94d0c5..89b7330 100644 --- a/lib/compress/status.ts +++ b/lib/compress/status.ts @@ -98,11 +98,11 @@ function collectVisibleMessages( for (const part of msg.parts || []) { if (part.type === "text" && typeof (part as any).text === "string") { tokens += Math.round(((part as any).text as string).length / 4) - } else if (part.type !== "text" && part.type !== "reasoning") { + } else if (part.type === "tool") { const raw = JSON.stringify(part) tokens += Math.round(raw.length / 4) if (!toolName) { - toolName = (part as any)?.tool || (part as any)?.type || "unknown" + toolName = (part as any)?.tool || "unknown" } } } diff --git a/lib/messages/inject/utils.ts b/lib/messages/inject/utils.ts index 7bf6082..2a58982 100644 --- a/lib/messages/inject/utils.ts +++ b/lib/messages/inject/utils.ts @@ -622,13 +622,13 @@ export function estimateContextComposition( msgCode += cTokens } } - } else if (part.type !== "text" && part.type !== "reasoning") { + } else if (part.type === "tool") { const raw = JSON.stringify(part) const tokens = Math.round(raw.length / 4) msgTotal += tokens toolTokens += tokens msgTool += tokens - const toolName = (part as any)?.tool || (part as any)?.type || "unknown" + const toolName = (part as any)?.tool || "unknown" toolTypeMap.set(toolName, (toolTypeMap.get(toolName) || 0) + tokens) if (!msgToolName) msgToolName = toolName } From d075f316eed14ab50bc9f26f468fd944ca8c3832 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Thu, 9 Jul 2026 22:50:41 +0800 Subject: [PATCH 09/12] =?UTF-8?q?refactor:=20open-ended=20drill-down=20hin?= =?UTF-8?q?t=20=E2=80=94=20show=20params=20can=20be=20mixed=20freely?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/compress/status.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/compress/status.ts b/lib/compress/status.ts index 89b7330..c7bd007 100644 --- a/lib/compress/status.ts +++ b/lib/compress/status.ts @@ -179,7 +179,7 @@ function renderOverview( } lines.push("") - lines.push('Drill down: acp_status({scope:"uncompressed"}) or {scope:"compressed"})') + lines.push('Tip: acp_status({scope:"uncompressed", tool:"todowrite", sort:"size"}) — mix any params freely') return lines } From 9b6f74f5605cf8a8edb8b2e4537a3c1d5cd2b1e0 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Thu, 9 Jul 2026 22:57:23 +0800 Subject: [PATCH 10/12] fix: dynamic drill-down hint shows session's actual top tool, not hardcoded --- lib/compress/status.ts | 15 ++++++++++----- tests/acp-status.test.ts | 4 ++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/compress/status.ts b/lib/compress/status.ts index c7bd007..1ad418c 100644 --- a/lib/compress/status.ts +++ b/lib/compress/status.ts @@ -123,6 +123,13 @@ function renderOverview( ): string[] { const lines: string[] = [] + const toolTypeMap = new Map() + for (const m of visibleMessages) { + toolTypeMap.set(m.tool, (toolTypeMap.get(m.tool) || 0) + m.tokens) + } + const topToolName = Array.from(toolTypeMap.entries()) + .sort((a, b) => b[1] - a[1])[0]?.[0] + if (fetchFailed) { lines.push("VISIBLE CONTEXT (uncompressed)") lines.push(" (unable to fetch messages for breakdown)") @@ -144,10 +151,6 @@ function renderOverview( ` ${formatTokens(total)} total | ${formatTokens(totalTool)} tool (${toolPct}%) | ${formatTokens(totalText)} text (${textPct}%) | ${formatTokens(summaryTokens)} summaries (${summaryPct}%)`, ) - const toolTypeMap = new Map() - for (const m of visibleMessages) { - toolTypeMap.set(m.tool, (toolTypeMap.get(m.tool) || 0) + m.tokens) - } const topTypes = Array.from(toolTypeMap.entries()) .map(([tool, tokens]) => ({ tool, tokens })) .sort((a, b) => b.tokens - a.tokens) @@ -179,7 +182,9 @@ function renderOverview( } lines.push("") - lines.push('Tip: acp_status({scope:"uncompressed", tool:"todowrite", sort:"size"}) — mix any params freely') + + const hintTool = topToolName || "bash" + lines.push(`Tip: acp_status({scope:"uncompressed", tool:"${hintTool}", sort:"size"}) — mix any params freely`) return lines } diff --git a/tests/acp-status.test.ts b/tests/acp-status.test.ts index 7229a7a..eb830e1 100644 --- a/tests/acp-status.test.ts +++ b/tests/acp-status.test.ts @@ -186,7 +186,7 @@ test("acp_status: overview includes drill-down hint", async () => { const blocks = blocksMap(makeBlock({ blockId: 1 })) const result = await runStatus([1], blocks) - assert.match(result, /Drill down/) + assert.match(result, /Tip:/) assert.match(result, /scope/) }) @@ -315,7 +315,7 @@ test("acp_status: invalid scope falls back to overview", async () => { const result = await runStatus([1], blocks, { scope: "bogus" as any }) assert.match(result, /COMPRESSED BLOCKS/) - assert.match(result, /Drill down/) + assert.match(result, /Tip:/) }) test("acp_status: invalid sort falls back to size", async () => { From 22ad5300bc1e42360fc4d0fe87694fc0fd38f2bd Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Thu, 9 Jul 2026 23:05:59 +0800 Subject: [PATCH 11/12] =?UTF-8?q?feat:=20compress=20prune=20mode=20?= =?UTF-8?q?=E2=80=94=20toolType=20param=20removes=20old=20tool=20outputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New compress mode: pass toolType instead of content to prune all old messages of that tool type. Keeps latest N (default 3). Creates a single compression block covering scattered messages. Example: compress({topic:"old todowrite", toolType:"todowrite", keepLatest:3}) Updated: - compress tool schema: content now optional, added toolType + keepLatest - system prompt: compress + acp_status descriptions updated - compress-range prompt: mentions both modes --- lib/compress/range.ts | 132 ++++++++++++++++++++++++++++++++-- lib/prompts/compress-range.ts | 4 ++ lib/prompts/system.ts | 4 +- 3 files changed, 132 insertions(+), 8 deletions(-) diff --git a/lib/compress/range.ts b/lib/compress/range.ts index 119d4cf..3fd249e 100644 --- a/lib/compress/range.ts +++ b/lib/compress/range.ts @@ -50,9 +50,20 @@ function buildSchema(maxSummaryLengthHard: number) { ), }), ) + .optional() + .describe( + "Range mode: one or more contiguous ranges to compress, each with start/end boundaries and a summary", + ), + toolType: tool.schema + .string() + .optional() .describe( - "One or more ranges to compress, each with start/end boundaries and a summary", + 'Prune mode: remove all old messages of this tool type (e.g. "todowrite", "edit"). Keeps only recent ones. Use for disposable tool outputs.', ), + keepLatest: tool.schema + .number() + .optional() + .describe("Prune mode: how many recent messages to keep (default 3)"), summaryMaxChars: tool.schema .number() .optional() @@ -68,6 +79,15 @@ export function createCompressRangeTool(ctx: ToolContext): ReturnType { + const keepLatest = args.keepLatest ?? 3 + const { rawMessages, searchContext } = await prepareSession( + ctx, + toolCtx, + `Prune: ${args.topic}`, + ) + + const matchingMessages: Array<{ messageId: string; index: number; tokens: number }> = [] + for (let i = 0; i < rawMessages.length; i++) { + const msg = rawMessages[i] + if (!msg) continue + const msgId = (msg.info as any)?.id || "" + if (!msgId) continue + + const pruneEntry = ctx.state.prune.messages.byMessageId.get(msgId) + if (pruneEntry && pruneEntry.activeBlockIds.length > 0) continue + + let hasTool = false + let tokenCount = 0 + for (const part of msg.parts || []) { + if (part.type === "tool") { + const partTool = (part as any)?.tool || "" + if (partTool === args.toolType) { + hasTool = true + } + } + if (part.type === "text") { + tokenCount += Math.round(((part as any).text || "").length / 4) + } else if (part.type === "tool") { + tokenCount += Math.round(JSON.stringify(part).length / 4) + } + } + + if (hasTool) { + matchingMessages.push({ messageId: msgId, index: i, tokens: tokenCount }) + } + } + + if (matchingMessages.length <= keepLatest) { + return `Nothing to prune — only ${matchingMessages.length} ${args.toolType} messages visible (keepLatest=${keepLatest}).` + } + + matchingMessages.sort((a, b) => a.index - b.index) + const toPrune = matchingMessages.slice(0, matchingMessages.length - keepLatest) + + const firstRef = ctx.state.messageIds.byRawId.get(toPrune[0]!.messageId) || "?" + const lastRef = ctx.state.messageIds.byRawId.get(toPrune[toPrune.length - 1]!.messageId) || "?" + + const messageTokenById = new Map() + for (const m of toPrune) { + messageTokenById.set(m.messageId, m.tokens) + } + + const blockId = allocateBlockId(ctx.state) + const runId = allocateRunId(ctx.state) + + const summary = `Pruned ${toPrune.length} ${args.toolType} messages (old outputs removed, latest ${keepLatest} kept). Range: ${firstRef}–${lastRef}.` + const storedSummary = wrapCompressedSummary(blockId, summary) + const summaryTokens = countTokens(storedSummary) + + applyCompressionState( + ctx.state, + { + topic: args.topic, + batchTopic: args.topic, + startId: firstRef, + endId: lastRef, + mode: "range", + runId, + compressMessageId: toolCtx.messageID, + compressCallId: callId, + summaryTokens, + }, + { + startReference: { kind: "message", rawIndex: toPrune[0]!.index }, + endReference: { kind: "message", rawIndex: toPrune[toPrune.length - 1]!.index }, + messageIds: toPrune.map((m) => m.messageId), + messageTokenById, + toolIds: [], + requiredBlockIds: [], + }, + toPrune[0]!.messageId, + blockId, + storedSummary, + [], + ctx.config.gc, + ) + + await finalizeSession( + ctx, + toolCtx, + rawMessages, + [{ blockId, runId, summary, summaryTokens }], + args.topic, + ) + + return `Pruned ${toPrune.length} ${args.toolType} messages (kept latest ${keepLatest}). Range: ${firstRef}–${lastRef}.\nIMPORTANT: This was an automatic context pruning. You MUST continue your previous task exactly where you left off. Do NOT ask the user what to do next.` +} diff --git a/lib/prompts/compress-range.ts b/lib/prompts/compress-range.ts index 7230302..c0d990f 100644 --- a/lib/prompts/compress-range.ts +++ b/lib/prompts/compress-range.ts @@ -1,5 +1,9 @@ export const COMPRESS_RANGE = `Collapse a range in the conversation into a detailed summary. +TWO MODES: +1. Range mode (content param): compress contiguous ranges with summaries you write. +2. Prune mode (toolType param): remove all old messages of a specific tool type, keeping only the latest few. No summary needed — the system auto-generates one. Use for disposable tool outputs (old todowrite states, edit echoes). + COMPRESSED BLOCK PLACEHOLDERS The system auto-detects any previously compressed blocks whose anchor messages fall inside your selected range. You do NOT need to manually list \`(bN)\` placeholders in your summary — every consumed block is tracked automatically. diff --git a/lib/prompts/system.ts b/lib/prompts/system.ts index fd19043..04ef38e 100644 --- a/lib/prompts/system.ts +++ b/lib/prompts/system.ts @@ -21,10 +21,10 @@ TOOLS You have four context-management tools: -- \`compress\` — Replace a contiguous range of older conversation with a single detailed summary you write. Use when content is genuinely consumed (no longer needed for the current task step). Example: \`compress({ topic: "API exploration", content: [{ startId: "m00150", endId: "m00220", summary: "..." }] })\`. +- \`compress\` — Two modes. Range mode: replace a contiguous range with a detailed summary. Example: \`compress({ topic: "API exploration", content: [{ startId: "m00150", endId: "m00220", summary: "..." }] })\`. Prune mode: remove all old messages of a tool type, keeping only recent ones. For disposable outputs like old todowrite states. Example: \`compress({ topic: "old todowrite", toolType: "todowrite", keepLatest: 3 })\`. - \`decompress\` — Restore a previously compressed block's full original content, optionally to a file for large blocks. Use when a summary lacks the exact detail you need. Example: \`decompress({ blockId: "b5" })\` or \`decompress({ blockId: "b5", toFile: "path" })\`. - \`search_context\` — Search compressed block summaries (and optionally visible messages) by keyword. Use BEFORE decompressing to find the right block. Example: \`search_context({ query: "auth token refresh" })\`. -- \`acp_status\` — Show full context status: visible (uncompressed) token breakdown by category (tool outputs, code, text, summaries) with largest items, plus the active compressed block list. Use to see what's consuming context and what to compress. Example: \`acp_status({ mode: "detailed", sort: "size" })\`. +- \`acp_status\` — Context status with drilldown. No args = overview. \`scope:"uncompressed"\` lists all visible messages; add \`tool:"bash"\` to filter by tool type. \`scope:"compressed"\` shows block details. Example: \`acp_status({scope:"uncompressed", tool:"todowrite"})\`. COMPRESSION PHILOSOPHY From 2106995dadfb9828d97b0078d814d584a5428eb9 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Thu, 9 Jul 2026 23:17:01 +0800 Subject: [PATCH 12/12] =?UTF-8?q?fix:=20separate=20prune=20tool=20from=20c?= =?UTF-8?q?ompress=20=E2=80=94=20safety=20+=20correct=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverted: compress toolType param (dangerous to mix compress+delete) Added: standalone prune tool (lib/compress/prune-tool.ts) - Takes toolType + keepLatest params - Adds tool call IDs to state.prune.tools - Uses existing prune mechanism (no compression blocks) Fixed: prune.ts:95 — removed edit/write restriction so ALL tool types can be pruned when their callID is in state.prune.tools Updated: system prompt — compress and prune described separately --- index.ts | 2 + lib/compress/index.ts | 1 + lib/compress/prune-tool.ts | 79 ++++++++++++++++++++ lib/compress/range.ts | 132 ++-------------------------------- lib/messages/prune.ts | 3 - lib/prompts/compress-range.ts | 4 -- lib/prompts/system.ts | 3 +- 7 files changed, 90 insertions(+), 134 deletions(-) create mode 100644 lib/compress/prune-tool.ts diff --git a/index.ts b/index.ts index 4663378..f6f0e3e 100644 --- a/index.ts +++ b/index.ts @@ -5,6 +5,7 @@ import { createCompressMessageTool, createCompressRangeTool, createDecompressTool, + createPruneTool, createSearchContextTool, } from "./lib/compress" import { @@ -91,6 +92,7 @@ const server: Plugin = (async (ctx) => { ? createCompressMessageTool(compressToolContext) : createCompressRangeTool(compressToolContext), decompress: createDecompressTool(compressToolContext), + prune: createPruneTool(compressToolContext), search_context: createSearchContextTool(compressToolContext), acp_status: createAcpStatusTool(compressToolContext), }), diff --git a/lib/compress/index.ts b/lib/compress/index.ts index 9cd17fd..490351e 100644 --- a/lib/compress/index.ts +++ b/lib/compress/index.ts @@ -4,3 +4,4 @@ export { createCompressRangeTool } from "./range" export { createDecompressTool } from "./decompress" export { createSearchContextTool } from "./search" export { createAcpStatusTool } from "./status" +export { createPruneTool } from "./prune-tool" diff --git a/lib/compress/prune-tool.ts b/lib/compress/prune-tool.ts new file mode 100644 index 0000000..0029ccd --- /dev/null +++ b/lib/compress/prune-tool.ts @@ -0,0 +1,79 @@ +import { tool } from "@opencode-ai/plugin" +import type { ToolContext } from "./types" +import { fetchSessionMessages } from "./search" +import { finalizeSession, prepareSession } from "./pipeline" + +const PRUNE_TOOL_DESCRIPTION = `Remove old tool outputs by tool type — frees context without compression. + +Unlike compress (which creates summaries), prune directly strips tool call outputs from context. Use for disposable tool outputs where the content is no longer needed: old todowrite states, edit success echoes, repeated status checks. + +Args: +- toolType: tool name to prune (e.g., "todowrite", "bash", "edit") +- keepLatest: how many recent calls to keep visible (default 3)` + +export function createPruneTool(ctx: ToolContext): ReturnType { + ctx.prompts.reload() + + return tool({ + description: PRUNE_TOOL_DESCRIPTION, + args: { + toolType: tool.schema + .string() + .describe('Tool name to prune (e.g., "todowrite", "bash", "edit")'), + keepLatest: tool.schema + .number() + .optional() + .describe("How many recent calls to keep visible (default 3)"), + }, + async execute(args, toolCtx) { + const keepLatest = args.keepLatest ?? 3 + const { rawMessages } = await prepareSession( + ctx, + toolCtx, + `Prune: ${args.toolType}`, + ) + + const matchingCalls: Array<{ callId: string; index: number; tokens: number }> = [] + for (let i = 0; i < rawMessages.length; i++) { + const msg = rawMessages[i] + if (!msg) continue + + for (const part of msg.parts || []) { + if (part.type !== "tool") continue + const partTool = (part as any)?.tool || "" + if (partTool !== args.toolType) continue + + const callId = (part as any)?.callID + if (!callId || typeof callId !== "string") continue + if (ctx.state.prune.tools.has(callId)) continue + + const tokens = Math.round(JSON.stringify(part).length / 4) + matchingCalls.push({ callId, index: i, tokens }) + } + } + + if (matchingCalls.length <= keepLatest) { + return `Nothing to prune — only ${matchingCalls.length} ${args.toolType} calls visible (keepLatest=${keepLatest}).` + } + + matchingCalls.sort((a, b) => a.index - b.index) + const toPrune = matchingCalls.slice(0, matchingCalls.length - keepLatest) + + let totalTokens = 0 + for (const item of toPrune) { + ctx.state.prune.tools.set(item.callId, item.tokens) + totalTokens += item.tokens + } + + await finalizeSession( + ctx, + toolCtx, + rawMessages, + [], + `Prune ${args.toolType}`, + ) + + return `Pruned ${toPrune.length} ${args.toolType} calls (~${totalTokens} tokens). Kept latest ${keepLatest}. Outputs will be stripped on next context refresh.\nIMPORTANT: This was an automatic context pruning. You MUST continue your previous task exactly where you left off. Do NOT ask the user what to do next.` + }, + }) +} diff --git a/lib/compress/range.ts b/lib/compress/range.ts index 3fd249e..119d4cf 100644 --- a/lib/compress/range.ts +++ b/lib/compress/range.ts @@ -50,20 +50,9 @@ function buildSchema(maxSummaryLengthHard: number) { ), }), ) - .optional() - .describe( - "Range mode: one or more contiguous ranges to compress, each with start/end boundaries and a summary", - ), - toolType: tool.schema - .string() - .optional() .describe( - 'Prune mode: remove all old messages of this tool type (e.g. "todowrite", "edit"). Keeps only recent ones. Use for disposable tool outputs.', + "One or more ranges to compress, each with start/end boundaries and a summary", ), - keepLatest: tool.schema - .number() - .optional() - .describe("Prune mode: how many recent messages to keep (default 3)"), summaryMaxChars: tool.schema .number() .optional() @@ -79,15 +68,6 @@ export function createCompressRangeTool(ctx: ToolContext): ReturnType { - const keepLatest = args.keepLatest ?? 3 - const { rawMessages, searchContext } = await prepareSession( - ctx, - toolCtx, - `Prune: ${args.topic}`, - ) - - const matchingMessages: Array<{ messageId: string; index: number; tokens: number }> = [] - for (let i = 0; i < rawMessages.length; i++) { - const msg = rawMessages[i] - if (!msg) continue - const msgId = (msg.info as any)?.id || "" - if (!msgId) continue - - const pruneEntry = ctx.state.prune.messages.byMessageId.get(msgId) - if (pruneEntry && pruneEntry.activeBlockIds.length > 0) continue - - let hasTool = false - let tokenCount = 0 - for (const part of msg.parts || []) { - if (part.type === "tool") { - const partTool = (part as any)?.tool || "" - if (partTool === args.toolType) { - hasTool = true - } - } - if (part.type === "text") { - tokenCount += Math.round(((part as any).text || "").length / 4) - } else if (part.type === "tool") { - tokenCount += Math.round(JSON.stringify(part).length / 4) - } - } - - if (hasTool) { - matchingMessages.push({ messageId: msgId, index: i, tokens: tokenCount }) - } - } - - if (matchingMessages.length <= keepLatest) { - return `Nothing to prune — only ${matchingMessages.length} ${args.toolType} messages visible (keepLatest=${keepLatest}).` - } - - matchingMessages.sort((a, b) => a.index - b.index) - const toPrune = matchingMessages.slice(0, matchingMessages.length - keepLatest) - - const firstRef = ctx.state.messageIds.byRawId.get(toPrune[0]!.messageId) || "?" - const lastRef = ctx.state.messageIds.byRawId.get(toPrune[toPrune.length - 1]!.messageId) || "?" - - const messageTokenById = new Map() - for (const m of toPrune) { - messageTokenById.set(m.messageId, m.tokens) - } - - const blockId = allocateBlockId(ctx.state) - const runId = allocateRunId(ctx.state) - - const summary = `Pruned ${toPrune.length} ${args.toolType} messages (old outputs removed, latest ${keepLatest} kept). Range: ${firstRef}–${lastRef}.` - const storedSummary = wrapCompressedSummary(blockId, summary) - const summaryTokens = countTokens(storedSummary) - - applyCompressionState( - ctx.state, - { - topic: args.topic, - batchTopic: args.topic, - startId: firstRef, - endId: lastRef, - mode: "range", - runId, - compressMessageId: toolCtx.messageID, - compressCallId: callId, - summaryTokens, - }, - { - startReference: { kind: "message", rawIndex: toPrune[0]!.index }, - endReference: { kind: "message", rawIndex: toPrune[toPrune.length - 1]!.index }, - messageIds: toPrune.map((m) => m.messageId), - messageTokenById, - toolIds: [], - requiredBlockIds: [], - }, - toPrune[0]!.messageId, - blockId, - storedSummary, - [], - ctx.config.gc, - ) - - await finalizeSession( - ctx, - toolCtx, - rawMessages, - [{ blockId, runId, summary, summaryTokens }], - args.topic, - ) - - return `Pruned ${toPrune.length} ${args.toolType} messages (kept latest ${keepLatest}). Range: ${firstRef}–${lastRef}.\nIMPORTANT: This was an automatic context pruning. You MUST continue your previous task exactly where you left off. Do NOT ask the user what to do next.` -} diff --git a/lib/messages/prune.ts b/lib/messages/prune.ts index c553a4c..0c31011 100644 --- a/lib/messages/prune.ts +++ b/lib/messages/prune.ts @@ -92,9 +92,6 @@ const pruneFullTool = (state: SessionState, logger: Logger, messages: WithParts[ if (!state.prune.tools.has(part.callID)) { continue } - if (part.tool !== "edit" && part.tool !== "write") { - continue - } partsToRemove.push(part.callID) } diff --git a/lib/prompts/compress-range.ts b/lib/prompts/compress-range.ts index c0d990f..7230302 100644 --- a/lib/prompts/compress-range.ts +++ b/lib/prompts/compress-range.ts @@ -1,9 +1,5 @@ export const COMPRESS_RANGE = `Collapse a range in the conversation into a detailed summary. -TWO MODES: -1. Range mode (content param): compress contiguous ranges with summaries you write. -2. Prune mode (toolType param): remove all old messages of a specific tool type, keeping only the latest few. No summary needed — the system auto-generates one. Use for disposable tool outputs (old todowrite states, edit echoes). - COMPRESSED BLOCK PLACEHOLDERS The system auto-detects any previously compressed blocks whose anchor messages fall inside your selected range. You do NOT need to manually list \`(bN)\` placeholders in your summary — every consumed block is tracked automatically. diff --git a/lib/prompts/system.ts b/lib/prompts/system.ts index 04ef38e..41f3c29 100644 --- a/lib/prompts/system.ts +++ b/lib/prompts/system.ts @@ -21,9 +21,10 @@ TOOLS You have four context-management tools: -- \`compress\` — Two modes. Range mode: replace a contiguous range with a detailed summary. Example: \`compress({ topic: "API exploration", content: [{ startId: "m00150", endId: "m00220", summary: "..." }] })\`. Prune mode: remove all old messages of a tool type, keeping only recent ones. For disposable outputs like old todowrite states. Example: \`compress({ topic: "old todowrite", toolType: "todowrite", keepLatest: 3 })\`. +- \`compress\` — Replace a contiguous range of older conversation with a single detailed summary you write. Use when content is genuinely consumed (no longer needed for the current task step). Example: \`compress({ topic: "API exploration", content: [{ startId: "m00150", endId: "m00220", summary: "..." }] })\`. - \`decompress\` — Restore a previously compressed block's full original content, optionally to a file for large blocks. Use when a summary lacks the exact detail you need. Example: \`decompress({ blockId: "b5" })\` or \`decompress({ blockId: "b5", toFile: "path" })\`. - \`search_context\` — Search compressed block summaries (and optionally visible messages) by keyword. Use BEFORE decompressing to find the right block. Example: \`search_context({ query: "auth token refresh" })\`. +- \`prune\` — Remove old tool outputs by tool type, keeping only recent calls. Unlike compress (which creates summaries), prune directly strips outputs. Use for disposable outputs like old todowrite states or edit echoes. Example: \`prune({ toolType: "todowrite", keepLatest: 3 })\`. - \`acp_status\` — Context status with drilldown. No args = overview. \`scope:"uncompressed"\` lists all visible messages; add \`tool:"bash"\` to filter by tool type. \`scope:"compressed"\` shows block details. Example: \`acp_status({scope:"uncompressed", tool:"todowrite"})\`. COMPRESSION PHILOSOPHY