UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

177 lines (176 loc) • 8.57 kB
export const PDF_MAX_PAGES_PER_READ = 20; export const PDF_INLINE_PAGE_THRESHOLD = 10; export const PDF_TARGET_RAW_SIZE = 20 * 1024 * 1024; export const PDF_MAX_EXTRACT_SIZE = 100 * 1024 * 1024; export function pdfMagicMatches(bytes) { return bytes.length >= 5 && bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46 && bytes[4] === 0x2d; } export function parsePdfPageRange(pages) { const s = pages.trim(); const single = /^(\d+)$/.exec(s); if (single) { const n = Number(single[1]); return n >= 1 ? { first: n, last: n } : undefined; } const range = /^(\d+)-(\d*)$/.exec(s); if (!range) return undefined; const first = Number(range[1]); const last = range[2] === "" ? Infinity : Number(range[2]); if (first < 1 || last < first) return undefined; return { first, last }; } export function countPdfPagesFromBytes(bytes) { const s = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("latin1"); const m = s.match(/\/Type\s*\/Page(?![a-zA-Z])/g); return m && m.length > 0 ? m.length : undefined; } function shellQuote(arg) { return `'${arg.replaceAll("'", `'\\''`)}'`; } export const PDF_EXEC_ERROR_PREVIEW_CHARS = 400; export function previewExecStream(s) { return s.length > PDF_EXEC_ERROR_PREVIEW_CHARS ? `${s.slice(0, PDF_EXEC_ERROR_PREVIEW_CHARS)} …[${s.length - PDF_EXEC_ERROR_PREVIEW_CHARS} chars truncated]` : s; } export async function getPdfPageCount(env, absPath, bytes, signal) { try { const r = await env.exec(`pdfinfo ${shellQuote(absPath)}`, { timeout: 10, abortSignal: signal }); if (r.ok && r.value.exitCode === 0) { const m = /^Pages:\s+(\d+)/m.exec(r.value.stdout); if (m) { const n = Number.parseInt(m[1], 10); if (Number.isFinite(n) && n > 0) return n; } } } catch { } return bytes ? countPdfPagesFromBytes(bytes) : undefined; } export const PDF_FALLBACK_RENDER_PAGES = 5; export function pdfModelCapabilitiesOf(model) { if (!model || (model.api === undefined && model.input === undefined)) return { document: true, vision: true }; const vision = model.input === undefined ? true : model.input.includes("image"); const document = model.input?.includes("document") === true || (model.api === "anthropic-messages" && vision); return { document, vision }; } export const PDF_EXEC_TIMEOUT_SEC = 120; export async function extractPdfTextLayer(env, absPath, range, signal) { const tmp = await env.createTempDir("sema-pdf-text-", signal); if (!tmp.ok) return { ok: false, toolMissing: false, error: `cannot create a temp dir for text extraction: ${tmp.error.message}` }; const dir = tmp.value; try { const outR = await env.joinPath([dir, "out.txt"], signal); if (!outR.ok) return { ok: false, toolMissing: false, error: outR.error.message }; const rangeArgs = range ? `-f ${range.first} -l ${range.last} ` : ""; const cmd = `pdftotext -layout ${rangeArgs}${shellQuote(absPath)} ${shellQuote(outR.value)}`; const r = await env.exec(cmd, { timeout: PDF_EXEC_TIMEOUT_SEC, abortSignal: signal }); if (!r.ok) { if (r.error.code === "timeout") { const partial = await env.readBinaryFile(outR.value, signal); const text = partial.ok ? Buffer.from(partial.value).toString("utf8") : ""; if (text.trim()) return { ok: true, text, timedOutAfterSec: PDF_EXEC_TIMEOUT_SEC }; return { ok: false, toolMissing: false, timedOut: true, error: `pdftotext timed out after ${PDF_EXEC_TIMEOUT_SEC}s before writing any text. Use the pages parameter to extract a narrower range (e.g. pages: "1-5").`, }; } return { ok: false, toolMissing: false, error: r.error.message }; } if (r.value.exitCode !== 0) { const stderr = previewExecStream((r.value.stderr || r.value.stdout || "").trim()); const missing = r.value.exitCode === 127 || /not found|command not found/i.test(stderr); return { ok: false, toolMissing: missing, error: stderr || `pdftotext exited with code ${r.value.exitCode}` }; } const bin = await env.readBinaryFile(outR.value, signal); if (!bin.ok) return { ok: false, toolMissing: false, error: `cannot read the extracted text: ${bin.error.message}` }; return { ok: true, text: Buffer.from(bin.value).toString("utf8") }; } finally { void env.remove(dir, { recursive: true, force: true }).catch(() => undefined); } } function jpegLooksComplete(bytes) { const n = bytes.length; return n >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[n - 2] === 0xff && bytes[n - 1] === 0xd9; } async function listRenderedJpegs(env, dir, signal) { const listing = await env.listDir(dir, signal); if (!listing.ok) return { ok: false, error: listing.error.message }; return { ok: true, jpgs: listing.value .map((f) => f.path) .filter((p) => p.toLowerCase().endsWith(".jpg") || p.toLowerCase().endsWith(".jpeg")) .sort(), }; } export async function extractPdfPagesAsImages(env, absPath, first, last, signal) { const tmp = await env.createTempDir("sema-pdf-pages-", signal); if (!tmp.ok) return { ok: false, toolMissing: false, error: `cannot create a temp dir for page extraction: ${tmp.error.message}` }; const dir = tmp.value; try { const prefixR = await env.joinPath([dir, "page"], signal); if (!prefixR.ok) return { ok: false, toolMissing: false, error: prefixR.error.message }; const cmd = `pdftoppm -jpeg -r 100 -f ${first} -l ${last} ${shellQuote(absPath)} ${shellQuote(prefixR.value)}`; const r = await env.exec(cmd, { timeout: PDF_EXEC_TIMEOUT_SEC, abortSignal: signal }); if (!r.ok) { if (r.error.code === "timeout") { const listed = await listRenderedJpegs(env, dir, signal); const jpgs = listed.ok ? listed.jpgs : []; const pages = []; for (let i = 0; i < jpgs.length; i++) { const bin = await env.readBinaryFile(jpgs[i], signal); if (!bin.ok || !jpegLooksComplete(bin.value)) break; pages.push({ pageNumber: first + i, jpeg: bin.value }); } if (pages.length > 0) return { ok: true, pages, timedOutAfterSec: PDF_EXEC_TIMEOUT_SEC }; return { ok: false, toolMissing: false, timedOut: true, error: `pdftoppm timed out after ${PDF_EXEC_TIMEOUT_SEC}s before finishing any page. Request a narrower pages range (e.g. a single page like pages: "${first}").`, }; } return { ok: false, toolMissing: false, error: r.error.message }; } if (r.value.exitCode !== 0) { const stderr = previewExecStream((r.value.stderr || r.value.stdout || "").trim()); const missing = r.value.exitCode === 127 || /not found|No such file or directory: pdftoppm|command not found/i.test(stderr); return { ok: false, toolMissing: missing, error: stderr || `pdftoppm exited with code ${r.value.exitCode}` }; } const listed = await listRenderedJpegs(env, dir, signal); if (!listed.ok) return { ok: false, toolMissing: false, error: listed.error }; const jpgs = listed.jpgs; if (jpgs.length === 0) return { ok: false, toolMissing: false, error: "pdftoppm produced no page images (page range past the end of the document?)" }; const pages = []; for (let i = 0; i < jpgs.length; i++) { const bin = await env.readBinaryFile(jpgs[i], signal); if (!bin.ok) return { ok: false, toolMissing: false, error: `cannot read the rendered page image: ${bin.error.message}` }; pages.push({ pageNumber: first + i, jpeg: bin.value }); } return { ok: true, pages }; } finally { void env.remove(dir, { recursive: true, force: true }).catch(() => undefined); } }