/** * Custom Message Compaction Plugin Example * * Shows how a plugin can use registerMessageBuilder() to rewrite provider-bound * messages before the model call. This example mirrors the shape of core * compaction: it estimates context size, preserves the first user message and * recent working context, and replaces older middle history with one concise * continuation summary message. * * Core still runs its built-in API-safety message builder after plugin builders, * so provider-safe normalization and hard truncation remain the final pass. * * CLI usage: * cline plugin install https://github.com/cline/cline/blob/main/sdk/examples/plugins/custom-compaction.ts --cwd . * cline -i "Search the codebase for dispatcher usage, then summarize it" */ import type { AgentPlugin, Message, ToolResultContent } from "@cline/core"; import { estimateTokens as estimateTokensFromChars } from "@cline/shared"; const MAX_INPUT_TOKENS = 120_000; const COMPACT_AT_RATIO = 0.75; const PRESERVE_RECENT_TOKENS = 24_000; const SUMMARY_PREVIEW_CHARS = 800; function estimateTokens(text: string): number { return estimateTokensFromChars(text.length); } function preview(text: string, limit = SUMMARY_PREVIEW_CHARS): string { if (text.length <= limit) { return text.trim(); } return `${text.slice(0, limit).trim()}\n...[${text.length - limit} more chars summarized]`; } function stringifyContent(content: ToolResultContent["content"]): string { return typeof content === "string" ? content : JSON.stringify(content); } function serializeMessage(message: Message): string { if (typeof message.content === "string") { return `[${message.role}]: ${message.content}`; } const lines: string[] = []; for (const block of message.content) { switch (block.type) { case "text": lines.push(`[${message.role}]: ${block.text ?? ""}`); break; case "thinking": lines.push( `[assistant thinking]: ${preview(block.thinking ?? "", 300)}`, ); break; case "tool_use": lines.push( `[assistant tool call]: ${block.name ?? "tool"}(${JSON.stringify(block.input ?? {})})`, ); break; case "tool_result": lines.push( `[tool result ${block.tool_use_id ?? "unknown"}]: ${preview(stringifyContent(block.content), 500)}`, ); break; case "file": lines.push( `[file ${block.path ?? "unknown"}]: ${preview(String(block.content ?? ""), 500)}`, ); break; default: lines.push(`[${message.role} ${block.type} block]`); } } return lines.join("\n"); } function estimateMessageTokens(message: Message): number { return estimateTokens(serializeMessage(message)); } function findFirstUserIndex(messages: Message[]): number { return messages.findIndex((message) => message.role === "user"); } function findRecentStartIndex(messages: Message[]): number { let tokens = 0; for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; if (!message) { continue; } tokens += estimateMessageTokens(message); if (tokens >= PRESERVE_RECENT_TOKENS) { return index; } } return 0; } function collectToolNames(messages: Message[]): string[] { const names = new Set(); for (const message of messages) { if (!Array.isArray(message.content)) { continue; } for (const block of message.content) { if (block.type === "tool_use" && block.name) { names.add(block.name); } } } return [...names].sort((left, right) => left.localeCompare(right)); } function collectTouchedFiles(messages: Message[]): string[] { const paths = new Set(); for (const message of messages) { if (!Array.isArray(message.content)) { continue; } for (const block of message.content) { if (block.type === "file" && block.path) { paths.add(block.path); } if (block.type === "tool_use") { for (const value of Object.values(block.input ?? {})) { if (typeof value === "string" && value.includes("/")) { paths.add(value); } } } } } return [...paths].sort((left, right) => left.localeCompare(right)); } function buildCompactionSummary( compacted: Message[], tokensBefore: number, ): Message { const roleCounts = compacted.reduce>( (counts, message) => { counts[message.role] = (counts[message.role] ?? 0) + 1; return counts; }, {}, ); const tools = collectToolNames(compacted); const files = collectTouchedFiles(compacted); const highlights = compacted .map(serializeMessage) .map((line) => preview(line, 500)) .slice(-6); return { role: "user", content: `Context summary: ## Compacted Range - Messages compacted: ${compacted.length} - Estimated tokens before compaction: ${tokensBefore} - Roles: ${Object.entries(roleCounts) .map(([role, count]) => `${role}=${count}`) .join(", ")} ## Tool Activity ${tools.length > 0 ? tools.map((tool) => `- ${tool}`).join("\n") : "- none"} ## Files Mentioned ${files.length > 0 ? files.map((path) => `- ${path}`).join("\n") : "- none"} ## Recent Highlights From Compacted History ${highlights.length > 0 ? highlights.map((item) => `- ${item}`).join("\n") : "- none"} Continue from this summary plus the preserved recent messages below.`, }; } export const plugin: AgentPlugin = { name: "custom-compaction", manifest: { capabilities: ["messageBuilders"], }, setup(api) { api.registerMessageBuilder({ name: "summarize-middle-history", build(messages) { const totalTokens = messages.reduce( (total, message) => total + estimateMessageTokens(message), 0, ); if (totalTokens < MAX_INPUT_TOKENS * COMPACT_AT_RATIO) { return messages; } const firstUserIndex = findFirstUserIndex(messages); const recentStartIndex = Math.max( firstUserIndex + 1, findRecentStartIndex(messages), ); if (firstUserIndex < 0 || recentStartIndex <= firstUserIndex + 1) { return messages; } const prefix = messages.slice(0, firstUserIndex + 1); const compacted = messages.slice(firstUserIndex + 1, recentStartIndex); const recent = messages.slice(recentStartIndex); if (compacted.length === 0) { return messages; } return [ ...prefix, buildCompactionSummary(compacted, totalTokens), ...recent, ]; }, }); }, }; export default plugin;