// Heuristic JSON → human summary for tool results. Default view; technical // mode still gets the raw JSON section. import { capitalize, normalize } from '@/lib/text' const WRAPPER_KEYS = ['data', 'result', 'output', 'response', 'payload'] as const const PRIORITY_KEYS = [ 'title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link', 'status', 'id', 'message', 'summary', 'description' ] as const const ERROR_KEYS = ['error', 'errors', 'failure', 'exception'] as const // 'stderr' deliberately excluded: many CLIs emit informational lines on // stderr (npm progress, git's hint:, gcc's `In file included from`) that // aren't errors. Treating those as error signal flipped tool cards into // destructive styling for healthy commands. const ERROR_MSG_KEYS = ['message', 'reason', 'detail'] as const const NON_ERROR_TEXT = new Set(['', '0', 'false', 'none', 'null', 'nil', 'ok', 'success', 'n/a', 'na']) type Json = Record const isRecord = (v: unknown): v is Json => Boolean(v && typeof v === 'object' && !Array.isArray(v)) function tryJson(value: string): unknown { const t = value.trim() if (!t) { return '' } if (!/^[{[]|^"/.test(t)) { return value } try { return JSON.parse(t) } catch { return value } } const norm = (v: unknown): unknown => (typeof v === 'string' ? tryJson(v) : v) const titleCase = (k: string) => k .split(/[_\-.]+/) .filter(Boolean) .map(capitalize) .join(' ') const pluralize = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}` function clipInline(value: string, max = 180): string { const c = value.replace(/\s+/g, ' ').trim() return c.length > max ? `${c.slice(0, max - 1)}…` : c } function clipBlock(value: string, maxChars = 1800, maxLines = 18): string { const t = value.trim() if (!t) { return '' } const lines = t.split('\n') let text = lines.slice(0, maxLines).join('\n') const clipped = lines.length > maxLines || text.length > maxChars if (text.length > maxChars) { text = text.slice(0, maxChars - 1).trimEnd() } return clipped && !text.endsWith('…') ? `${text}…` : text } function firstString(record: Json, keys: readonly string[]): string { for (const k of keys) { const v = record[k] if (typeof v === 'string' && v.trim()) { return v.trim() } } return '' } function orderedKeys(keys: string[]): string[] { const priority = PRIORITY_KEYS.filter(k => keys.includes(k)) const rest = keys.filter(k => !priority.includes(k as never)) return [...priority, ...rest] } const isWrapperKey = (k: string) => (WRAPPER_KEYS as readonly string[]).includes(k) const skipField = (k: string, v: unknown) => isWrapperKey(k) || ((k === 'success' || k === 'ok') && v === true) function summarizeScalar(v: unknown): string { if (typeof v === 'string') { return clipInline(v) } if (typeof v === 'number' || typeof v === 'boolean') { return String(v) } return '' } function summarizeRecordInline(record: Json, depth: number): string { if (depth > 3) { return pluralize(Object.keys(record).length, 'field') } const title = firstString(record, ['title', 'name', 'path', 'file', 'filepath', 'url', 'href', 'link', 'id']) const status = firstString(record, ['status', 'category', 'type']) const message = firstString(record, ['snippet', 'summary', 'description', 'message']) if (title && status) { return `${clipInline(title, 110)} (${clipInline(status, 54)})` } if (title && message && title !== message) { return `${clipInline(title, 90)} - ${clipInline(message, 84)}` } if (title) { return clipInline(title, 150) } const pairs = orderedKeys(Object.keys(record)) .filter(k => !skipField(k, record[k])) .map(k => { const s = summarizeScalar(record[k]) return s ? `${titleCase(k)}: ${s}` : '' }) .filter(Boolean) .slice(0, 2) return pairs.length ? pairs.join(' · ') : pluralize(Object.keys(record).length, 'field') } function summarizeListItem(item: unknown, depth: number): string { const v = norm(item) if (typeof v === 'string') { return clipInline(v) } if (typeof v === 'number' || typeof v === 'boolean') { return String(v) } if (v == null) { return '' } if (Array.isArray(v)) { return pluralize(v.length, 'item') } if (isRecord(v)) { return summarizeRecordInline(v, depth + 1) } return clipInline(String(v)) } function formatFieldValue(value: unknown, depth: number): string { const v = norm(value) const scalar = summarizeScalar(v) if (scalar) { return scalar } if (v == null) { return '' } if (Array.isArray(v)) { if (!v.length) { return '' } const scalars = v.map(summarizeScalar).filter(Boolean) if (scalars.length === v.length && v.length <= 4) { return clipInline(scalars.join(', ')) } const first = summarizeListItem(v[0], depth + 1) return first ? `${pluralize(v.length, 'item')} (${first})` : pluralize(v.length, 'item') } if (isRecord(v)) { return summarizeRecordInline(v, depth + 1) } return clipInline(String(v)) } // "Returned N items" / "0 items" / "Returned an empty object" are all // noise — better to render nothing and let the title carry the signal. function formatArraySummary(value: unknown[], depth: number): string { if (!value.length) { return '' } const max = 6 const lines = value .slice(0, max) .map(item => summarizeListItem(item, depth + 1)) .filter(Boolean) .map(l => `- ${l}`) if (!lines.length) { return '' } if (value.length > max) { const remaining = value.length - max lines.push(`- … ${remaining} more ${remaining === 1 ? 'item' : 'items'}`) } return lines.join('\n') } function formatRecordSummary(record: Json, depth: number): string { const keys = Object.keys(record) if (!keys.length) { return '' } if (depth <= 2) { const direct = firstString(record, ['message', 'summary', 'description', 'preview', 'text', 'content']) const meaningful = keys.filter(k => !skipField(k, record[k]) && !isWrapperKey(k)) if (direct && meaningful.length <= 1) { return clipBlock(direct) } } const candidates = orderedKeys(keys).filter(k => !skipField(k, record[k])) const max = 8 const lines: string[] = [] for (const k of candidates) { const v = formatFieldValue(record[k], depth + 1) if (!v) { continue } lines.push(`- ${titleCase(k)}: ${v}`) if (lines.length >= max) { break } } if (!lines.length) { return '' } if (candidates.length > lines.length) { const remaining = candidates.length - lines.length lines.push(`- … ${remaining} more ${remaining === 1 ? 'field' : 'fields'}`) } return lines.join('\n') } function formatSummaryValue(value: unknown, depth: number): string { if (depth > 4) { return '' } const v = norm(value) if (typeof v === 'string') { return clipBlock(v) } if (typeof v === 'number' || typeof v === 'boolean') { return String(v) } if (v == null) { return '' } if (Array.isArray(v)) { return formatArraySummary(v, depth + 1) } if (isRecord(v)) { return formatRecordSummary(v, depth + 1) } return clipInline(String(v)) } function unwrapPayload(value: unknown): unknown { let cur: unknown = norm(value) for (let i = 0; i < 4; i += 1) { if (!isRecord(cur)) { return cur } const record = cur const key = WRAPPER_KEYS.find(k => record[k] != null) if (!key) { return record } cur = norm(record[key]) } return cur } function hasMeaningfulErrorValue(value: unknown): boolean { const v = norm(value) if (v == null) { return false } if (typeof v === 'string') { return !NON_ERROR_TEXT.has(normalize(v)) } if (typeof v === 'boolean') { return v } if (typeof v === 'number') { return v !== 0 } if (Array.isArray(v)) { return v.some(hasMeaningfulErrorValue) } if (isRecord(v)) { return Object.keys(v).length > 0 } return true } function hasErrorSignal(record: Json): boolean { const status = typeof record.status === 'string' ? record.status : '' return ( record.success === false || record.ok === false || /\b(error|failed|failure|fatal|exception)\b/i.test(status) || ERROR_KEYS.some(k => hasMeaningfulErrorValue(record[k])) ) } function valueErrorText(value: unknown): string { const v = norm(value) if (typeof v === 'string') { return hasMeaningfulErrorValue(v) ? clipBlock(v, 700, 12) : '' } if (Array.isArray(v)) { return clipBlock(v.map(valueErrorText).filter(Boolean).slice(0, 3).join('; '), 700, 12) } if (isRecord(v)) { const direct = firstString(v, ERROR_MSG_KEYS) if (direct) { return clipBlock(direct, 700, 12) } } return '' } function findNestedError(value: unknown, depth: number, seen: Set): string { if (depth > 5) { return '' } const v = norm(value) if (!v || typeof v !== 'object' || seen.has(v)) { return '' } seen.add(v) if (Array.isArray(v)) { for (const item of v) { const nested = findNestedError(item, depth + 1, seen) if (nested) { return nested } } return '' } const record = v as Json for (const k of ERROR_KEYS) { if (!hasMeaningfulErrorValue(record[k])) { continue } const text = valueErrorText(record[k]) if (text) { return text } } if (hasErrorSignal(record)) { const direct = firstString(record, ERROR_MSG_KEYS) if (direct) { return clipBlock(direct, 700, 12) } } for (const k of [...ERROR_KEYS, ...WRAPPER_KEYS, 'details', 'meta']) { const nested = findNestedError(record[k], depth + 1, seen) if (nested) { return nested } } return '' } export function formatToolResultSummary(value: unknown): string { return formatSummaryValue(unwrapPayload(value), 0) || formatSummaryValue(value, 0) } export function extractToolErrorMessage(value: unknown): string { return findNestedError(value, 0, new Set()) }