import type { ToolGuardRule, TruncationInfo } from "./types"; const SEPARATOR_LINE = "\n\n"; function formatPercent(ratio: number): string { return `${(ratio * 100).toFixed(1)}%`; } function formatChars(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; return String(n); } function buildMarker(info: TruncationInfo): string { const lines = [ `⚠ output-guard: ${formatChars(info.originalChars)} → ${formatChars(info.keptChars)} chars (${formatPercent(info.removedPercent)} removed)`, ` tool: ${info.tool} | head/tail ratio: ${formatPercent(info.headRatio)}/${formatPercent(1 - info.headRatio)}`, ]; if (info.hint) { lines.push(` hint: ${info.hint}`); } if (info.savedPath) { lines.push(` full content saved to: ${info.savedPath}`); } return lines.join("\n"); } /** * Split text at line boundary closest to `charTarget`, never breaking mid-line. * Returns the portion up to the split point. */ function splitAtLineBoundary(text: string, charTarget: number): string { if (charTarget <= 0) return ""; if (charTarget >= text.length) return text; // Find the last newline before or at charTarget const lastNewline = text.lastIndexOf("\n", charTarget); if (lastNewline === -1) { // No newline found — take the whole text up to target return text.slice(0, charTarget); } return text.slice(0, lastNewline + 1); } /** * Extract tail portion: text from the last `charTarget` chars, * snapped forward to a line boundary. */ function tailAtLineBoundary(text: string, charTarget: number): string { if (charTarget <= 0) return ""; if (charTarget >= text.length) return text; const start = text.length - charTarget; const firstNewline = text.indexOf("\n", start); if (firstNewline === -1) { return text.slice(start); } return text.slice(firstNewline + 1); } /** * Measure total character count of text content blocks. */ export function measureContent(content: { type: string; text?: string }[]): number { let total = 0; for (const block of content) { if (block.type === "text" && typeof block.text === "string") { total += block.text.length; } } return total; } /** * Apply head/tail truncation to a single text string. */ export function truncateText( text: string, rule: ToolGuardRule, toolName: string, savedPath?: string, ): { text: string; info: TruncationInfo } | null { const originalChars = text.length; if (originalChars <= rule.maxChars) return null; const budget = rule.maxChars; const headBudget = Math.floor(budget * rule.headRatio); const tailBudget = budget - headBudget; const head = splitAtLineBoundary(text, headBudget); const tail = tailAtLineBoundary(text, tailBudget); const info: TruncationInfo = { tool: toolName, originalChars, keptChars: head.length + tail.length, removedPercent: (originalChars - head.length - tail.length) / originalChars, headRatio: rule.headRatio, hint: rule.hint ?? "", savedPath, }; const marker = buildMarker(info); const result = head + SEPARATOR_LINE + marker + SEPARATOR_LINE + tail; info.keptChars = result.length; return { text: result, info }; } /** * Truncate content blocks in place. Returns truncation info if any block was truncated. * Only processes text blocks; images are left untouched. */ export function truncateContentBlocks( content: { type: string; text?: string }[], rule: ToolGuardRule, toolName: string, savedPath?: string, ): TruncationInfo | null { // Fast path: measure total first const total = measureContent(content); if (total <= rule.maxChars) return null; // If single text block (most common case), truncate directly const textBlocks = content.filter( (b): b is { type: "text"; text: string } => b.type === "text" && typeof b.text === "string", ); if (textBlocks.length === 1) { const result = truncateText(textBlocks[0].text, rule, toolName, savedPath); if (result) { textBlocks[0].text = result.text; return result.info; } return null; } // Multiple text blocks: distribute budget proportionally, truncate largest first // Sort by size descending const sorted = textBlocks .map((block, index) => ({ block, index, chars: block.text.length })) .sort((a, b) => b.chars - a.chars); let remaining = total - rule.maxChars; let truncationInfo: TruncationInfo | null = null; for (const entry of sorted) { if (remaining <= 0) break; const blockBudget = Math.max(200, entry.chars - remaining); const blockRule: ToolGuardRule = { maxChars: blockBudget, headRatio: rule.headRatio, hint: rule.hint, }; const result = truncateText(entry.block.text, blockRule, toolName, savedPath); if (result) { entry.block.text = result.text; remaining -= (entry.chars - result.text.length); truncationInfo = { tool: toolName, originalChars: total, keptChars: total - (total - rule.maxChars) + remaining, removedPercent: (total - rule.maxChars) / total, headRatio: rule.headRatio, hint: rule.hint ?? "", savedPath, }; } } return truncationInfo; }