import type { ContentBlock } from "../providers/types.js";
import { escapeXmlAttr } from "../util/xml.js";
export function extractTextFromStoredMessageContent(
raw: string | ContentBlock[],
): string {
try {
const parsed = Array.isArray(raw) ? raw : (JSON.parse(raw) as unknown);
if (typeof parsed === "string") {
return parsed;
}
if (!Array.isArray(parsed)) {
return raw as string;
}
const blocks = parsed as ContentBlock[];
const lines: string[] = [];
for (const block of blocks) {
switch (block.type) {
case "text":
lines.push(block.text);
break;
case "tool_use":
lines.push(`Tool use (${block.name}): ${stableJson(block.input)}`);
break;
case "tool_result":
lines.push(
`Tool result${block.is_error ? " " : ""}: ${block.content}`,
);
break;
case "thinking":
lines.push(block.thinking);
break;
case "redacted_thinking":
lines.push("");
break;
case "image":
lines.push(
``,
);
break;
case "file": {
const filename = block.source.filename ?? "";
if (block.extracted_text) {
lines.push(`File (${filename}): ${block.extracted_text}`);
} else {
lines.push(
``,
);
}
break;
}
case "server_tool_use": {
const query =
typeof block.input?.query === "string"
? block.input.query
: block.name;
lines.push(`[web search: ${query}]`);
break;
}
case "web_search_tool_result":
lines.push("[web search results]");
break;
default:
lines.push("");
}
}
return lines.join("\n").trim();
} catch {
return Array.isArray(raw) ? "" : raw;
}
}
function stableJson(value: unknown): string {
try {
return JSON.stringify(value);
} catch {
return "";
}
}
/**
* Coerce stored message content into a single human-readable text string,
* dropping non-text blocks (images, tool calls, tool results, thinking,
* …). Used by call sites that want only the spoken text — sweep-model
* context, RAG backfill, bookmark previews. For richer renderings that
* include tool metadata, use {@link extractTextFromStoredMessageContent}
* instead.
*
* Handles the two on-disk shapes:
* - Modern rows: JSON-serialized `ContentBlock[]`
* - Legacy rows: plain string
*
* Parse failures fall back to returning the raw input trimmed (the
* legacy-string path).
*/
export function stringifyMessageContent(
stored: string | ContentBlock[],
): string {
let parsed: unknown;
if (Array.isArray(stored)) {
parsed = stored;
} else {
try {
parsed = JSON.parse(stored);
} catch {
return stored.trim();
}
}
if (typeof parsed === "string") {
return parsed.trim();
}
if (!Array.isArray(parsed)) {
return (stored as string).trim();
}
const parts: string[] = [];
for (const block of parsed) {
if (
block &&
typeof block === "object" &&
(block as { type?: string }).type === "text" &&
typeof (block as { text?: unknown }).text === "string"
) {
parts.push((block as { text: string }).text);
}
}
return parts.join("\n").trim();
}