import type { AssistantMessage } from "@mariozechner/pi-ai"; import { formatToolDetail, resolveToolDisplay } from "./tool-display.js"; export function extractAssistantText(msg: AssistantMessage): string { const isTextBlock = ( block: unknown, ): block is { type: "text"; text: string } => { if (!block || typeof block !== "object") return false; const rec = block as Record; return rec.type === "text" && typeof rec.text === "string"; }; const blocks = Array.isArray(msg.content) ? msg.content .filter(isTextBlock) .map((c) => c.text.trim()) .filter(Boolean) : []; return blocks.join("\n").trim(); } export function extractAssistantThinking(msg: AssistantMessage): string { if (!Array.isArray(msg.content)) return ""; const blocks = msg.content .map((block) => { if (!block || typeof block !== "object") return ""; const record = block as unknown as Record; if (record.type === "thinking" && typeof record.thinking === "string") { return record.thinking.trim(); } return ""; }) .filter(Boolean); return blocks.join("\n").trim(); } export function formatReasoningMessage(text: string): string { const trimmed = text.trim(); if (!trimmed) return ""; return `Reasoning:\n${trimmed}`; } type ThinkTaggedSplitBlock = | { type: "thinking"; thinking: string } | { type: "text"; text: string }; export function splitThinkingTaggedText( text: string, ): ThinkTaggedSplitBlock[] | null { const trimmedStart = text.trimStart(); // Avoid false positives: only treat it as structured thinking when it begins // with a think tag (common for local/OpenAI-compat providers that emulate // reasoning blocks via tags). if (!trimmedStart.startsWith("<")) return null; const openRe = /<\s*think(?:ing)?\s*>/i; const closeRe = /<\s*\/\s*think(?:ing)?\s*>/i; if (!openRe.test(trimmedStart)) return null; if (!closeRe.test(text)) return null; const scanRe = /<\s*(\/?)\s*think(?:ing)?\s*>/gi; let inThinking = false; let cursor = 0; let thinkingStart = 0; const blocks: ThinkTaggedSplitBlock[] = []; const pushText = (value: string) => { if (!value) return; blocks.push({ type: "text", text: value }); }; const pushThinking = (value: string) => { const cleaned = value.trim(); if (!cleaned) return; blocks.push({ type: "thinking", thinking: cleaned }); }; for (const match of text.matchAll(scanRe)) { const index = match.index ?? 0; const isClose = Boolean(match[1]?.includes("/")); if (!inThinking && !isClose) { pushText(text.slice(cursor, index)); thinkingStart = index + match[0].length; inThinking = true; continue; } if (inThinking && isClose) { pushThinking(text.slice(thinkingStart, index)); cursor = index + match[0].length; inThinking = false; } } if (inThinking) return null; pushText(text.slice(cursor)); const hasThinking = blocks.some((b) => b.type === "thinking"); if (!hasThinking) return null; return blocks; } export function promoteThinkingTagsToBlocks(message: AssistantMessage): void { if (!Array.isArray(message.content)) return; const hasThinkingBlock = message.content.some( (block) => block.type === "thinking", ); if (hasThinkingBlock) return; const next: AssistantMessage["content"] = []; let changed = false; for (const block of message.content) { if (block.type !== "text") { next.push(block); continue; } const split = splitThinkingTaggedText(block.text); if (!split) { next.push(block); continue; } changed = true; for (const part of split) { if (part.type === "thinking") { next.push({ type: "thinking", thinking: part.thinking }); } else if (part.type === "text") { const cleaned = part.text.trimStart(); if (cleaned) next.push({ type: "text", text: cleaned }); } } } if (!changed) return; message.content = next; } export function extractThinkingFromTaggedText(text: string): string { if (!text) return ""; const scanRe = /<\s*(\/?)\s*think(?:ing)?\s*>/gi; let result = ""; let lastIndex = 0; let inThinking = false; for (const match of text.matchAll(scanRe)) { const idx = match.index ?? 0; if (inThinking) { result += text.slice(lastIndex, idx); } const isClose = match[1] === "/"; inThinking = !isClose; lastIndex = idx + match[0].length; } return result.trim(); } export function extractThinkingFromTaggedStream(text: string): string { if (!text) return ""; const closed = extractThinkingFromTaggedText(text); if (closed) return closed; const openRe = /<\s*think(?:ing)?\s*>/gi; const closeRe = /<\s*\/\s*think(?:ing)?\s*>/gi; const openMatches = [...text.matchAll(openRe)]; if (openMatches.length === 0) return ""; const closeMatches = [...text.matchAll(closeRe)]; const lastOpen = openMatches[openMatches.length - 1]; const lastClose = closeMatches[closeMatches.length - 1]; if (lastClose && (lastClose.index ?? -1) > (lastOpen.index ?? -1)) { return closed; } const start = (lastOpen.index ?? 0) + lastOpen[0].length; return text.slice(start).trim(); } export function inferToolMetaFromArgs( toolName: string, args: unknown, ): string | undefined { const display = resolveToolDisplay({ name: toolName, args }); return formatToolDetail(display); }