|
| 1 | +import { Anthropic } from "@anthropic-ai/sdk" |
| 2 | + |
| 3 | +/** |
| 4 | + * UI-safe formatter for ContentBlockParam that avoids including large or sensitive payloads |
| 5 | + * such as full file contents, diffs, or oversized text in UI messages (ui_messages.json). |
| 6 | + * |
| 7 | + * IMPORTANT: |
| 8 | + * - This is ONLY for rendering content into UI messages (e.g., api_req_started.request). |
| 9 | + * - Do NOT use this for API conversation history or export; those need full fidelity. |
| 10 | + */ |
| 11 | +export function formatContentBlockForUi(block: Anthropic.Messages.ContentBlockParam): string { |
| 12 | + switch (block.type) { |
| 13 | + case "text": |
| 14 | + return sanitizeText(block.text ?? "") |
| 15 | + case "image": |
| 16 | + return "[Image]" |
| 17 | + case "tool_use": |
| 18 | + return summarizeToolUse(block) |
| 19 | + case "tool_result": |
| 20 | + if (typeof block.content === "string") { |
| 21 | + return sanitizeText(block.content) |
| 22 | + } else if (Array.isArray(block.content)) { |
| 23 | + // Recursively sanitize nested blocks |
| 24 | + return block.content.map(formatContentBlockForUi).join("\n") |
| 25 | + } else { |
| 26 | + return "[Tool Result]" |
| 27 | + } |
| 28 | + default: |
| 29 | + return `[${block.type}]` |
| 30 | + } |
| 31 | +} |
| 32 | + |
| 33 | +/** |
| 34 | + * Summarize tool_use without dumping large params (like diff/content). |
| 35 | + */ |
| 36 | +function summarizeToolUse(block: Anthropic.Messages.ToolUseBlockParam): string { |
| 37 | + const name = block.name |
| 38 | + // Try to extract relevant lightweight params for display |
| 39 | + try { |
| 40 | + const params = (block as any)?.input ?? (block as any)?.params ?? {} |
| 41 | + // Prefer path if present |
| 42 | + const directPath = params?.path as string | undefined |
| 43 | + |
| 44 | + // For XML args (e.g., read_file, apply_diff multi-file), collect a small summary of paths |
| 45 | + const xmlArgs = typeof params?.args === "string" ? params.args : undefined |
| 46 | + const pathsFromXml = xmlArgs ? extractPathsFromXml(xmlArgs) : [] |
| 47 | + |
| 48 | + if (name === "read_file") { |
| 49 | + const paths = directPath ? [directPath] : pathsFromXml |
| 50 | + if (paths.length === 0) return `[Tool Use: ${name}]` |
| 51 | + if (paths.length === 1) return `[Tool Use: ${name}] ${paths[0]}` |
| 52 | + return `[Tool Use: ${name}] ${paths[0]} (+${paths.length - 1} more)` |
| 53 | + } |
| 54 | + |
| 55 | + if ( |
| 56 | + name === "apply_diff" || |
| 57 | + name === "insert_content" || |
| 58 | + name === "search_and_replace" || |
| 59 | + name === "write_to_file" |
| 60 | + ) { |
| 61 | + const paths = directPath ? [directPath] : pathsFromXml |
| 62 | + if (paths.length === 0) return `[Tool Use: ${name}]` |
| 63 | + if (paths.length === 1) return `[Tool Use: ${name}] ${paths[0]}` |
| 64 | + return `[Tool Use: ${name}] ${paths[0]} (+${paths.length - 1} more)` |
| 65 | + } |
| 66 | + |
| 67 | + if (name === "search_files") { |
| 68 | + const regex = params?.regex ? ` regex="${String(params.regex)}"` : "" |
| 69 | + const fp = params?.file_pattern ? ` file_pattern="${String(params.file_pattern)}"` : "" |
| 70 | + const p = params?.path ? ` ${String(params.path)}` : "" |
| 71 | + return `[Tool Use: ${name}]${p}${regex}${fp}` |
| 72 | + } |
| 73 | + |
| 74 | + // Default: show name only |
| 75 | + return `[Tool Use: ${name}]` |
| 76 | + } catch { |
| 77 | + return `[Tool Use: ${block.name}]` |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | +/** |
| 82 | + * Sanitize a text chunk for UI: |
| 83 | + * - Collapse <files> XML to a per-file summary (hide <content> bodies) |
| 84 | + * - Truncate very long text |
| 85 | + * - Redact obvious diff blobs |
| 86 | + */ |
| 87 | +function sanitizeText(text: string): string { |
| 88 | + if (!text) return "" |
| 89 | + |
| 90 | + // If this looks like a files XML, summarize paths/errors/notices and drop content bodies |
| 91 | + if (text.includes("<files") || text.includes("<files")) { |
| 92 | + return summarizeFilesXml(text) |
| 93 | + } |
| 94 | + |
| 95 | + // If this contains diff markers, replace with a short placeholder |
| 96 | + if (looksLikeDiff(text)) { |
| 97 | + return "[diff content omitted]" |
| 98 | + } |
| 99 | + |
| 100 | + // Generic truncation to keep UI light-weight |
| 101 | + const MAX = 2000 |
| 102 | + if (text.length > MAX) { |
| 103 | + const omitted = text.length - MAX |
| 104 | + return `${text.slice(0, MAX)}\n[omitted ${omitted} chars]` |
| 105 | + } |
| 106 | + |
| 107 | + return text |
| 108 | +} |
| 109 | + |
| 110 | +function looksLikeDiff(s: string): boolean { |
| 111 | + return ( |
| 112 | + s.includes("<<<<<<< SEARCH") || |
| 113 | + s.includes(">>>>>>> REPLACE") || |
| 114 | + s.includes("<<<<<<< SEARCH") || |
| 115 | + s.includes(">>>>>>> REPLACE") || |
| 116 | + /^diff --git/m.test(s) |
| 117 | + ) |
| 118 | +} |
| 119 | + |
| 120 | +/** |
| 121 | + * Summarize a <files> XML payload by listing file paths and high-level status, |
| 122 | + * but never including <content> bodies. |
| 123 | + */ |
| 124 | +function summarizeFilesXml(xmlLike: string): string { |
| 125 | + // Support both escaped and unescaped tags |
| 126 | + const decode = (s: string) => s.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&") |
| 127 | + |
| 128 | + const raw = decode(xmlLike) |
| 129 | + const fileRegex = /<file>([\s\S]*?)<\/file>/g |
| 130 | + const items: string[] = [] |
| 131 | + let match: RegExpExecArray | null |
| 132 | + |
| 133 | + while ((match = fileRegex.exec(raw)) !== null) { |
| 134 | + const fileBlock = match[1] |
| 135 | + const path = matchOne(fileBlock, /<path>([\s\S]*?)<\/path>/) |
| 136 | + const error = matchOne(fileBlock, /<error>([\s\S]*?)<\/error>/) |
| 137 | + const notice = matchOne(fileBlock, /<notice>([\s\S]*?)<\/notice>/) |
| 138 | + const binary = matchOne(fileBlock, /<binary_file(?:[^>]*)>([\s\S]*?)<\/binary_file>/) |
| 139 | + |
| 140 | + let line = path ? `- ${path}` : "- [unknown path]" |
| 141 | + if (error) line += ` [error: ${singleLine(error)}]` |
| 142 | + if (!error && binary) line += " [binary file]" |
| 143 | + if (!error && !binary && notice) line += ` [${singleLine(notice)}]` |
| 144 | + |
| 145 | + items.push(line) |
| 146 | + } |
| 147 | + |
| 148 | + if (items.length === 0) { |
| 149 | + return "[files omitted]" |
| 150 | + } |
| 151 | + |
| 152 | + const MAX_ITEMS = 20 |
| 153 | + let output = items.slice(0, MAX_ITEMS).join("\n") |
| 154 | + if (items.length > MAX_ITEMS) { |
| 155 | + output += `\n[+${items.length - MAX_ITEMS} more files]` |
| 156 | + } |
| 157 | + return output |
| 158 | +} |
| 159 | + |
| 160 | +function extractPathsFromXml(xml: string): string[] { |
| 161 | + const decode = (s: string) => s.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&") |
| 162 | + const raw = decode(xml) |
| 163 | + const pathRegex = /<path>([\s\S]*?)<\/path>/g |
| 164 | + const paths: string[] = [] |
| 165 | + let m: RegExpExecArray | null |
| 166 | + while ((m = pathRegex.exec(raw)) !== null) { |
| 167 | + paths.push(m[1]) |
| 168 | + } |
| 169 | + return paths |
| 170 | +} |
| 171 | + |
| 172 | +function matchOne(source: string, re: RegExp): string | undefined { |
| 173 | + const m = re.exec(source) |
| 174 | + return m ? m[1] : undefined |
| 175 | +} |
| 176 | + |
| 177 | +function singleLine(s: string): string { |
| 178 | + return s.replace(/\s+/g, " ").trim() |
| 179 | +} |
0 commit comments