@sema-agent/core
Version:
Stateless, task-oriented AI agent core
177 lines (176 loc) • 8.57 kB
JavaScript
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);
}
}