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/status.ts b/lib/compress/status.ts index 4a02bf7..1ad418c 100644 --- a/lib/compress/status.ts +++ b/lib/compress/status.ts @@ -1,28 +1,32 @@ 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 } 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 context status — overview or drill down into compressed/uncompressed sections. -Use this tool when: -- 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) +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). -Args: -- mode: "summary" (default) — one line per block with size/range/topic. "detailed" — adds age, generation, effective message count, consumed block lineage. -- sort: "recent" (default) | "size" (largest compressed first) | "age" (oldest surviving first, nearing GC). -- limit: max blocks to show (default 30).` +Sort options: "size" (default, largest first), "time" (chronological), "tool" (group by tool type). + +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 { @@ -33,44 +37,288 @@ function formatIdRange(block: CompressionBlock): string { return `${start}–${end}` } -function sortBlocks( +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.state?.input + if (input && typeof input === "object") { + 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, 60).replace(/\n/g, " ") + } + return "?" +} + +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 + } + + 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 === "tool") { + const raw = JSON.stringify(part) + tokens += Math.round(raw.length / 4) + if (!toolName) { + toolName = (part as any)?.tool || "unknown" + } + } + } + + if (tokens > 0) { + result.push({ ref, tokens, tool: toolName || "text", index: idx }) + } + }) + + return { messages: result, summaryTokens } +} + +function renderOverview( + visibleMessages: VisibleMessageInfo[], + summaryTokens: number, 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)) + fetchFailed: boolean, +): 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)") + } 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 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 { - copy.sort((a, b) => b.createdAt - a.createdAt) + 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}"`) + } } - return copy + + lines.push("") + + const hintTool = topToolName || "bash" + lines.push(`Tip: acp_status({scope:"uncompressed", tool:"${hintTool}", sort:"size"}) — mix any params freely`) + + return lines } -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 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 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 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 buildVisibleWithSummaries( + rawMessages: WithParts[], + ctx: ToolContext, +): WithParts[] { + const pruneMap = ctx.state.prune.messages.byMessageId + 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 { @@ -79,71 +327,62 @@ export function createAcpStatusTool(ctx: ToolContext): ReturnType { return tool({ description: ACP_STATUS_TOOL_DESCRIPTION, args: { - mode: tool.schema + scope: tool.schema .string() .optional() - .describe('Output detail level: "summary" (default) or "detailed"'), + .describe('Drill down: "compressed" or "uncompressed". No arg = overview of both.'), + tool: tool.schema + .string() + .optional() + .describe('Filter by tool type (only with scope:"uncompressed"). e.g., "bash", "todowrite", "write"'), sort: tool.schema .string() .optional() - .describe('Sort order: "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) { - const mode = args.mode === "detailed" ? "detailed" : "summary" - const sort: "recent" | "size" | "age" = - args.sort === "size" || args.sort === "age" ? args.sort : "recent" + async execute(args, toolCtx) { + 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 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." - } - + 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) { - return "No compressed blocks. Context is fully visible." - } - - 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 + const lines: string[] = [] - const idWidth = Math.max(...shown.map((b) => String(b.blockId).length)) + if (scope === "compressed") { + lines.push(...renderCompressedDrilldown(allBlocks, sort, limit)) + return lines.join("\n") + } - const lines: string[] = [ - `ACP Status — ${allBlocks.length} active compressed block${allBlocks.length === 1 ? "" : "s"} (${formatTokens(totalSummary)} summary, ${formatTokens(totalCompressed)} original compressed)`, - "", - ] + let visibleMsgs: VisibleMessageInfo[] = [] + let summaryTokens = 0 + let fetchFailed = false - 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" - ? 'sorted by recent. Use acp_status({sort:"size"}) for largest, {sort:"age"} for near-GC.' - : `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/messages/inject/inject.ts b/lib/messages/inject/inject.ts index 982dcd2..a73e126 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)` : "" @@ -241,15 +241,13 @@ 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 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(", ")}` + const topToolTypes = composition.toolTypeBreakdown.slice(0, 3) + if (topToolTypes.length > 0) { + breakdown += `\nTop tools: ${topToolTypes.map((t) => `${t.tool} (${pct(t.tokens)}%)`).join(", ")}` } + 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(", ")}` @@ -293,22 +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) => Math.round((n / composition.total) * 100) - 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)}%)` - 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) } diff --git a/lib/messages/inject/utils.ts b/lib/messages/inject/utils.ts index d28b124..2a58982 100644 --- a/lib/messages/inject/utils.ts +++ b/lib/messages/inject/utils.ts @@ -558,9 +558,10 @@ 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 }[] } function estimateCodeTokens(text: string): number { @@ -586,9 +587,10 @@ 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() for (const msg of messages) { const text = (msg.parts || []) @@ -602,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") { @@ -619,19 +622,22 @@ 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 || "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 }) } @@ -642,6 +648,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 +663,6 @@ export function estimateContextComposition( largestToolRanges: perTool.slice(0, 15), largestCodeRanges: perCode.slice(0, 5), largestMessageRanges: perText.slice(0, 5), + toolTypeBreakdown, } } 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/system.ts b/lib/prompts/system.ts index 089a644..41f3c29 100644 --- a/lib/prompts/system.ts +++ b/lib/prompts/system.ts @@ -24,7 +24,8 @@ 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" })\`. +- \`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 @@ -63,7 +64,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..eb830e1 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,23 +112,24 @@ 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 () => { 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/) 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 compressed blocks/) + 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, /Tip:/) + 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, /Tip:/) }) -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, /sorted by recent/) + assert.match(result, /Sorted by size/) })