@bli-cockpit/cli 0.2.271 → 0.2.272

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.271");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.272");
25
25
  return 0;
26
26
  }
27
27
 
@@ -17,11 +17,17 @@ import { isUtf8 } from "node:buffer";
17
17
  import { formatImageMarker } from "@bli-cockpit/telemetry-core";
18
18
  import { TranscriptNotStreamable } from "./adapters/transcript-text-stream.js";
19
19
  import { bytesMayHoldImage, scanImagesInLine } from "./image-byte-scan.js";
20
+ import { bytesMayHoldPdf, scanPdfsInLine } from "./pdf-byte-scan.js";
20
21
  export async function censusOfLines(lines) {
21
- const census = { total: 0, occurrences: [], unique: new Map(), tally: new Map() };
22
+ const census = { total: 0, occurrences: [], unique: new Map(), tally: new Map(), pdfs: [] };
22
23
  for await (const { start, bytes } of lines) {
23
24
  if (!isUtf8(bytes))
24
25
  throw new TranscriptNotStreamable("invalid_utf8");
26
+ if (bytesMayHoldPdf(bytes)) {
27
+ for (const pdf of scanPdfsInLine(bytes)) {
28
+ census.pdfs.push({ ...pdf, replaceStart: pdf.replaceStart + start, replaceEnd: pdf.replaceEnd + start, base64Start: pdf.base64Start + start, base64End: pdf.base64End + start });
29
+ }
30
+ }
25
31
  if (!bytesMayHoldImage(bytes))
26
32
  continue;
27
33
  for (const found of scanImagesInLine(bytes)) {
@@ -11,6 +11,7 @@ import { sendPendingImages } from "./image-description-send.js";
11
11
  import { resolveImageHandlingMode } from "./image-mode-state.js";
12
12
  import { censusOfLines } from "./image-census-stream.js";
13
13
  import { sha256OfBase64Run } from "./image-byte-scan.js";
14
+ import { addPdfsToFinds, mergeOccurrences, pdfsOfText } from "./pdf-transcript-step.js";
14
15
  import { openPinnedTranscript } from "./adapters/pinned-transcript.js";
15
16
  import { openStagedOutput, readStagedText } from "./adapters/staged-output.js";
16
17
  import { TranscriptNotStreamable } from "./adapters/transcript-text-stream.js";
@@ -47,7 +48,12 @@ export async function openImageDescriptions(options) {
47
48
  isDescribed: (sha256) => described.has(imageStateKey(sha256)),
48
49
  hasDescribeRoom: (sha256s) => describeRoomLeft(run, sha256s),
49
50
  async prepareTranscript(input) {
50
- const { replace, finds, ...afterImages } = await describeAndReplace(run, input, findsOfText(input.text));
51
+ // BLI-23347: in drop mode each base64 PDF becomes a marker block (its text, or its described page images).
52
+ const imageFinds = findsOfText(input.text);
53
+ const finds0 = mode === "describe_and_drop"
54
+ ? await addPdfsToFinds(imageFinds, pdfsOfText(input.text), pdfContext(run, { stateDir: options.stateDir, read: async (pdf) => input.text.slice(pdf.base64Start, pdf.base64End), changed: () => new Error("pdf_changed_in_text") }))
55
+ : imageFinds;
56
+ const { replace, finds, ...afterImages } = await describeAndReplace(run, input, finds0);
51
57
  if (mode !== "describe_and_drop")
52
58
  return { ...afterImages, text: input.text, changed: false, slim: null };
53
59
  // BLI-23317: the image swap and the slim rules are ONE pass over the lines (one joined string, no
@@ -116,11 +122,17 @@ async function prepareFileInTwoPasses(run, input) {
116
122
  // Pass one: discover. Nothing of the text is kept; only where each image is and what it is.
117
123
  const census = await censusOfLines(source.lines());
118
124
  // Describe attempts finish and the swap decisions are settled before a single line is swapped, as with the whole text.
119
- const { replace, finds: _finds, ...afterImages } = await describeAndReplace(run, input, findsOfCensus(census, source));
125
+ // BLI-23347: the census also found the PDFs; they are converted here and swapped in the same pass as the images.
126
+ const pdfFinds = await addPdfsToFinds(findsOfCensus(census, source), census.pdfs, pdfContext(run, {
127
+ stateDir: run.options.stateDir,
128
+ read: (pdf) => source.read(pdf.base64Start, pdf.base64End - pdf.base64Start),
129
+ changed: () => new TranscriptNotStreamable("file_changed_during_pass"),
130
+ }));
131
+ const { replace, finds, ...afterImages } = await describeAndReplace(run, input, pdfFinds);
120
132
  // Pass two: swap and slim over exactly the bytes pass one saw, writing the result to staging as it goes.
121
133
  writer = input.stageDir ? await openStagedOutput(input.stageDir) : undefined;
122
134
  const memory = writer ? undefined : new LineBytesSink(source.length);
123
- const slim = await slimLineStream(source.lines(), input.source, census.occurrences, replace, writer ?? memory);
135
+ const slim = await slimLineStream(source.lines(), input.source, mergeOccurrences(census.occurrences, finds.extraOccurrences), replace, writer ?? memory);
124
136
  if (slim.replacedCount !== afterImages.replacedCount)
125
137
  throw new TranscriptNotStreamable("images_changed_between_passes");
126
138
  await source.verifyUnchanged();
@@ -202,14 +214,24 @@ function countImageSwaps(swap) {
202
214
  let replacedCount = 0;
203
215
  let charsSaved = 0;
204
216
  for (const image of swap.images) {
205
- if (!swap.replace(image))
217
+ if (image.replacement === undefined && !swap.replace(image))
206
218
  continue;
207
- const marker = formatImageMarker({ sha256: image.sha256, mediaType: image.mediaType, byteSize: image.byteSize });
219
+ const marker = image.replacement ?? formatImageMarker({ sha256: image.sha256, mediaType: image.mediaType, byteSize: image.byteSize });
208
220
  replacedCount += 1;
209
221
  charsSaved += image.replaceEnd - image.replaceStart - marker.length;
210
222
  }
211
223
  return { replacedCount, charsSaved };
212
224
  }
225
+ /** What the PDF step needs from this run (BLI-23347); `described` is the same set the image step reads. */
226
+ function pdfContext(run, parts) {
227
+ return {
228
+ key: run,
229
+ stateDir: parts.stateDir,
230
+ isDescribed: (sha256) => run.described.has(imageStateKey(sha256)),
231
+ readBase64: parts.read,
232
+ sourceChanged: parts.changed,
233
+ };
234
+ }
213
235
  function selectImagesToDescribe(run, unique, counts) {
214
236
  const { described, settledThisRun, failedImages, now } = run;
215
237
  const toSend = [];
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Find the base64 PDFs of one transcript line on its BYTES (BLI-23347), the PDF twin of `image-byte-scan.ts`.
3
+ *
4
+ * Claude Code stores a PDF it read twice on one line (`message.content[].content[].source.data` and
5
+ * `toolUseResult.file.base64`). Two shapes are recognised, both ASCII so a byte offset here is the character
6
+ * offset on ASCII text:
7
+ *
8
+ * - a whole-string payload: a base64 run that starts right after a `"` with `JVBERi0` (the base64 of
9
+ * `%PDF-`), is at least `PDF_MIN_BASE64_CHARS` long and is closed by `"` or by `\"` (a string that was
10
+ * itself stored inside a string; `escapeDepth` says which, so the replacement is escaped to match);
11
+ * - a data URL `data:application/pdf;base64,` followed by such a run; the whole URL is the replaced range.
12
+ *
13
+ * The run is hashed in blocks of 48 Ki characters straight from the line, so neither a string of the
14
+ * run nor a Buffer of the whole PDF exists here; the first block is sniffed for `%PDF-`. A run whose `=`
15
+ * characters are not plain trailing padding is not a PDF we can vouch for and is left alone.
16
+ */
17
+ import { createHash } from "node:crypto";
18
+ const QUOTE = 0x22;
19
+ const BACKSLASH = 0x5c;
20
+ const EQUALS = 0x3d;
21
+ /** Characters per decoded block. A multiple of 4: each block is a whole number of base64 quanta. */
22
+ const BLOCK_CHARS = 48 * 1024;
23
+ /** Below this a "PDF" is a stub, not a document anyone read (a one-line minimal PDF is about 400 bytes). */
24
+ export const PDF_MIN_BASE64_CHARS = 600;
25
+ const BASE64 = new Uint8Array(256);
26
+ for (const range of ["AZ", "az", "09"]) {
27
+ for (let code = range.charCodeAt(0); code <= range.charCodeAt(1); code += 1)
28
+ BASE64[code] = 1;
29
+ }
30
+ for (const char of "+/=")
31
+ BASE64[char.charCodeAt(0)] = 1;
32
+ const START = Buffer.from("JVBERi0", "latin1");
33
+ const DATA_URL = Buffer.from("data:application/pdf;base64,", "latin1");
34
+ const PDF_MAGIC = Buffer.from("%PDF-", "latin1");
35
+ /** Is there any chance this line holds a PDF? */
36
+ export function bytesMayHoldPdf(line) {
37
+ return line.includes(START) || line.includes(DATA_URL);
38
+ }
39
+ function runEnd(line, from) {
40
+ let at = from;
41
+ while (at < line.length && BASE64[line[at]] === 1)
42
+ at += 1;
43
+ return at;
44
+ }
45
+ /** sha256 and decoded size of the PDF `line[from, to)` encodes, or null when it is not one we can vouch for. */
46
+ export function digestPdfRun(line, from, to) {
47
+ const length = to - from;
48
+ const firstPad = line.subarray(from, to).indexOf(EQUALS);
49
+ if (firstPad !== -1) {
50
+ const padCount = length - firstPad;
51
+ if (padCount > 2 || length % 4 !== 0)
52
+ return null;
53
+ for (let at = from + firstPad; at < to; at += 1)
54
+ if (line[at] !== EQUALS)
55
+ return null;
56
+ }
57
+ const hash = createHash("sha256");
58
+ let byteSize = 0;
59
+ for (let at = from; at < to; at += BLOCK_CHARS) {
60
+ const block = Buffer.from(line.toString("latin1", at, Math.min(to, at + BLOCK_CHARS)), "base64");
61
+ if (at === from && block.compare(PDF_MAGIC, 0, PDF_MAGIC.length, 0, PDF_MAGIC.length) !== 0)
62
+ return null;
63
+ hash.update(block);
64
+ byteSize += block.byteLength;
65
+ }
66
+ return { sha256: hash.digest("hex"), byteSize };
67
+ }
68
+ /** Every embedded PDF in one physical line, in order, with byte offsets inside the line. */
69
+ export function scanPdfsInLine(line) {
70
+ const found = [];
71
+ if (line.length < PDF_MIN_BASE64_CHARS)
72
+ return found;
73
+ let position = 0;
74
+ while (position < line.length) {
75
+ const quoted = nextQuotedStart(line, position);
76
+ const dataUrl = line.indexOf(DATA_URL, position);
77
+ if (quoted === -1 && dataUrl === -1)
78
+ break;
79
+ const useUrl = dataUrl !== -1 && (quoted === -1 || dataUrl < quoted);
80
+ const base64Start = useUrl ? dataUrl + DATA_URL.length : quoted;
81
+ const end = runEnd(line, base64Start);
82
+ position = Math.max(end, base64Start + 1);
83
+ if (end - base64Start < PDF_MIN_BASE64_CHARS)
84
+ continue;
85
+ let escapeDepth = 0;
86
+ if (!useUrl) {
87
+ const plain = end < line.length && line[end] === QUOTE;
88
+ const escaped = end + 1 < line.length && line[end] === BACKSLASH && line[end + 1] === QUOTE;
89
+ if (!plain && !escaped)
90
+ continue;
91
+ escapeDepth = escaped ? 1 : 0;
92
+ }
93
+ else if (dataUrl >= 2 && line[dataUrl - 2] === BACKSLASH && line[dataUrl - 1] === QUOTE) {
94
+ escapeDepth = 1;
95
+ }
96
+ const digest = digestPdfRun(line, base64Start, end);
97
+ if (!digest)
98
+ continue;
99
+ found.push({ replaceStart: useUrl ? dataUrl : base64Start, replaceEnd: end, base64Start, base64End: end, escapeDepth, ...digest });
100
+ }
101
+ return found;
102
+ }
103
+ /** The next index at or after `from` where the PDF start sits right after a `"`, or -1. */
104
+ function nextQuotedStart(line, from) {
105
+ let at = line.indexOf(START, from);
106
+ while (at !== -1 && !(at > 0 && line[at - 1] === QUOTE))
107
+ at = line.indexOf(START, at + 1);
108
+ return at;
109
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * What a PDF became, kept on disk by its sha256 so a transcript that is rebuilt every tick does not re-read
3
+ * the same PDF every tick (BLI-23347).
4
+ *
5
+ * One small JSON file per distinct PDF under `<state dir>/pdf-conversions/`: the converted text, or for a
6
+ * scan the page list (page number, sha256 and size of each page image). Never the PDF, never a page image,
7
+ * never a path: the text is what the transcript carries anyway. A file written by other rules (a different
8
+ * `PDF_RULES_VERSION`) or damaged is treated as absent, with the reason logged. At most `MAX_ENTRIES` files
9
+ * stay; the oldest go first.
10
+ */
11
+ import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
12
+ import path from "node:path";
13
+ import { PDF_RULES_VERSION } from "@bli-cockpit/telemetry-core";
14
+ const TAG = "[pdf-cache]";
15
+ const DIRECTORY = "pdf-conversions";
16
+ const MAX_ENTRIES = 300;
17
+ const SHA = /^[0-9a-f]{64}$/;
18
+ function fileOf(stateDir, sha256) {
19
+ return path.join(stateDir, DIRECTORY, `${sha256}.json`);
20
+ }
21
+ function valid(value) {
22
+ if (typeof value !== "object" || value === null)
23
+ return false;
24
+ const record = value;
25
+ if (typeof record["pages"] !== "number" || !Array.isArray(record["notes"]) || !record["notes"].every((note) => typeof note === "string"))
26
+ return false;
27
+ if (record["kind"] === "text")
28
+ return typeof record["body"] === "string";
29
+ if (record["kind"] !== "scanned" || !Array.isArray(record["pageImages"]))
30
+ return false;
31
+ return record["pageImages"].every((entry) => typeof entry === "object" && entry !== null
32
+ && typeof entry["page"] === "number"
33
+ && typeof entry["sha256"] === "string" && SHA.test(entry["sha256"])
34
+ && typeof entry["byteSize"] === "number");
35
+ }
36
+ export async function readCachedPdf(stateDir, sha256) {
37
+ let raw;
38
+ try {
39
+ raw = await readFile(fileOf(stateDir, sha256), "utf8");
40
+ }
41
+ catch (error) {
42
+ if (error.code !== "ENOENT")
43
+ console.error(TAG + " entry unreadable, converting again", JSON.stringify({ reason: "cache_read_failed", error_name: error instanceof Error ? error.name : typeof error }));
44
+ return null;
45
+ }
46
+ try {
47
+ const parsed = JSON.parse(raw);
48
+ if (parsed.rules !== PDF_RULES_VERSION) {
49
+ console.error(TAG + " entry made by other rules, converting again", JSON.stringify({ reason: "cache_rules_changed" }));
50
+ return null;
51
+ }
52
+ if (valid(parsed.entry))
53
+ return parsed.entry;
54
+ }
55
+ catch {
56
+ // Falls through to the damaged-entry log below.
57
+ }
58
+ console.error(TAG + " entry damaged, converting again", JSON.stringify({ reason: "cache_entry_invalid", byte_size: raw.length }));
59
+ return null;
60
+ }
61
+ export async function writeCachedPdf(stateDir, sha256, entry) {
62
+ const directory = path.join(stateDir, DIRECTORY);
63
+ const target = fileOf(stateDir, sha256);
64
+ const partial = `${target}.${process.pid}.partial`;
65
+ try {
66
+ await mkdir(directory, { recursive: true });
67
+ await writeFile(partial, JSON.stringify({ rules: PDF_RULES_VERSION, entry }), "utf8");
68
+ await rename(partial, target);
69
+ }
70
+ catch (error) {
71
+ await unlink(partial).catch(() => undefined);
72
+ console.error(TAG + " entry not saved, it will be converted again next time", JSON.stringify({ reason: "cache_write_failed", error_name: error instanceof Error ? error.name : typeof error }));
73
+ return;
74
+ }
75
+ await prune(directory);
76
+ }
77
+ async function prune(directory) {
78
+ try {
79
+ const names = (await readdir(directory)).filter((name) => name.endsWith(".json"));
80
+ if (names.length <= MAX_ENTRIES)
81
+ return;
82
+ const aged = await Promise.all(names.map(async (name) => ({ name, mtime: (await stat(path.join(directory, name)).catch(() => null))?.mtimeMs ?? 0 })));
83
+ aged.sort((a, b) => a.mtime - b.mtime);
84
+ const drop = aged.slice(0, names.length - MAX_ENTRIES + 50);
85
+ await Promise.all(drop.map(({ name }) => unlink(path.join(directory, name)).catch(() => undefined)));
86
+ console.error(TAG + " oldest entries removed", JSON.stringify({ reason: "cache_over_limit", removed: drop.length, kept: names.length - drop.length }));
87
+ }
88
+ catch (error) {
89
+ console.error(TAG + " prune failed", JSON.stringify({ reason: "cache_prune_failed", error_name: error instanceof Error ? error.name : typeof error }));
90
+ }
91
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * One PDF's bytes to what the transcript keeps (BLI-23347): its text with tables kept, or, for a scan with no
3
+ * text layer, the PNG of each page's image so the existing image describe door can read it.
4
+ *
5
+ * pdf.js comes from `unpdf`, which ships it as one self-contained module: no native dependency, no canvas, so
6
+ * macOS and Windows behave the same. A scan is not RENDERED (that needs a canvas); each page's largest embedded
7
+ * image is decoded by pdf.js's own decoders and re-encoded by `pdf-page-image.ts`. A page drawn as vector shapes
8
+ * with no text and no image has nothing to extract and is named as such.
9
+ *
10
+ * Bounded: the document is held once (the caller's bytes), one page's raster at a time, at most
11
+ * `MAX_TEXT_PAGES` pages of text, `MAX_TEXT_CHARS` of it, `MAX_IMAGE_PAGES` page images, and a wall-clock
12
+ * budget per document. Hitting a bound is said in `notes`, never silent. Never throws: a document that cannot
13
+ * be read comes back as `failed` with a reason label.
14
+ *
15
+ * CHANGING WHAT THIS PRODUCES (limits, the text/scan rule, pdf-layout.ts heuristics) MUST BUMP
16
+ * `PDF_RULES_VERSION` in telemetry-core so every collector rebuilds the transcripts it converted.
17
+ */
18
+ import { IMAGE_DESCRIBE_MAX_BYTES } from "@bli-cockpit/telemetry-core";
19
+ import { layoutPage } from "./pdf-layout.js";
20
+ import { rasterToPagePng } from "./pdf-page-image.js";
21
+ export const MAX_TEXT_PAGES = 300;
22
+ export const MAX_TEXT_CHARS = 400_000;
23
+ export const MAX_IMAGE_PAGES = 8;
24
+ export const MAX_CONVERT_MS = 25_000;
25
+ /** A document whose first pages hold fewer characters per page than this has no usable text layer. */
26
+ const MIN_CHARS_PER_PAGE = 30;
27
+ /** Pages sampled to decide text or scan. */
28
+ const SAMPLE_PAGES = 10;
29
+ let loading;
30
+ /** pdf.js through unpdf, loaded once per process; null (and a logged reason) when the package is missing. */
31
+ async function loadPdfJs() {
32
+ loading ??= import("unpdf")
33
+ .then(async (unpdf) => (await unpdf.getResolvedPDFJS()))
34
+ .catch((error) => {
35
+ console.error("[pdf-convert] extractor unavailable", JSON.stringify({ reason: "extractor_unavailable", error_name: error instanceof Error ? error.name : typeof error }));
36
+ return null;
37
+ });
38
+ return loading;
39
+ }
40
+ function textItemsOf(content) {
41
+ const items = [];
42
+ for (const item of content.items) {
43
+ if (typeof item.str !== "string" || !item.transform)
44
+ continue;
45
+ items.push({ str: item.str, x: item.transform[4], y: item.transform[5], width: item.width ?? 0, size: Math.abs(item.transform[3]) || item.height || 10 });
46
+ }
47
+ return items;
48
+ }
49
+ /** Control characters other than tab and newline mean nothing to a reader and break line-oriented tools. */
50
+ function clean(text) {
51
+ return text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "");
52
+ }
53
+ function failure(error, pages) {
54
+ const name = error instanceof Error ? error.name : typeof error;
55
+ const reason = name === "PasswordException" ? "password_protected" : name === "InvalidPDFException" || name === "FormatError" ? "not_a_readable_pdf" : "extract_error";
56
+ return { kind: "failed", reason, pages };
57
+ }
58
+ async function pageRaster(pdfjs, page) {
59
+ const list = await page.getOperatorList();
60
+ let best = null;
61
+ for (let index = 0; index < list.fnArray.length; index += 1) {
62
+ const op = list.fnArray[index];
63
+ if (op !== pdfjs.OPS.paintImageXObject && op !== pdfjs.OPS.paintInlineImageXObject)
64
+ continue;
65
+ const arg = list.argsArray[index]?.[0];
66
+ let image = arg;
67
+ if (typeof arg === "string") {
68
+ image = page.objs.has(arg) ? page.objs.get(arg) : await new Promise((resolve) => { page.objs.get(arg, resolve); });
69
+ }
70
+ const candidate = image;
71
+ if (!candidate?.data || !candidate.width || !candidate.height || (candidate.kind !== 1 && candidate.kind !== 2 && candidate.kind !== 3))
72
+ continue;
73
+ if (!best || candidate.width * candidate.height > best.width * best.height)
74
+ best = candidate;
75
+ }
76
+ return best;
77
+ }
78
+ export async function convertPdf(bytes, now = Date.now) {
79
+ const started = now();
80
+ const pdfjs = await loadPdfJs();
81
+ if (!pdfjs)
82
+ return { kind: "failed", reason: "extractor_unavailable", pages: null };
83
+ const task = pdfjs.getDocument({ data: bytes, isEvalSupported: false, disableFontFace: true, useSystemFonts: false, verbosity: 0 });
84
+ let doc;
85
+ try {
86
+ doc = await task.promise;
87
+ const pages = doc.numPages;
88
+ // Text first: the first pages decide text or scan, and a text document goes on page by page.
89
+ const rendered = [];
90
+ const notes = [];
91
+ let chars = 0;
92
+ let sampledChars = 0;
93
+ let shown = 0;
94
+ for (let number = 1; number <= Math.min(pages, MAX_TEXT_PAGES); number += 1) {
95
+ if (now() - started > MAX_CONVERT_MS) {
96
+ notes.push(`stopped after ${MAX_CONVERT_MS / 1000} s`);
97
+ break;
98
+ }
99
+ const page = await doc.getPage(number);
100
+ const text = clean(layoutPage(textItemsOf(await page.getTextContent())));
101
+ page.cleanup();
102
+ if (number <= SAMPLE_PAGES)
103
+ sampledChars += text.replace(/\s/g, "").length;
104
+ if (number === Math.min(pages, SAMPLE_PAGES) && sampledChars < MIN_CHARS_PER_PAGE * number)
105
+ return await scannedPages(pdfjs, doc, pages, started, now);
106
+ if (chars + text.length > MAX_TEXT_CHARS) {
107
+ notes.push(`text cut at ${MAX_TEXT_CHARS} characters`);
108
+ break;
109
+ }
110
+ chars += text.length;
111
+ rendered.push(text === "" ? `[page ${number}: no text]` : `[page ${number}]\n${text}`);
112
+ shown = number;
113
+ }
114
+ // Nothing read (the time budget was gone before page one): never a text block of nothing in place of a document.
115
+ if (shown === 0)
116
+ return { kind: "failed", reason: "time_budget_spent_before_first_page", pages };
117
+ if (shown < pages)
118
+ notes.push(`pages 1-${shown} of ${pages} shown`);
119
+ return { kind: "text", pages, body: rendered.join("\n\n"), notes };
120
+ }
121
+ catch (error) {
122
+ return failure(error, doc?.numPages ?? null);
123
+ }
124
+ finally {
125
+ await task.destroy().catch(() => undefined);
126
+ }
127
+ }
128
+ async function scannedPages(pdfjs, doc, pages, started, now) {
129
+ const pageImages = [];
130
+ const notes = ["no text layer"];
131
+ const missing = [];
132
+ const limit = Math.min(pages, MAX_IMAGE_PAGES);
133
+ for (let number = 1; number <= limit; number += 1) {
134
+ if (now() - started > MAX_CONVERT_MS) {
135
+ notes.push(`stopped after ${MAX_CONVERT_MS / 1000} s`);
136
+ break;
137
+ }
138
+ const page = await doc.getPage(number);
139
+ const raster = await pageRaster(pdfjs, page);
140
+ page.cleanup();
141
+ const png = raster ? rasterToPagePng(raster, IMAGE_DESCRIBE_MAX_BYTES - 100_000) : null;
142
+ if (png)
143
+ pageImages.push({ page: number, png });
144
+ else
145
+ missing.push(number);
146
+ }
147
+ if (missing.length > 0)
148
+ notes.push(`no page image on ${missing.length === 1 ? "page" : "pages"} ${missing.join(", ")}`);
149
+ if (pages > limit)
150
+ notes.push(`pages 1-${limit} of ${pages} described`);
151
+ return { kind: "scanned", pages, pageImages, notes };
152
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * One PDF page's text items to readable text, tables kept (BLI-23347).
3
+ *
4
+ * Pure: positioned text items in, a string out. No pdf library, no I/O, so the heuristics are tested on
5
+ * hand-made items and measured on real PDFs without a parser in the way.
6
+ *
7
+ * What it does, in order:
8
+ * 1. Items are grouped into ROWS by their baseline (y), left to right inside a row.
9
+ * 2. A page of two justified text columns (a paper) is split at its gutter, so the left column is read
10
+ * before the right one; rows that cross the gutter (title, abstract, figure captions) stay whole.
11
+ * 3. Inside a row, items that sit close together join into one CELL; a gap of about a em starts the next.
12
+ * 4. Consecutive rows with two or more cells whose cells line up in the same vertical channels become a
13
+ * Markdown table (first row is the header). Rows that do not line up stay plain lines, cells apart by
14
+ * two spaces, so nothing is invented and nothing is dropped.
15
+ *
16
+ * Measured against `pdftotext -layout` on public PDFs: docs/architecture/pdf-transcripts.md.
17
+ */
18
+ /** A gap wider than this many font sizes starts a new cell. */
19
+ const CELL_GAP_EM = 0.5;
20
+ /** A gap wider than this many font sizes between two items still needs a space when joining them. */
21
+ const WORD_GAP_EM = 0.15;
22
+ /** A vertical jump of more than this many font sizes between rows is a paragraph break (or the end of a table). */
23
+ const BREAK_EM = 1.9;
24
+ /** Rows of one table sit at most this many font sizes apart (booktabs rules add space around the header). */
25
+ const TABLE_ROW_EM = 3;
26
+ function median(values) {
27
+ const sorted = [...values].sort((a, b) => a - b);
28
+ return sorted.length === 0 ? 0 : sorted[Math.floor(sorted.length / 2)];
29
+ }
30
+ function toRows(items) {
31
+ const live = items.filter((item) => item.str.trim() !== "").sort((a, b) => b.y - a.y || a.x - b.x);
32
+ const rows = [];
33
+ for (const item of live) {
34
+ const last = rows[rows.length - 1];
35
+ if (last && Math.abs(last.y - item.y) <= Math.max(1.5, 0.5 * Math.min(last.size, item.size || last.size))) {
36
+ last.items.push(item);
37
+ last.size = Math.max(last.size, item.size);
38
+ }
39
+ else {
40
+ rows.push({ y: item.y, size: item.size || 10, items: [item], cells: [] });
41
+ }
42
+ }
43
+ const merged = attachScripts(rows);
44
+ for (const row of merged) {
45
+ row.items.sort((a, b) => a.x - b.x);
46
+ row.cells = cellsOf(row);
47
+ }
48
+ return merged;
49
+ }
50
+ /**
51
+ * A row of one or two tiny, smaller items right above or below a normal row is a superscript or subscript
52
+ * (`n·d²`), not a line of its own: it joins that row as `^2` or `_i`. Without this a table whose cells hold
53
+ * exponents is cut in pieces.
54
+ */
55
+ function attachScripts(rows) {
56
+ const out = [];
57
+ for (let at = 0; at < rows.length; at += 1) {
58
+ const row = rows[at];
59
+ const chars = row.items.reduce((sum, item) => sum + item.str.trim().length, 0);
60
+ const near = [rows[at - 1], rows[at + 1]]
61
+ .filter((other) => other !== undefined && row.size <= 0.85 * other.size && Math.abs(other.y - row.y) <= 0.85 * other.size)
62
+ .sort((a, b) => Math.abs(a.y - row.y) - Math.abs(b.y - row.y))[0];
63
+ if (!near || chars > 6 || row.items.length > 2) {
64
+ out.push(row);
65
+ continue;
66
+ }
67
+ for (const item of row.items)
68
+ near.items.push({ ...item, str: `${item.y > near.y ? "^" : "_"}${item.str.trim()}` });
69
+ }
70
+ return out.filter((row, index) => out.indexOf(row) === index);
71
+ }
72
+ function cellsOf(row) {
73
+ const cells = [];
74
+ let end = Number.NEGATIVE_INFINITY;
75
+ for (const item of row.items) {
76
+ const gap = item.x - end;
77
+ const last = cells[cells.length - 1];
78
+ const text = item.str.replace(/\s+/g, " ");
79
+ if (!last || gap > CELL_GAP_EM * row.size) {
80
+ cells.push({ text: text.trim(), x0: item.x, x1: item.x + item.width });
81
+ }
82
+ else {
83
+ const script = text.startsWith("^") || text.startsWith("_");
84
+ const spaced = !script && gap > WORD_GAP_EM * row.size && !last.text.endsWith(" ") && !text.startsWith(" ");
85
+ last.text = (last.text + (spaced ? " " : "") + text).replace(/\s+/g, " ");
86
+ last.x1 = item.x + item.width;
87
+ }
88
+ end = item.x + item.width;
89
+ }
90
+ for (const cell of cells)
91
+ cell.text = cell.text.replace(/\s*(?:\.\s?){4,}\s*/g, " … ").trim();
92
+ return cells.filter((cell) => cell.text !== "");
93
+ }
94
+ function percentile(values, fraction) {
95
+ const sorted = [...values].sort((a, b) => a - b);
96
+ return sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.floor(fraction * sorted.length))];
97
+ }
98
+ const spans = (item, x) => item.x < x - 2 && item.x + item.width > x + 2;
99
+ /**
100
+ * The x of a gutter that splits two justified text columns, or null. Rows that cross it (title, abstract,
101
+ * a wide figure or table) stay whole; the caller keeps them. Decided on items, not rows: the two columns'
102
+ * baselines rarely line up, so a "row" holds one side of the page at a time.
103
+ */
104
+ export function findGutter(rows) {
105
+ const items = rows.flatMap((row) => row.items);
106
+ if (items.length < 40)
107
+ return null;
108
+ const left = percentile(items.map((item) => item.x), 0.05);
109
+ const right = percentile(items.map((item) => item.x + item.width), 0.95);
110
+ const width = right - left;
111
+ if (width < 200)
112
+ return null;
113
+ const crossing = (x) => items.filter((item) => spans(item, x)).length;
114
+ let best = left + 0.4 * width;
115
+ for (let x = best; x <= left + 0.6 * width; x += 2)
116
+ if (crossing(x) < crossing(best))
117
+ best = x;
118
+ const floor = crossing(best);
119
+ if (floor > 0.04 * items.length)
120
+ return null;
121
+ let lo = best;
122
+ let hi = best;
123
+ while (crossing(lo - 1) <= floor && lo > left + 0.3 * width)
124
+ lo -= 1;
125
+ while (crossing(hi + 1) <= floor && hi < left + 0.7 * width)
126
+ hi += 1;
127
+ if (hi - lo < 6)
128
+ return null;
129
+ const gutter = (lo + hi) / 2;
130
+ // Both sides carry text, and the lines of each side run most of the way across their column (justified text).
131
+ const leftRows = rows.map((row) => row.items.filter((item) => item.x + item.width <= gutter)).filter((side) => side.length > 0);
132
+ const rightRows = rows.map((row) => row.items.filter((item) => item.x >= gutter)).filter((side) => side.length > 0);
133
+ if (leftRows.length < 8 || rightRows.length < 8)
134
+ return null;
135
+ const leftReach = median(leftRows.map((side) => Math.max(...side.map((item) => item.x + item.width)) - left));
136
+ const rightReach = median(rightRows.map((side) => Math.max(...side.map((item) => item.x + item.width)) - gutter));
137
+ if (leftReach < 0.8 * (lo - left) || rightReach < 0.8 * (right - hi))
138
+ return null;
139
+ return gutter;
140
+ }
141
+ function crosses(row, gutter) {
142
+ return row.items.some((item) => spans(item, gutter));
143
+ }
144
+ /** A section of rows read in order: whole rows, or left column then right column. */
145
+ function sections(rows, gutter) {
146
+ if (gutter === null)
147
+ return [rows];
148
+ const out = [];
149
+ let run = [];
150
+ const flush = () => {
151
+ if (run.length === 0)
152
+ return;
153
+ const leftItems = run.flatMap((row) => row.items.filter((item) => item.x + item.width / 2 < gutter));
154
+ const rightItems = run.flatMap((row) => row.items.filter((item) => item.x + item.width / 2 >= gutter));
155
+ out.push(toRows(leftItems), toRows(rightItems));
156
+ run = [];
157
+ };
158
+ for (const row of rows) {
159
+ if (crosses(row, gutter)) {
160
+ flush();
161
+ out.push([row]);
162
+ }
163
+ else {
164
+ run.push(row);
165
+ }
166
+ }
167
+ flush();
168
+ return out.filter((section) => section.length > 0);
169
+ }
170
+ /** Column channels of a block of columnar rows: the x ranges where cells of the rows with the most cells sit. */
171
+ function columnsOf(block) {
172
+ const most = median(block.map((row) => row.cells.length));
173
+ const spans = block
174
+ .filter((row) => row.cells.length >= Math.max(2, most))
175
+ .flatMap((row) => row.cells.map((cell) => [cell.x0, cell.x1]))
176
+ .sort((a, b) => a[0] - b[0]);
177
+ const pad = 0.3 * median(block.map((row) => row.size));
178
+ const columns = [];
179
+ for (const [x0, x1] of spans) {
180
+ const last = columns[columns.length - 1];
181
+ if (last && x0 <= last[1] + pad)
182
+ last[1] = Math.max(last[1], x1);
183
+ else
184
+ columns.push([x0, x1]);
185
+ }
186
+ return columns;
187
+ }
188
+ function columnIndex(columns, cell) {
189
+ const center = (cell.x0 + cell.x1) / 2;
190
+ let best = 0;
191
+ let bestDistance = Number.POSITIVE_INFINITY;
192
+ columns.forEach(([x0, x1], index) => {
193
+ const distance = center >= x0 && center <= x1 ? 0 : Math.min(Math.abs(center - x0), Math.abs(center - x1));
194
+ if (distance < bestDistance) {
195
+ best = index;
196
+ bestDistance = distance;
197
+ }
198
+ });
199
+ return best;
200
+ }
201
+ function markdownTable(block, columns) {
202
+ const lines = block.map((row) => {
203
+ const cells = Array.from({ length: columns.length }, () => "");
204
+ for (const cell of row.cells) {
205
+ const at = columnIndex(columns, cell);
206
+ cells[at] = cells[at] ? `${cells[at]} ${cell.text}` : cell.text;
207
+ }
208
+ return `| ${cells.map((text) => text.replace(/\|/g, "\\|")).join(" | ")} |`;
209
+ });
210
+ lines.splice(1, 0, `| ${columns.map(() => "---").join(" | ")} |`);
211
+ return lines.join("\n");
212
+ }
213
+ const plainLine = (row) => row.cells.map((cell) => cell.text).join(row.cells.length > 1 ? " " : " ");
214
+ /**
215
+ * A short one-cell row under a columnar row, with another columnar row after it, is the wrapped second line
216
+ * of one of that row's cells (a table header that wrapped): it joins that cell.
217
+ */
218
+ function foldContinuations(rows) {
219
+ const out = [];
220
+ for (let at = 0; at < rows.length; at += 1) {
221
+ const row = rows[at];
222
+ const above = out[out.length - 1];
223
+ const below = rows[at + 1];
224
+ if (above && above.cells.length >= 2 && below && below.cells.length >= 2 && row.cells.length === 1 && row.cells[0].text.length <= 24 && above.y - row.y <= TABLE_ROW_EM * row.size) {
225
+ const cell = row.cells[0];
226
+ const center = (cell.x0 + cell.x1) / 2;
227
+ const target = above.cells.find((candidate) => center >= candidate.x0 - 2 && center <= candidate.x1 + 2);
228
+ if (target) {
229
+ target.text = `${target.text} ${cell.text}`;
230
+ target.x0 = Math.min(target.x0, cell.x0);
231
+ target.x1 = Math.max(target.x1, cell.x1);
232
+ continue;
233
+ }
234
+ }
235
+ out.push(row);
236
+ }
237
+ return out;
238
+ }
239
+ function renderSection(input) {
240
+ const rows = foldContinuations(input);
241
+ const out = [];
242
+ let previous;
243
+ let at = 0;
244
+ while (at < rows.length) {
245
+ const row = rows[at];
246
+ if (previous && previous.y - row.y > BREAK_EM * Math.max(previous.size, row.size))
247
+ out.push("");
248
+ if (row.cells.length >= 2) {
249
+ // A block: consecutive columnar rows with no paragraph-sized jump between them.
250
+ let end = at + 1;
251
+ while (end < rows.length && rows[end].cells.length >= 2 && rows[end - 1].y - rows[end].y <= TABLE_ROW_EM * rows[end].size)
252
+ end += 1;
253
+ const block = rows.slice(at, end);
254
+ const columns = block.length >= 2 ? columnsOf(block) : [];
255
+ const fullRows = block.filter((member) => member.cells.length === columns.length).length;
256
+ if (columns.length >= 2 && fullRows >= 2) {
257
+ out.push(markdownTable(block, columns));
258
+ }
259
+ else {
260
+ for (const member of block)
261
+ out.push(plainLine(member));
262
+ }
263
+ previous = block[block.length - 1];
264
+ at = end;
265
+ continue;
266
+ }
267
+ if (row.cells.length === 1)
268
+ out.push(plainLine(row));
269
+ previous = row;
270
+ at += 1;
271
+ }
272
+ return out;
273
+ }
274
+ /** One page's items as text. Empty string when the page has no text. */
275
+ export function layoutPage(items) {
276
+ const rows = toRows(items);
277
+ if (rows.length === 0)
278
+ return "";
279
+ const gutter = findGutter(rows);
280
+ const lines = [];
281
+ for (const section of sections(rows, gutter)) {
282
+ if (lines.length > 0 && lines[lines.length - 1] !== "")
283
+ lines.push("");
284
+ lines.push(...renderSection(section));
285
+ }
286
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
287
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * One decoded PDF page image to a small grayscale PNG the image describe door takes (BLI-23347).
3
+ *
4
+ * A scanned PDF page is one big raster (a 7.4 MB scan decodes to 2550x3300 RGB, 25 MB). The describer
5
+ * takes at most 3 MB and reads text fine at about 1,600 px on the long side, so the raster is box-filtered
6
+ * down, turned to 8-bit grey and PNG-encoded, trying smaller sizes until it fits. Pure: pixels in, bytes
7
+ * out, no pdf library and no native dependency, so it runs the same on macOS and Windows.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import { deflateSync } from "node:zlib";
11
+ /** Long-side sizes tried in order; the first PNG under the byte limit wins. */
12
+ const LONG_SIDES = [1600, 1200, 900, 640];
13
+ const CRC_TABLE = (() => {
14
+ const table = new Uint32Array(256);
15
+ for (let n = 0; n < 256; n += 1) {
16
+ let c = n;
17
+ for (let k = 0; k < 8; k += 1)
18
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
19
+ table[n] = c >>> 0;
20
+ }
21
+ return table;
22
+ })();
23
+ function crc32(buffer) {
24
+ let c = 0xffffffff;
25
+ for (let i = 0; i < buffer.length; i += 1)
26
+ c = CRC_TABLE[(c ^ buffer[i]) & 0xff] ^ (c >>> 8);
27
+ return (c ^ 0xffffffff) >>> 0;
28
+ }
29
+ function chunk(type, body) {
30
+ const head = Buffer.alloc(8);
31
+ head.writeUInt32BE(body.length, 0);
32
+ head.write(type, 4, "latin1");
33
+ const crc = Buffer.alloc(4);
34
+ crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), body])), 0);
35
+ return Buffer.concat([head, body, crc]);
36
+ }
37
+ /** Grey level (0-255) of every pixel, one byte each. */
38
+ function toGrey(raster) {
39
+ const { width, height, kind, data } = raster;
40
+ const grey = new Uint8Array(width * height);
41
+ if (kind === 1) {
42
+ const stride = Math.ceil(width / 8);
43
+ for (let y = 0; y < height; y += 1) {
44
+ for (let x = 0; x < width; x += 1)
45
+ grey[y * width + x] = (data[y * stride + (x >> 3)] >> (7 - (x & 7))) & 1 ? 255 : 0;
46
+ }
47
+ return grey;
48
+ }
49
+ const step = kind === 2 ? 3 : 4;
50
+ for (let i = 0, p = 0; i < grey.length; i += 1, p += step) {
51
+ const luma = (data[p] * 299 + data[p + 1] * 587 + data[p + 2] * 114) / 1000;
52
+ // Transparent pixels read as paper, not as black.
53
+ const alpha = kind === 3 ? data[p + 3] / 255 : 1;
54
+ grey[i] = Math.round(luma * alpha + 255 * (1 - alpha));
55
+ }
56
+ return grey;
57
+ }
58
+ /** Box-filter `grey` (width x height) down to `toWidth` x `toHeight`. */
59
+ function shrink(grey, width, height, toWidth, toHeight) {
60
+ if (toWidth === width && toHeight === height)
61
+ return grey;
62
+ const out = new Uint8Array(toWidth * toHeight);
63
+ for (let y = 0; y < toHeight; y += 1) {
64
+ const y0 = Math.floor((y * height) / toHeight);
65
+ const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * height) / toHeight));
66
+ for (let x = 0; x < toWidth; x += 1) {
67
+ const x0 = Math.floor((x * width) / toWidth);
68
+ const x1 = Math.max(x0 + 1, Math.floor(((x + 1) * width) / toWidth));
69
+ let sum = 0;
70
+ for (let yy = y0; yy < y1; yy += 1)
71
+ for (let xx = x0; xx < x1; xx += 1)
72
+ sum += grey[yy * width + xx];
73
+ out[y * toWidth + x] = Math.round(sum / ((y1 - y0) * (x1 - x0)));
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+ /** An 8-bit grey PNG with the Sub filter on every row. */
79
+ export function encodeGreyPng(grey, width, height) {
80
+ const rows = Buffer.alloc((width + 1) * height);
81
+ for (let y = 0; y < height; y += 1) {
82
+ const at = y * (width + 1);
83
+ rows[at] = 1;
84
+ for (let x = 0; x < width; x += 1)
85
+ rows[at + 1 + x] = (grey[y * width + x] - (x > 0 ? grey[y * width + x - 1] : 0)) & 0xff;
86
+ }
87
+ const header = Buffer.alloc(13);
88
+ header.writeUInt32BE(width, 0);
89
+ header.writeUInt32BE(height, 4);
90
+ header[8] = 8; // bit depth
91
+ header[9] = 0; // grey
92
+ return Buffer.concat([
93
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
94
+ chunk("IHDR", header),
95
+ chunk("IDAT", deflateSync(rows, { level: 6 })),
96
+ chunk("IEND", Buffer.alloc(0)),
97
+ ]);
98
+ }
99
+ /** The raster as a PNG of at most `maxBytes`, or null when even the smallest size is too big. */
100
+ export function rasterToPagePng(raster, maxBytes) {
101
+ const { width, height } = raster;
102
+ if (!(width > 0 && height > 0) || raster.data.length < (raster.kind === 1 ? Math.ceil(width / 8) * height : width * height * (raster.kind === 2 ? 3 : 4)))
103
+ return null;
104
+ const grey = toGrey(raster);
105
+ for (const longSide of LONG_SIDES) {
106
+ const scale = Math.min(1, longSide / Math.max(width, height));
107
+ const toWidth = Math.max(1, Math.round(width * scale));
108
+ const toHeight = Math.max(1, Math.round(height * scale));
109
+ const bytes = encodeGreyPng(shrink(grey, width, height, toWidth, toHeight), toWidth, toHeight);
110
+ if (bytes.length <= maxBytes)
111
+ return { bytes, sha256: createHash("sha256").update(bytes).digest("hex"), width: toWidth, height: toHeight };
112
+ }
113
+ return null;
114
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * The PDF step of the transcript prepare path (BLI-23347): turn each base64 PDF into a marker block, in the
3
+ * same pass and under the same budgets as images. Drop mode only.
4
+ *
5
+ * The session (`image-description-session.ts`) holds the image state; this module plugs into it through ONE
6
+ * seam, `addPdfsToFinds`, which takes the images the session found and returns the same shape with the PDFs
7
+ * added:
8
+ *
9
+ * - every PDF occurrence is a stand-in `EmbeddedImage` carrying `replacement` (its marker text, set once the
10
+ * conversion is complete), so the swap in `replaceImagesWithMarkers` and `slimLineStream` needs no PDF code;
11
+ * - a scanned PDF's page images join `unique`, so the existing describe door, budgets, backoff and state do
12
+ * the describing; the PDF is replaced only when every page image has a description (an undescribed PDF is
13
+ * kept, exactly as an undescribed image is);
14
+ * - the FIRST copy of a PDF in the transcript gets the full marker block, every later copy a one-line reference.
15
+ *
16
+ * Conversion results are cached on disk by sha256 (`pdf-conversion-cache.ts`), so a transcript rebuilt every
17
+ * tick converts each PDF once. A PDF that cannot be converted (too big, the budget spent, unreadable) is left
18
+ * as it is and says why in the log; it is not counted, so it never keeps a transcript "pending" forever.
19
+ */
20
+ import { createHash } from "node:crypto";
21
+ import { formatImageMarker, formatPdfMarker, formatPdfReference } from "@bli-cockpit/telemetry-core";
22
+ import { convertPdf } from "./pdf-convert.js";
23
+ import { readCachedPdf, writeCachedPdf } from "./pdf-conversion-cache.js";
24
+ import { bytesMayHoldPdf, scanPdfsInLine } from "./pdf-byte-scan.js";
25
+ const TAG = "[pdf-step]";
26
+ /** Largest PDF decoded for conversion: the document, its pdf.js structures and one page raster must fit comfortably. */
27
+ export const MAX_PDF_BYTES = 25 * 1024 * 1024;
28
+ /** Time spent converting across one sync; later PDFs wait for the next tick (the cache keeps finished ones). */
29
+ export const MAX_PDF_RUN_MS = 60_000;
30
+ const BLOCK_CHARS = 48 * 1024;
31
+ const runStates = new WeakMap();
32
+ function runStateOf(key) {
33
+ let state = runStates.get(key);
34
+ if (!state) {
35
+ state = { spentMs: 0, records: new Map() };
36
+ runStates.set(key, state);
37
+ }
38
+ return state;
39
+ }
40
+ /** `text` in a JSON string that sits `depth` strings deep: one escaping pass per level, plus the string's own. */
41
+ function jsonEscaped(text, depth) {
42
+ let out = JSON.stringify(text).slice(1, -1);
43
+ if (depth === 1)
44
+ out = JSON.stringify(out).slice(1, -1);
45
+ return out;
46
+ }
47
+ /** The decoded bytes of a base64 run, in blocks, into one exact-size buffer. */
48
+ function decode(payload, byteSize) {
49
+ const out = Buffer.alloc(byteSize);
50
+ let at = 0;
51
+ const length = payload.length;
52
+ for (let from = 0; from < length; from += BLOCK_CHARS) {
53
+ const block = Buffer.from(typeof payload === "string" ? payload.slice(from, from + BLOCK_CHARS) : payload.toString("latin1", from, Math.min(length, from + BLOCK_CHARS)), "base64");
54
+ if (at + block.length > byteSize)
55
+ return null;
56
+ block.copy(out, at);
57
+ at += block.length;
58
+ }
59
+ return at === byteSize ? new Uint8Array(out.buffer, out.byteOffset, out.length) : null;
60
+ }
61
+ const prefix = (sha256) => sha256.slice(0, 16);
62
+ export async function planPdfs(found, ctx) {
63
+ const state = runStateOf(ctx.key);
64
+ const distinct = new Map();
65
+ const order = new Map();
66
+ for (const pdf of found)
67
+ order.set(pdf.sha256, [...(order.get(pdf.sha256) ?? []), pdf]);
68
+ const pageImages = new Map();
69
+ const payloads = new Map();
70
+ for (const [sha256, copies] of order) {
71
+ const first = copies[0];
72
+ const started = Date.now();
73
+ // A PDF given up on earlier in this sync is not tried again.
74
+ if (state.records.has(sha256) && state.records.get(sha256) === null)
75
+ continue;
76
+ let entry = state.records.get(sha256) ?? (await readCachedPdf(ctx.stateDir, sha256));
77
+ const cacheHit = entry !== null;
78
+ const needsPages = entry?.kind === "scanned" && entry.pageImages.some((page) => !ctx.isDescribed(page.sha256));
79
+ if (!entry || needsPages) {
80
+ const skip = first.byteSize > MAX_PDF_BYTES ? "pdf_too_large" : state.spentMs > MAX_PDF_RUN_MS ? "pdf_time_budget_spent" : null;
81
+ if (skip) {
82
+ console.error(TAG + " PDF left as it is", JSON.stringify({ reason: skip, sha256_prefix: prefix(sha256), byte_size: first.byteSize, copies: copies.length }));
83
+ state.records.set(sha256, null);
84
+ continue;
85
+ }
86
+ const bytes = decode(await ctx.readBase64(first), first.byteSize);
87
+ if (!bytes || createHash("sha256").update(bytes).digest("hex") !== sha256)
88
+ throw ctx.sourceChanged();
89
+ const converted = await convertPdf(bytes);
90
+ state.spentMs += Date.now() - started;
91
+ if (converted.kind === "failed") {
92
+ console.error(TAG + " PDF left as it is", JSON.stringify({ reason: converted.reason, sha256_prefix: prefix(sha256), byte_size: first.byteSize, pages: converted.pages, copies: copies.length }));
93
+ state.records.set(sha256, null);
94
+ continue;
95
+ }
96
+ if (converted.kind === "text") {
97
+ entry = { kind: "text", pages: converted.pages, notes: converted.notes, body: converted.body };
98
+ }
99
+ else if (converted.pageImages.length === 0) {
100
+ console.error(TAG + " PDF left as it is", JSON.stringify({ reason: "scanned_without_page_images", sha256_prefix: prefix(sha256), byte_size: first.byteSize, pages: converted.pages, copies: copies.length }));
101
+ state.records.set(sha256, null);
102
+ continue;
103
+ }
104
+ else {
105
+ entry = { kind: "scanned", pages: converted.pages, notes: converted.notes, pageImages: converted.pageImages.map(({ page, png }) => ({ page, sha256: png.sha256, byteSize: png.bytes.length })) };
106
+ for (const { png } of converted.pageImages) {
107
+ if (ctx.isDescribed(png.sha256) || payloads.has(png.sha256))
108
+ continue;
109
+ payloads.set(png.sha256, png.bytes.toString("base64"));
110
+ }
111
+ }
112
+ await writeCachedPdf(ctx.stateDir, sha256, entry);
113
+ }
114
+ if (!entry)
115
+ continue;
116
+ state.records.set(sha256, entry);
117
+ if (entry.kind === "scanned") {
118
+ for (const page of entry.pageImages) {
119
+ if (ctx.isDescribed(page.sha256) || !payloads.has(page.sha256))
120
+ continue;
121
+ pageImages.set(page.sha256, { replaceStart: -1, replaceEnd: -1, base64Start: -1, base64End: -1, sha256: page.sha256, mediaType: "image/png", byteSize: page.byteSize });
122
+ }
123
+ }
124
+ console.error(TAG + " PDF converted", JSON.stringify({ kind: entry.kind, sha256_prefix: prefix(sha256), byte_size: first.byteSize, pages: entry.pages, chars: entry.kind === "text" ? entry.body.length : null, page_images: entry.kind === "scanned" ? entry.pageImages.length : null, copies: copies.length, cache: cacheHit ? "hit" : "miss", elapsed_ms: Date.now() - started }));
125
+ distinct.set(sha256, { pdf: first, copies, entry });
126
+ }
127
+ const occurrences = [];
128
+ const standIns = new Map();
129
+ let counted = 0;
130
+ for (const pdf of found) {
131
+ if (!distinct.has(pdf.sha256))
132
+ continue;
133
+ const standIn = { replaceStart: pdf.replaceStart, replaceEnd: pdf.replaceEnd, base64Start: pdf.base64Start, base64End: pdf.base64End, sha256: pdf.sha256, mediaType: "image/png", byteSize: pdf.byteSize };
134
+ standIns.set(pdf, standIn);
135
+ occurrences.push(standIn);
136
+ counted += 1;
137
+ }
138
+ const settle = () => {
139
+ let replacedCount = 0;
140
+ let charsSaved = 0;
141
+ for (const { copies, entry } of distinct.values()) {
142
+ const ready = entry.kind === "text" || entry.pageImages.every((page) => ctx.isDescribed(page.sha256));
143
+ if (!ready)
144
+ continue;
145
+ const facts = { sha256: copies[0].sha256, pages: entry.pages, byteSize: copies[0].byteSize };
146
+ const full = entry.kind === "text"
147
+ ? formatPdfMarker({ ...facts, method: "text-layer", notes: entry.notes }, entry.body)
148
+ : formatPdfMarker({ ...facts, method: "page-images", notes: entry.notes }, entry.pageImages.map((page) => `page ${page.page}: ${formatImageMarker({ sha256: page.sha256, mediaType: "image/png", byteSize: page.byteSize })}`).join("\n"));
149
+ const reference = formatPdfReference(facts);
150
+ copies.forEach((pdf, index) => {
151
+ const standIn = standIns.get(pdf);
152
+ const text = index === 0 ? full : reference;
153
+ standIn.replacement = jsonEscaped(text, pdf.escapeDepth);
154
+ replacedCount += 1;
155
+ charsSaved += pdf.replaceEnd - pdf.replaceStart - standIn.replacement.length;
156
+ });
157
+ }
158
+ return { replacedCount, charsSaved };
159
+ };
160
+ return { occurrences, pageImages, payloads, counted, settle };
161
+ }
162
+ /**
163
+ * The session's finds with the PDFs added; `finds` itself when there are none. Call only in drop mode.
164
+ */
165
+ export async function addPdfsToFinds(finds, found, ctx) {
166
+ if (found.length === 0)
167
+ return finds;
168
+ const step = await planPdfs(found, ctx);
169
+ if (step.counted === 0)
170
+ return finds;
171
+ const images = finds.images ? [...finds.images, ...step.occurrences].sort((a, b) => a.replaceStart - b.replaceStart) : undefined;
172
+ return {
173
+ ...finds,
174
+ total: finds.total + step.counted,
175
+ unique: new Map([...finds.unique, ...step.pageImages]),
176
+ base64For: (image) => step.payloads.get(image.sha256) ?? finds.base64For(image),
177
+ swapped: (replace) => {
178
+ const base = finds.swapped(replace);
179
+ const pdf = step.settle();
180
+ return { replacedCount: base.replacedCount + pdf.replacedCount, charsSaved: base.charsSaved + pdf.charsSaved };
181
+ },
182
+ ...(images ? { images } : {}),
183
+ extraOccurrences: step.occurrences,
184
+ };
185
+ }
186
+ /** The PDFs of a whole transcript's text, with character offsets (the text path; files at the streaming size use the census). */
187
+ export function pdfsOfText(text) {
188
+ if (!text.includes("JVBERi0") && !text.includes("data:application/pdf"))
189
+ return [];
190
+ const bytes = Buffer.from(text, "utf8");
191
+ if (!bytesMayHoldPdf(bytes))
192
+ return [];
193
+ const byteOffsets = scanPdfsInLine(bytes);
194
+ const out = [];
195
+ let consumedBytes = 0;
196
+ let consumedChars = 0;
197
+ const charAt = (byteOffset) => {
198
+ consumedChars += bytes.toString("utf8", consumedBytes, byteOffset).length;
199
+ consumedBytes = byteOffset;
200
+ return consumedChars;
201
+ };
202
+ for (const pdf of byteOffsets) {
203
+ const replaceStart = charAt(pdf.replaceStart);
204
+ const base64Start = replaceStart + (pdf.base64Start - pdf.replaceStart);
205
+ const length = pdf.base64End - pdf.base64Start;
206
+ const replaceEnd = base64Start + length;
207
+ consumedBytes = pdf.replaceEnd;
208
+ consumedChars = replaceEnd;
209
+ out.push({ ...pdf, replaceStart, replaceEnd, base64Start, base64End: replaceEnd });
210
+ }
211
+ return out;
212
+ }
213
+ /** Merge two lists already sorted by `replaceStart`. */
214
+ export function mergeOccurrences(a, b) {
215
+ if (!b || b.length === 0)
216
+ return a;
217
+ return [...a, ...b].sort((x, y) => x.replaceStart - y.replaceStart);
218
+ }
@@ -84,7 +84,8 @@ export async function slimLineStream(lines, kind, occurrences, replace, sink) {
84
84
  const image = occurrences[next];
85
85
  if (image.replaceEnd > end)
86
86
  throw new TranscriptNotStreamable("images_changed_between_passes");
87
- if (replace(image))
87
+ // BLI-23347: a stand-in that carries its own replacement (a PDF) is always swapped.
88
+ if (image.replacement !== undefined || replace(image))
88
89
  chosen.push(image);
89
90
  }
90
91
  let line;
@@ -95,7 +96,7 @@ export async function slimLineStream(lines, kind, occurrences, replace, sink) {
95
96
  const pieces = [];
96
97
  let cursor = 0;
97
98
  for (const image of chosen) {
98
- const marker = formatImageMarker({ sha256: image.sha256, mediaType: image.mediaType, byteSize: image.byteSize });
99
+ const marker = image.replacement ?? formatImageMarker({ sha256: image.sha256, mediaType: image.mediaType, byteSize: image.byteSize });
99
100
  pieces.push(bytes.subarray(cursor, image.replaceStart - start), Buffer.from(marker, "utf8"));
100
101
  cursor = image.replaceEnd - start;
101
102
  replacedCount += 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.271",
3
+ "version": "0.2.272",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,9 +27,10 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.81",
31
- "@bli-cockpit/mcp": "0.1.165",
32
- "@bli-cockpit/telemetry-core": "0.1.88"
30
+ "@bli-cockpit/memory-mcp": "0.1.82",
31
+ "@bli-cockpit/mcp": "0.1.166",
32
+ "@bli-cockpit/telemetry-core": "0.1.89",
33
+ "unpdf": "1.8.1"
33
34
  },
34
35
  "repository": {
35
36
  "type": "git",