import { isRecord, ptree } from "@oh-my-pi/pi-utils"; export { isRecord }; import { ToolAbortError } from "../../tools/tool-errors"; import { convertBufferWithMarkit } from "../../utils/markit"; import { MAX_BYTES } from "./types"; export function asRecord(value: unknown): Record | null { return isRecord(value) ? value : null; } export function asString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } export function asNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } export interface BinaryFetchSuccess { ok: true; buffer: Uint8Array; contentDisposition?: string; } export type BinaryFetchResult = BinaryFetchSuccess | { ok: false; error?: string }; async function readResponseWithLimit(response: Response, maxBytes: number, signal?: AbortSignal): Promise { const reader = response.body?.getReader(); if (!reader) return new Uint8Array(0); const chunks: Buffer[] = []; let totalBytes = 0; try { while (true) { if (signal?.aborted) { await reader.cancel(); throw new ToolAbortError(); } const { done, value } = await reader.read(); if (done) break; if (!value || value.byteLength === 0) continue; totalBytes += value.byteLength; if (totalBytes > maxBytes) { await reader.cancel(); throw new Error(`response exceeds ${maxBytes} bytes`); } chunks.push(Buffer.from(value)); } } finally { reader.releaseLock(); } return new Uint8Array(Buffer.concat(chunks, totalBytes)); } /** * Fetch binary content from a URL */ export async function fetchBinary(url: string, timeout: number = 20, signal?: AbortSignal): Promise { const requestSignal = ptree.combineSignals(signal, timeout * 1000); try { const response = await fetch(url, { signal: requestSignal, headers: { "User-Agent": "Mozilla/5.0 (compatible; TextBot/1.0)", }, redirect: "follow", }); if (!response.ok) { return { ok: false, error: `HTTP ${response.status}` }; } const contentDisposition = response.headers.get("content-disposition") || undefined; const contentLength = response.headers.get("content-length"); if (contentLength) { const size = Number.parseInt(contentLength, 10); if (Number.isFinite(size) && size > MAX_BYTES) { return { ok: false, error: `content-length ${size} exceeds ${MAX_BYTES}` }; } } const buffer = await readResponseWithLimit(response, MAX_BYTES, requestSignal); return { ok: true, buffer, contentDisposition }; } catch (err) { if (signal?.aborted) throw new ToolAbortError(); if (requestSignal?.aborted) return { ok: false, error: "aborted" }; return { ok: false, error: err instanceof Error ? err.message : "Failed to fetch binary" }; } } /** * Convert binary content to markdown using markit. */ export async function convertWithMarkit( buffer: Uint8Array, extension: string, timeout: number = 20, signal?: AbortSignal, ): Promise<{ content: string; ok: boolean; error?: string }> { const conversionSignal = ptree.combineSignals(signal, timeout * 1000); return convertBufferWithMarkit(buffer, extension, conversionSignal); }