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:
amosblomqvist
2026-09-19 15:24:26 +03:00
parent bc2c02b865
commit 9a90dd51d9
27 changed files with 1970 additions and 55 deletions
+227
View File
@@ -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")}`);
}