feat(T2b): sources v0 — read tool + pdf-reader sub-agent
Learner source files, on the teacher's own terms (nothing auto-injects): - read tool (src/agent/tools/read.ts): pi-read port — path/offset/limit, 2000-line/50KB truncation with use-offset continuation notices, image files (jpg/png/gif/webp/bmp) returned as native image content parts (new LhToolImageResult escape hatch in lh-tool.ts). Always-on in both the prober and teacher registries (user-decided 2026-09). - pdf-reader sub-agent (JobKind 'pdf', toggleable, ack-and-steer): triage via pdf_info (pdfjs text layer: per-page text/image/math-density), targeted pdf_extract, regex pdf_search (hits count as consultation), and pdf_render — pages painted by pdfjs onto a canvas shim in headless Chrome (the pdfium viewer doesn't paint headless), screenshot fed back image-in-the-loop (visual-maker idiom, providerSafePng applied). The unread-finish gate bounces a zero-consultation brief once. Teacher gets a text brief with page citations only; renders never leave the loop. - MODELS.pdfReader = qwen/qwen3.8-flash (user override 2026-09-19: the look-at-renders workload wants the vision-proven model; catalog listed glm-5.3-flash as image-capable but qwen is the known-good daily driver). - UI: PdfCompleteBadge + PdfBriefCard (steer-card, job-card), dispatch card + activity-dock kind entries (type-forced, flagged for UI review). - Prompts: 'Learner source files' section in teacher/30-jobs.md; read line in prober/10-probe-doctrine.md. Doctrine rides tool descriptions per the self-contained-tools principle.
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
|
||||
/**
|
||||
* PDF plumbing for the pdf-reader sub-agent — sources v0 (2026-09): Node
|
||||
* ports of the four pdf-reader skill scripts (pdf_info / pdf_extract /
|
||||
* pdf_search / pdf_render), on the user-chosen hybrid stack:
|
||||
*
|
||||
* - TEXT (info / extract / search): pdfjs-dist — pure JS, no native deps.
|
||||
* - RENDER: the shared headless-Chrome infra (subagents/headless.ts,
|
||||
* puppeteer + system Chrome) screenshots pdfium-rendered pages.
|
||||
*
|
||||
* Doctrine (the skill's reading strategy, mirrored in the sub-agent's system
|
||||
* prompt): triage with pdf_info FIRST, then size-based strategy — short PDFs
|
||||
* render all pages, medium render flagged (math/image-heavy) pages, long
|
||||
* ones search for targeted lookups before reading anything at length.
|
||||
*
|
||||
* These are pure library functions — no model in sight. The pdf-reader
|
||||
* agent (pdf-reader.ts) drives them through its own tool loop.
|
||||
*/
|
||||
|
||||
export interface PdfPageInfo {
|
||||
page: number;
|
||||
text_length: number;
|
||||
image_count: number;
|
||||
/** Fraction of math-flavored chars — the render-this-page signal (port
|
||||
* of the skill's unicode-range density heuristic). */
|
||||
math_density: number;
|
||||
}
|
||||
|
||||
export interface PdfInfo {
|
||||
path: string;
|
||||
page_count: number;
|
||||
/** Mean chars/page — the size class the strategy branches on. */
|
||||
mean_text_length: number;
|
||||
pages: PdfPageInfo[];
|
||||
}
|
||||
|
||||
/** Load the legacy pdfjs build (runs headless in plain node, no worker). */
|
||||
async function loadPdfjs() {
|
||||
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
return pdfjs;
|
||||
}
|
||||
|
||||
async function openDocument(filePath: string) {
|
||||
const pdfjs = await loadPdfjs();
|
||||
const data = new Uint8Array(await fs.readFile(filePath));
|
||||
// The legacy build in plain node runs on a main-thread fake worker (no
|
||||
// workerSrc needed); teardown is the loading task's destroy().
|
||||
const task = pdfjs.getDocument({ data });
|
||||
return { doc: await task.promise, task };
|
||||
}
|
||||
|
||||
/** Concatenate a page's text items into a body string. */
|
||||
function itemsToText(items: unknown[]): string {
|
||||
let out = "";
|
||||
for (const it of items) {
|
||||
if (it && typeof it === "object" && "str" in (it as Record<string, unknown>)) {
|
||||
const str = (it as { str?: unknown }).str;
|
||||
if (typeof str === "string") out += str;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Math-flavored char fraction — the skill's heuristic, verbatim ranges. */
|
||||
function mathDensity(text: string): number {
|
||||
let mathChars = 0;
|
||||
for (const c of text) {
|
||||
const code = c.codePointAt(0)!;
|
||||
if (
|
||||
(code >= 0x2200 && code <= 0x22ff) || // Mathematical Operators
|
||||
(code >= 0x2100 && code <= 0x214f) || // Letterlike Symbols
|
||||
(code >= 0x2190 && code <= 0x21ff) || // Arrows
|
||||
(code >= 0x27c0 && code <= 0x27ef) || // Misc Mathematical Symbols-A
|
||||
(code >= 0x2980 && code <= 0x29ff) || // Misc Mathematical Symbols-B
|
||||
(code >= 0x2a00 && code <= 0x2aff) || // Supplemental Mathematical Operators
|
||||
(code >= 0x0370 && code <= 0x03ff) || // Greek
|
||||
(code >= 0x1d400 && code <= 0x1d7ff) || // Mathematical Alphanumeric Symbols
|
||||
"∫∑∏√∂∇∞≈≠≤≥±×÷∈∉⊂⊃∪∩∧∨¬∀∃∅".includes(c)
|
||||
) {
|
||||
mathChars++;
|
||||
}
|
||||
}
|
||||
return mathChars / Math.max(text.length, 1);
|
||||
}
|
||||
|
||||
/** Cheap validity probe: getDocument resolves only for parseable PDFs —
|
||||
* no page walk, no text extraction (unlike pdfInfo). The reader's upfront
|
||||
* unreadable-source gate; garbage files throw pdfjs's precise error. */
|
||||
export async function pdfProbe(filePath: string): Promise<void> {
|
||||
const { task } = await openDocument(filePath);
|
||||
await task.destroy();
|
||||
}
|
||||
|
||||
/** pdf_info: page count + per-page triage signals. ALWAYS run first. */
|
||||
export async function pdfInfo(filePath: string): Promise<PdfInfo> {
|
||||
const pdfjs = await loadPdfjs(); // OPS constant for the operator-list scan
|
||||
const { doc } = await openDocument(filePath);
|
||||
const pages: PdfPageInfo[] = [];
|
||||
let totalText = 0;
|
||||
for (let i = 1; i <= doc.numPages; i++) {
|
||||
const page = await doc.getPage(i);
|
||||
const content = await page.getTextContent();
|
||||
const text = itemsToText(content.items as unknown[]);
|
||||
// count page images: paintImageXObject ops in the operator list
|
||||
const ops = await page.getOperatorList();
|
||||
let imageCount = 0;
|
||||
for (const fn of ops.fnArray) {
|
||||
if (fn === pdfjs.OPS.paintImageXObject) imageCount++;
|
||||
}
|
||||
totalText += text.length;
|
||||
pages.push({
|
||||
page: i,
|
||||
text_length: text.length,
|
||||
image_count: imageCount,
|
||||
math_density: Math.round(mathDensity(text) * 10000) / 10000,
|
||||
});
|
||||
}
|
||||
return {
|
||||
path: filePath,
|
||||
page_count: doc.numPages,
|
||||
mean_text_length: Math.round(totalText / Math.max(doc.numPages, 1)),
|
||||
pages,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse 'all' | '1-5' | '1,3,7' | '3' into a sorted 1-indexed page list. */
|
||||
export function parsePageSpec(spec: string, total: number): number[] {
|
||||
const trimmed = spec.trim().toLowerCase();
|
||||
if (trimmed === "all") return Array.from({ length: total }, (_, i) => i + 1);
|
||||
const pages = new Set<number>();
|
||||
for (const part of spec.split(",")) {
|
||||
const p = part.trim();
|
||||
if (!p) continue;
|
||||
if (p.includes("-")) {
|
||||
const [startRaw, endRaw] = p.split("-", 2) as [string, string];
|
||||
const start = Math.max(1, parseInt(startRaw, 10));
|
||||
const end = Math.min(total, parseInt(endRaw, 10));
|
||||
for (let pg = start; pg <= end; pg++) pages.add(pg);
|
||||
} else {
|
||||
const pg = parseInt(p, 10);
|
||||
if (pg >= 1 && pg <= total) pages.add(pg);
|
||||
}
|
||||
}
|
||||
return [...pages].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** pdf_extract: text of the requested pages, `--- Page N ---` headed. */
|
||||
export async function pdfExtract(filePath: string, pageSpec: string): Promise<string> {
|
||||
const { doc } = await openDocument(filePath);
|
||||
const pages = parsePageSpec(pageSpec, doc.numPages);
|
||||
if (pages.length === 0) return "No pages selected — page spec matched nothing.";
|
||||
const out: string[] = [];
|
||||
for (const i of pages) {
|
||||
const page = await doc.getPage(i);
|
||||
const content = await page.getTextContent();
|
||||
out.push(`--- Page ${i} ---`);
|
||||
out.push(itemsToText(content.items as unknown[]));
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
export interface PdfSearchHit {
|
||||
page: number;
|
||||
line: number;
|
||||
/** contextLines around the hit, `>>>` marking the hit line. */
|
||||
context: string;
|
||||
}
|
||||
|
||||
/** pdf_search: keyword/regex across all pages with context lines.
|
||||
* pageTexts rides along so the agent can extract a hit's full page without
|
||||
* a second pass (the skill pipelines `search → extract` the same way). */
|
||||
export async function pdfSearch(
|
||||
filePath: string,
|
||||
query: string,
|
||||
opts?: { contextLines?: number; literal?: boolean },
|
||||
): Promise<{ hits: PdfSearchHit[]; pageTexts: Map<number, string> }> {
|
||||
const contextLines = opts?.contextLines ?? 3;
|
||||
const { doc } = await openDocument(filePath);
|
||||
let pattern: RegExp;
|
||||
if (opts?.literal) {
|
||||
pattern = new RegExp(escapeRegex(query), "i");
|
||||
} else {
|
||||
try {
|
||||
pattern = new RegExp(query, "i");
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid regex: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
const hits: PdfSearchHit[] = [];
|
||||
const pageTexts = new Map<number, string>();
|
||||
for (let i = 1; i <= doc.numPages; i++) {
|
||||
const page = await doc.getPage(i);
|
||||
const content = await page.getTextContent();
|
||||
const text = itemsToText(content.items as unknown[]);
|
||||
pageTexts.set(i, text);
|
||||
const lines = text.split("\n");
|
||||
for (let ln = 0; ln < lines.length; ln++) {
|
||||
if (!pattern.test(lines[ln]!)) continue;
|
||||
const start = Math.max(0, ln - contextLines);
|
||||
const end = Math.min(lines.length, ln + contextLines + 1);
|
||||
const context = lines
|
||||
.slice(start, end)
|
||||
.map((l, j) => `${start + j === ln ? ">>>" : " "} ${l}`)
|
||||
.join("\n");
|
||||
hits.push({ page: i, line: ln + 1, context });
|
||||
}
|
||||
}
|
||||
return { hits, pageTexts };
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** Where rendered pages land (mirrors the skill's /tmp convention). */
|
||||
export function renderDir(filePath: string): string {
|
||||
// FNV-1a short hash of the absolute path — stable per file, no deps.
|
||||
const abs = path.resolve(filePath);
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < abs.length; i++) {
|
||||
h ^= abs.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return path.join("/tmp", `lh-pdf-${h.toString(16).padStart(8, "0")}`);
|
||||
}
|
||||
Reference in New Issue
Block a user