import type { AttachmentKind, AttachmentMimeType, MessageBlock } from "@rakazo/contracts"; import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_BYTES, attachmentKindOf, isAllowedAttachmentMimeType, isAttachmentImageMimeType, } from "@rakazo/contracts"; import { autopilotHistoryText } from "./autopilot.js"; import { cardHistoryText } from "./cards.js"; import { routineEventHistoryText } from "./routine-events.js"; export class AttachmentValidationError extends Error { constructor(message: string) { super(message); this.name = "AttachmentValidationError"; } } export function decodeAttachmentBase64(contentBase64: string): Uint8Array { const normalized = contentBase64.trim(); if (!normalized) throw new AttachmentValidationError("Attachment content is empty"); if (normalized.length > ATTACHMENT_MAX_BASE64_LENGTH) { throw new AttachmentValidationError("Attachment exceeds the 10 MiB limit"); } if ( normalized.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(normalized) ) { throw new AttachmentValidationError("Attachment content is not valid base64"); } let bytes: Buffer; try { bytes = Buffer.from(normalized, "base64"); } catch { throw new AttachmentValidationError("Attachment content is not valid base64"); } if (bytes.byteLength === 0) throw new AttachmentValidationError("Attachment content is empty"); if (bytes.byteLength > ATTACHMENT_MAX_BYTES) { throw new AttachmentValidationError("Attachment exceeds the 10 MiB limit"); } return new Uint8Array(bytes); } export function validateAttachmentMimeType(mimeType: string): void { if (!isAllowedAttachmentMimeType(mimeType)) { throw new AttachmentValidationError(`Unsupported attachment type: ${mimeType}`); } } export function attachmentKindForMimeType(mimeType: string): AttachmentKind { return attachmentKindOf(mimeType); } export function messageBlockForArtifact(artifact: { id: string; name: string; mimeType: string; size: number; width?: number | null; height?: number | null; durationMs?: number | null; }): Extract { const kind = attachmentKindForMimeType(artifact.mimeType); if (kind === "image") { return { kind: "image", artifactId: artifact.id, mimeType: artifact.mimeType, name: artifact.name, }; } if (kind === "video") { return { kind: "video", artifactId: artifact.id, mimeType: artifact.mimeType, name: artifact.name, size: artifact.size, ...(artifact.durationMs != null ? { durationMs: artifact.durationMs } : {}), ...(artifact.width != null ? { width: artifact.width } : {}), ...(artifact.height != null ? { height: artifact.height } : {}), }; } if (kind === "audio") { return { kind: "audio", artifactId: artifact.id, mimeType: artifact.mimeType, name: artifact.name, size: artifact.size, ...(artifact.durationMs != null ? { durationMs: artifact.durationMs } : {}), }; } return { kind: "file", artifactId: artifact.id, mimeType: artifact.mimeType, name: artifact.name, size: artifact.size, }; } export function promptTextForAttachments( text: string | undefined, artifacts: Array<{ name: string; mimeType: string; size: number }>, ): string { const caption = text?.trim() ?? ""; const notes = artifacts .filter((artifact) => !isAttachmentImageMimeType(artifact.mimeType)) .map( (artifact) => `User attached file ${JSON.stringify(artifact.name)} (${artifact.mimeType}, ${artifact.size} bytes).`, ); return [caption, ...notes].filter(Boolean).join("\n\n") || "See attached files."; } export function blocksToAgentHistoryText(blocks: MessageBlock[]): string { return blocks .map((block) => { if (block.kind === "text") return block.text; if (block.kind === "chart") return `[chart: ${block.name}]`; if (block.kind === "file") { return `[file: ${block.name} (${block.mimeType}, ${block.size} bytes)]`; } // Media keeps the artifact id so a later turn can edit or export what it refers to. if (block.kind === "image" || block.kind === "video" || block.kind === "audio") { return `[${block.kind}: ${block.name} (artifact ${block.artifactId})]`; } if (block.kind === "media") { const outputs = block.outputs .map((output) => `${output.kind} ${output.name} (artifact ${output.artifactId})`) .join(", "); return `[media ${block.operation} ${block.status}: ${block.title}${outputs ? ` -> ${outputs}` : ""}]`; } // Keep attribution on peer messages: without it a later turn cannot tell // which lines came from another bot rather than the user. if (block.kind === "bot_message_received") return `[from ${block.fromBotName}] ${block.text}`; if (block.kind === "bot_message_sent") return `[to ${block.toBotName}] ${block.text}`; if (block.kind === "task") { return `[task for ${block.assigneeBotName}: ${block.title} (${block.status})] ${block.text}`; } if (block.kind === "event") return routineEventHistoryText(block); if (block.kind === "autopilot") return autopilotHistoryText(block); if (block.kind === "card") return cardHistoryText(block); if (block.kind === "handoff") { return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`; } // A card's question and answer, so a later turn knows what the user already chose. if (block.kind === "choice") { const answer = block.answerText ?? block.options.find((option) => option.id === block.answerId)?.label; return answer ? `${block.question}\n[answered: ${answer}]` : block.question; } if ("text" in block && typeof block.text === "string") return block.text; return ""; }) .filter(Boolean) .join("\n"); } const EXTENSION_MIME_TYPES: Record = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif", ".pdf": "application/pdf", ".txt": "text/plain", ".md": "text/markdown", ".markdown": "text/markdown", ".csv": "text/csv", ".json": "application/json", ".mp4": "video/mp4", ".m4v": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime", ".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".wav": "audio/wav", ".ogg": "audio/ogg", ".oga": "audio/ogg", ".vtt": "text/vtt", ".srt": "application/x-subrip", }; const MIME_TYPE_EXTENSIONS: Record = { "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", "image/gif": ".gif", "application/pdf": ".pdf", "text/plain": ".txt", "text/markdown": ".md", "text/csv": ".csv", "application/json": ".json", "video/mp4": ".mp4", "video/webm": ".webm", "video/quicktime": ".mov", "audio/mpeg": ".mp3", "audio/mp4": ".m4a", "audio/wav": ".wav", "audio/webm": ".weba", "audio/ogg": ".ogg", "text/vtt": ".vtt", "application/x-subrip": ".srt", }; export function inferAttachmentMimeType( name: string, reportedType?: string, ): AttachmentMimeType | null { const dot = name.lastIndexOf("."); const extensionType = dot < 0 ? undefined : EXTENSION_MIME_TYPES[name.slice(dot).toLowerCase()]; // Some browsers and native document pickers report Markdown as text/plain. if (extensionType === "text/markdown" && (!reportedType || reportedType === "text/plain")) { return extensionType; } if (reportedType && isAllowedAttachmentMimeType(reportedType)) return reportedType; return extensionType ?? null; } export function attachmentExtensionForMimeType(mimeType: string): string { return isAllowedAttachmentMimeType(mimeType) ? MIME_TYPE_EXTENSIONS[mimeType] : ""; } export function attachmentsForThread( attachments: readonly T[], threadKey: string | undefined, ): T[] { if (!threadKey) return []; return attachments.filter((attachment) => attachment.threadKey === threadKey); } export function attachmentsForBot( attachments: readonly T[], botId: string | undefined, ): T[] { if (!botId) return []; return attachments.filter((attachment) => attachment.botId === botId); } export function userTurnBlocksForRun( trigger: string, runId: string, messages: Array<{ id?: string; role: string; runId?: string | null; blocks: MessageBlock[]; }>, sourceMessageId?: string | null, ): MessageBlock[] | undefined { if (trigger !== "user") return undefined; return messages.find( (message) => message.role === "user" && (sourceMessageId ? message.id === sourceMessageId : message.runId === runId), )?.blocks; }