@hraness/wordcell 0.26.3 → 0.27.0

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.
Files changed (77) hide show
  1. package/README.md +36 -21
  2. package/dist/capture.js +15 -7
  3. package/dist/cli-bin.js +2 -2
  4. package/dist/cli.js +21 -19
  5. package/dist/clip/acquire.js +4 -3
  6. package/dist/clip/bundle-reader.js +3 -2
  7. package/dist/clip/cli.js +8 -7
  8. package/dist/clip/defuddle-worker.js +4 -1
  9. package/dist/clip/doctor.js +5 -4
  10. package/dist/clip/jobs.js +2 -1
  11. package/dist/clip/persist.js +2 -1
  12. package/dist/clip/refresh.js +4 -3
  13. package/dist/evaluation-builder-bin.js +2 -2
  14. package/dist/evaluation-builder.js +3 -3
  15. package/dist/evaluation-kb.js +3 -3
  16. package/dist/{index-mp5y3ty3.js → index-30v9w4cy.js} +36 -20
  17. package/dist/{index-9paxge84.js → index-3hts6yy9.js} +1 -1
  18. package/dist/{index-h9vyd03z.js → index-58azscg5.js} +7 -6
  19. package/dist/{index-9zkba8hr.js → index-7bqwzjba.js} +7 -6
  20. package/dist/{index-j4zgmzjr.js → index-8djdwjjd.js} +1 -1
  21. package/dist/{index-5h5awwh4.js → index-b40e2dkx.js} +2 -2
  22. package/dist/index-e2wstc7t.js +323 -0
  23. package/dist/{index-hacpnaew.js → index-fd8j527z.js} +1 -1
  24. package/dist/{index-mxxxytys.js → index-g63d3937.js} +4 -0
  25. package/dist/{index-k86wepd8.js → index-h2w7syy2.js} +5 -1
  26. package/dist/{index-9fywek7g.js → index-ht14xne3.js} +1 -1
  27. package/dist/{index-82r6g4py.js → index-k9yzc4vn.js} +1 -1
  28. package/dist/{index-m9p2a4mj.js → index-my50xsa3.js} +221 -36
  29. package/dist/{index-ncwzmsge.js → index-nwdzhj1h.js} +2 -2
  30. package/dist/{index-9rf81m0p.js → index-pbz3qw1s.js} +35 -20
  31. package/dist/index-pn14a2m5.js +59 -0
  32. package/dist/{index-nd8v7r0z.js → index-sp43012q.js} +1 -1
  33. package/dist/{index-8pabwzqg.js → index-t4hjm1kk.js} +2 -1
  34. package/dist/{index-npg9z1a4.js → index-xn5q4kne.js} +5 -1
  35. package/dist/{index-j2jt49gr.js → index-yatn62qq.js} +1 -1
  36. package/dist/index.js +14 -4
  37. package/dist/pdf.js +438 -4
  38. package/dist/portfolio.js +3 -3
  39. package/dist/rerank-clef.js +16 -0
  40. package/dist/rerank-typesafe.js +3 -1
  41. package/dist/sdk.js +2 -2
  42. package/dist/{support-9qdr4hb8.js → support-s059eytv.js} +20 -18
  43. package/dist/url-intelligence.js +4 -3
  44. package/dist/workflows/decision-context.js +3 -3
  45. package/dist/workflows/index.js +3 -3
  46. package/package.json +23 -9
  47. package/skills/wordcell/SKILL.md +1 -1
  48. package/skills/wordcell/references/query.md +11 -6
  49. package/src/capture.ts +5 -0
  50. package/src/cli-help.ts +2 -1
  51. package/src/cli-program.ts +23 -8
  52. package/src/clip/bundle-reader.ts +3 -0
  53. package/src/clip/capture.ts +1 -0
  54. package/src/clip/extract.ts +2 -0
  55. package/src/clip/lib.ts +3 -0
  56. package/src/clip/metadata-search-tool/Cargo.lock +6 -6
  57. package/src/clip/metadata.ts +232 -0
  58. package/src/clip/persist.ts +3 -0
  59. package/src/index.ts +2 -1
  60. package/src/pdf/bundle-reader.ts +457 -0
  61. package/src/pdf/capture.ts +6 -2
  62. package/src/pdf/cli.ts +2 -1
  63. package/src/pdf/errors.ts +11 -0
  64. package/src/pdf/extract.ts +16 -13
  65. package/src/pdf/layout.ts +136 -3
  66. package/src/pdf/markdown.ts +7 -1
  67. package/src/pdf/model.ts +9 -0
  68. package/src/pdf/persist.ts +8 -2
  69. package/src/pdf/source.ts +5 -4
  70. package/src/pdf/tools.ts +8 -6
  71. package/src/pdf.ts +10 -0
  72. package/src/rerank-clef.ts +52 -0
  73. package/src/rerank-credentials.ts +11 -2
  74. package/src/rerank-typesafe.ts +44 -20
  75. package/src/rerank.ts +1 -1
  76. package/src/sdk.ts +4 -2
  77. /package/dist/{index-cd75vky9.js → index-jbtyp6q5.js} +0 -0
package/src/pdf/layout.ts CHANGED
@@ -36,6 +36,7 @@ export type ParsedPopplerXml = {
36
36
  };
37
37
 
38
38
  export type PdfLayoutBlock =
39
+ | { readonly kind: "table"; readonly page: number; readonly rows: readonly (readonly string[])[] }
39
40
  | {
40
41
  readonly kind: "heading";
41
42
  readonly page: number;
@@ -456,7 +457,7 @@ export function layoutBlocks(pages: readonly PdfPageLayout[]): readonly PdfLayou
456
457
 
457
458
  for (const page of pages) {
458
459
  const lines = (linesByPage.get(page.page) ?? []).filter(
459
- (line) => !repeatedMargins.has(normalizedMarginText(line.text)),
460
+ (line) => !(line.top <= page.height * 0.06 || line.top + line.height >= page.height * 0.94) || !repeatedMargins.has(normalizedMarginText(line.text)),
460
461
  );
461
462
  const steps = lines.slice(1).map((line, index) =>
462
463
  Math.max(0, line.top - (lines[index]?.top ?? line.top)));
@@ -491,7 +492,7 @@ export function layoutBlocks(pages: readonly PdfPageLayout[]): readonly PdfLayou
491
492
  const { line, index } = event;
492
493
  const previous = lines[index - 1];
493
494
  const gapBefore = previous === undefined ? Number.POSITIVE_INFINITY : line.top - previous.top;
494
- const fontHeading = line.fontSize > bodySize * 1.08;
495
+ const fontHeading = line.fontSize > bodySize * 1.08 && !/[=∫∑√⁰¹²³⁴⁵⁶⁷⁸⁹]/u.test(line.text);
495
496
  const boldHeading = line.boldRatio >= 0.78
496
497
  && line.text.length <= 180
497
498
  && gapBefore >= normalStep * 1.45;
@@ -516,7 +517,7 @@ export function layoutBlocks(pages: readonly PdfPageLayout[]): readonly PdfLayou
516
517
  }
517
518
  flushParagraph();
518
519
  }
519
- return output.filter((block) => block.kind === "image" || block.text.trim() !== "");
520
+ return output.filter((block) => block.kind === "image" || block.kind === "table" || block.text.trim() !== "");
520
521
  }
521
522
 
522
523
  export function parsePdfImageInterpretations(value: unknown): readonly PdfImageInterpretation[] {
@@ -616,3 +617,135 @@ export function parsePdfImageInterpretations(value: unknown): readonly PdfImageI
616
617
  }
617
618
  return output;
618
619
  }
620
+
621
+ /** Geometry evidence is deliberately conservative: uncertain structure remains visible in quality. */
622
+ export function analyzePdfLayout(pages: readonly PdfPageLayout[]): { readonly blocks: readonly PdfLayoutBlock[]; readonly issues: readonly import("./model.js").PdfQualityIssue[] } {
623
+ const issues: import("./model.js").PdfQualityIssue[] = [];
624
+ const blocks: PdfLayoutBlock[] = [];
625
+ const marginLines = new Map(pages.filter(page => page.text.length <= 4000).map(page => [page.page, pageLines(page)]));
626
+ const margins = repeatedMarginLines(pages, marginLines);
627
+ for (const originalInput of pages) {
628
+ const original = { ...originalInput, text: originalInput.text.filter(fragment => !(fragment.top <= originalInput.height * 0.06 || fragment.top + fragment.height >= originalInput.height * 0.94) || !margins.has(normalizedMarginText(fragment.text))) };
629
+ const warn = (code: import("./model.js").PdfQualityIssue["code"]): void => {
630
+ if (!issues.some(issue => issue.page === original.page && issue.code === code)) issues.push({ page: original.page, code });
631
+ };
632
+ // Bound quadratic geometry analysis independently of the parser's byte limit.
633
+ if (original.text.length > 4000) {
634
+ warn("reading-order-uncertain"); blocks.push(...layoutBlocks([original])); continue;
635
+ }
636
+ if (original.text.some(fragment => /[∫∑√]/u.test(fragment.text))) warn("math-structure-uncertain");
637
+ const text = [...original.text].sort((a, b) => a.top - b.top || a.left - b.left);
638
+ const consumed = new Set<PdfTextFragment>();
639
+ const replacements = new Map<PdfTextFragment, PdfTextFragment>();
640
+ const superscripts: Record<string, string> = { "0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹", "+": "⁺", "-": "⁻", "=": "⁼", "(": "⁽", ")": "⁾", "n": "ⁿ", "i": "ⁱ" };
641
+ for (const small of text) {
642
+ const bases = text.filter(base => base !== small && base.fontSize >= small.fontSize * 1.3
643
+ && small.top < base.top && small.top + small.height > base.top - base.height * 0.25
644
+ && Math.abs(small.left - (base.left + base.width)) < base.fontSize * 0.6);
645
+ if (bases.length === 0) continue;
646
+ const base = bases[0]!;
647
+ const glyphs = [...small.text.trim()];
648
+ if (bases.length === 1 && glyphs.length > 0 && glyphs.length <= 8 && glyphs.every(glyph => superscripts[glyph] !== undefined) && !replacements.has(base)) {
649
+ replacements.set(base, { ...base, text: base.text + glyphs.map(glyph => superscripts[glyph]).join(""), width: small.left + small.width - base.left });
650
+ consumed.add(small);
651
+ } else warn("math-structure-uncertain");
652
+ }
653
+ const fragments = text.filter(fragment => !consumed.has(fragment)).map(fragment => replacements.get(fragment) ?? fragment);
654
+ const page: PdfPageLayout = { ...original, text: fragments };
655
+ const rows: PdfTextFragment[][] = [];
656
+ for (const fragment of fragments) {
657
+ const row = rows.at(-1);
658
+ if (row === undefined || Math.abs(fragment.top - row[0]!.top) > Math.max(2, fragment.height * 0.2)) rows.push([fragment]);
659
+ else row.push(fragment);
660
+ }
661
+ const cellRows = rows.map(row => {
662
+ const groups: PdfTextFragment[][] = [];
663
+ for (const fragment of [...row].sort((a, b) => a.left - b.left)) {
664
+ const prior = groups.at(-1), last = prior?.at(-1);
665
+ if (last === undefined || fragment.left - (last.left + last.width) > Math.max(12, fragment.fontSize * 1.5)) groups.push([fragment]);
666
+ else prior!.push(fragment);
667
+ }
668
+ return groups;
669
+ });
670
+ const tables: Array<{ top: number; bottom: number; block: PdfLayoutBlock; fragments: PdfTextFragment[] }> = [];
671
+ for (let index = 0; index < cellRows.length; index++) {
672
+ const first = cellRows[index]!;
673
+ if (first.length < 2 || first.length > 12) continue;
674
+ const group = [first];
675
+ let next = index + 1;
676
+ for (; next < cellRows.length; next++) {
677
+ const candidate = cellRows[next]!;
678
+ if (candidate.length !== first.length || candidate.some((cell, column) => Math.abs(cell[0]!.left - first[column]![0]!.left) > 4)) break;
679
+ if (candidate[0]![0]!.top - group.at(-1)![0]![0]!.top > Math.max(40, candidate[0]![0]!.height * 3)) break;
680
+ group.push(candidate);
681
+ }
682
+ const cells = group.map(row => row.map(cell => joinFragments(cell)));
683
+ const header = first.every(cell => cell.every(fragment => fragment.bold));
684
+ const numeric = cells.slice(1).some(row => row.slice(1).some(cell => /^[-+\d$€£]/u.test(cell.trim())));
685
+ const compact = cells.every(row => row.every(cell => cell.length <= 100));
686
+ if (group.length >= 3 && compact && header && numeric) {
687
+ const members = group.flat(2);
688
+ tables.push({ top: Math.min(...members.map(fragment => fragment.top)), bottom: Math.max(...members.map(fragment => fragment.top + fragment.height)), block: { kind: "table", page: page.page, rows: cells }, fragments: members });
689
+ index = next - 1;
690
+ } else if (group.length >= 3 && first.length >= 3) warn("table-structure-uncertain");
691
+ }
692
+ const tableFragments = new Set(tables.flatMap(table => table.fragments));
693
+ const remaining = fragments.filter(fragment => !tableFragments.has(fragment));
694
+ const emitBand = (band: readonly PdfTextFragment[], images: readonly PdfImageCandidate[]): void => {
695
+ if (band.length === 0 && images.length === 0) return;
696
+ // Repeated gutters are detected before line grouping can join adjacent columns.
697
+ const candidates = new Set<number>();
698
+ for (const row of cellRows) {
699
+ for (let i = 1; i < row.length; i++) {
700
+ const left = row[i - 1]!.at(-1)!, right = row[i]![0]!;
701
+ const gap = right.left - (left.left + left.width);
702
+ if (gap >= Math.max(page.width * 0.025, left.fontSize * 1.5)) candidates.add(Math.round((left.left + left.width + right.left) / 2));
703
+ }
704
+ }
705
+ const valid = [...candidates].filter(gutter => {
706
+ const left = band.filter(fragment => fragment.left + fragment.width <= gutter);
707
+ const right = band.filter(fragment => fragment.left >= gutter);
708
+ if (left.length + right.length !== band.length || left.length < 3 || right.length < 3) return false;
709
+ if (left.filter(fragment => fragment.width > page.width * 0.18).length < 2 || right.filter(fragment => fragment.width > page.width * 0.18).length < 2) return false;
710
+ const overlap = Math.min(Math.max(...left.map(fragment => fragment.top)), Math.max(...right.map(fragment => fragment.top))) - Math.max(Math.min(...left.map(fragment => fragment.top)), Math.min(...right.map(fragment => fragment.top)));
711
+ return overlap > Math.max(...band.map(fragment => fragment.height)) * 1.5;
712
+ });
713
+ if (valid.length > 0) {
714
+ const gutter = valid[Math.floor(valid.length / 2)]!;
715
+ if (images.some(image => image.left < gutter && image.left + image.width > gutter)) {
716
+ warn("reading-order-uncertain"); blocks.push(...layoutBlocks([{ ...page, text: band, images }])); return;
717
+ }
718
+ for (const left of [true, false]) blocks.push(...layoutBlocks([{ ...page, text: band.filter(fragment => left ? fragment.left + fragment.width <= gutter : fragment.left >= gutter), images: images.filter(image => left ? image.left + image.width <= gutter : image.left >= gutter) }]));
719
+ } else {
720
+ if (band.length >= 6 && cellRows.filter(row => row.length >= 2 && row.some(cell => band.includes(cell[0]!))).length >= 3) warn("reading-order-uncertain");
721
+ blocks.push(...layoutBlocks([{ ...page, text: band, images }]));
722
+ }
723
+ };
724
+ // Spanning headings split vertical regions; tables retain their exact place.
725
+ const bodyFont = weightedBodyFontSize(pageLines(page));
726
+ const gutterVotes = new Map<number, number>();
727
+ for (const row of cellRows) for (let i = 1; i < row.length; i++) {
728
+ const left = row[i - 1]!.at(-1)!, right = row[i]![0]!;
729
+ if (right.left - (left.left + left.width) < Math.max(12, left.fontSize * 1.5)) continue;
730
+ const gutter = Math.round((left.left + left.width + right.left) / 20) * 10;
731
+ gutterVotes.set(gutter, (gutterVotes.get(gutter) ?? 0) + 1);
732
+ }
733
+ const repeatedGutters = [...gutterVotes].filter(([, votes]) => votes >= 3).map(([gutter]) => gutter);
734
+ const spans = remaining.filter(fragment => fragment.width > page.width * 0.65 || (fragment.fontSize > bodyFont * 1.08 && repeatedGutters.some(gutter => fragment.left < gutter && fragment.left + fragment.width > gutter)));
735
+ const boundaries = [...tables.map(table => ({ top: table.top, bottom: table.bottom, table, span: null as PdfTextFragment | null })), ...spans.map(span => ({ top: span.top, bottom: span.top + span.height, table: null, span }))].sort((a, b) => a.top - b.top);
736
+ const emitted = new Set<PdfTextFragment>(), emittedImages = new Set<PdfImageCandidate>();
737
+ for (const boundary of boundaries) {
738
+ const band = remaining.filter(fragment => !emitted.has(fragment) && fragment !== boundary.span && fragment.top < boundary.top);
739
+ const images = page.images.filter(image => !emittedImages.has(image) && image.top < boundary.top);
740
+ emitBand(band, images); for (const fragment of band) emitted.add(fragment); for (const image of images) emittedImages.add(image);
741
+ if (boundary.table !== null) blocks.push(boundary.table.block);
742
+ if (boundary.span !== null) {
743
+ const span = boundary.span;
744
+ blocks.push(span.fontSize > bodyFont * 1.08 ? { kind: "heading", page: page.page, level: 2, text: span.text } : { kind: "paragraph", page: page.page, text: span.text });
745
+ emitted.add(span);
746
+ }
747
+ }
748
+ emitBand(remaining.filter(fragment => !emitted.has(fragment)), page.images.filter(image => !emittedImages.has(image)));
749
+ }
750
+ return { blocks, issues };
751
+ }
@@ -24,7 +24,7 @@ export type ResolvedPdfImage = {
24
24
  function escapeInline(value: string): string {
25
25
  return sanitizeTerminalText(value)
26
26
  .replace(/\\/gu, "\\\\")
27
- .replace(/([`*_[\]{}<>#])/gu, "\\$1")
27
+ .replace(/([`*_[\]{}<>#|])/gu, "\\$1")
28
28
  .replace(/\s+/gu, " ")
29
29
  .trim();
30
30
  }
@@ -81,6 +81,12 @@ function markdownContent(
81
81
  lines.push(imageMarkdown(resolved), "");
82
82
  continue;
83
83
  }
84
+ if (block.kind === "table") {
85
+ textBlockCount += 1;
86
+ const rows = block.rows.map(row => `| ${row.map(cell => escapeInline(cell)).join(" | ")} |`);
87
+ if (rows.length > 0) lines.push(rows[0]!, `| ${block.rows[0]!.map(() => "---").join(" | ")} |`, ...rows.slice(1), "");
88
+ continue;
89
+ }
84
90
  if (block.kind === "heading") {
85
91
  headingCount += 1;
86
92
  lines.push(`${"#".repeat(Math.max(2, Math.min(6, block.level)))} ${escapeInline(block.text)}`, "");
package/src/pdf/model.ts CHANGED
@@ -126,7 +126,14 @@ export type PdfManifestImage = PdfBounds & {
126
126
  readonly metadata: PdfImageSemanticMetadata | null;
127
127
  };
128
128
 
129
+ export type PdfQualityIssue = {
130
+ readonly code: "reading-order-uncertain" | "table-structure-uncertain" | "math-structure-uncertain" | "ocr-needed";
131
+ readonly page: number;
132
+ };
133
+
129
134
  export type PdfCaptureManifest = {
135
+ readonly markdown?: { readonly path: string; readonly bytes: number; readonly sha256: string };
136
+ readonly quality?: { readonly schemaVersion: 1; readonly issues: readonly PdfQualityIssue[] };
130
137
  readonly schemaVersion: typeof PDF_CAPTURE_MANIFEST_SCHEMA_VERSION;
131
138
  readonly kind: "pdf";
132
139
  readonly capturedAt: string;
@@ -204,6 +211,8 @@ export type PdfCaptureOutcome = {
204
211
  };
205
212
 
206
213
  export type PdfToolCommand = {
214
+ /** An external supervisor may own the inherited group and its final cleanup. */
215
+ readonly processGroup?: "new" | "inherit";
207
216
  readonly command: readonly string[];
208
217
  readonly timeoutMs: number;
209
218
  readonly maxOutputBytes: number;
@@ -212,9 +212,15 @@ export function persistPdfCapture(input: PersistPdfCaptureInput): string {
212
212
  chmodSync(destination, 0o644);
213
213
  }
214
214
 
215
+ const markdownBytes = `${redactSensitiveText(input.markdown).trimEnd()}\n`;
216
+ const markdownDescriptor = { path: `${slug}.md`, bytes: Buffer.byteLength(markdownBytes), sha256: createHash("sha256").update(markdownBytes).digest("hex") };
217
+ if (input.manifest.markdown !== undefined && (input.manifest.markdown.path !== markdownDescriptor.path || input.manifest.markdown.bytes !== markdownDescriptor.bytes || input.manifest.markdown.sha256 !== markdownDescriptor.sha256)) {
218
+ throw new Error("PDF Markdown does not match its manifest provenance");
219
+ }
220
+ const manifest = { ...input.manifest, markdown: markdownDescriptor };
215
221
  writeFileSync(
216
222
  join(stagingDirectory, `${slug}.md`),
217
- `${redactSensitiveText(input.markdown).trimEnd()}\n`,
223
+ markdownBytes,
218
224
  { encoding: "utf8", flag: "wx", mode: 0o644 },
219
225
  );
220
226
  if (input.manifest.annotations === null) {
@@ -253,7 +259,7 @@ export function persistPdfCapture(input: PersistPdfCaptureInput): string {
253
259
  }
254
260
  writeFileSync(
255
261
  join(stagingDirectory, PDF_CAPTURE_MANIFEST_FILENAME),
256
- `${JSON.stringify(input.manifest, null, 2)}\n`,
262
+ `${JSON.stringify(manifest, null, 2)}\n`,
257
263
  { encoding: "utf8", flag: "wx", mode: 0o644 },
258
264
  );
259
265
  assertStagingTreeSafe(stagingDirectory);
package/src/pdf/source.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { PdfCaptureError } from "./errors.js";
1
2
  import {
2
3
  chmodSync,
3
4
  mkdtempSync,
@@ -37,13 +38,13 @@ function parseRemoteUrl(input: string): URL | null {
37
38
  try {
38
39
  url = new URL(input);
39
40
  } catch {
40
- throw new Error("PDF URL is invalid");
41
+ throw new PdfCaptureError("invalid-input", "PDF URL is invalid");
41
42
  }
42
43
  if (url.protocol !== "http:" && url.protocol !== "https:") {
43
- throw new Error("PDF URL must use HTTP or HTTPS");
44
+ throw new PdfCaptureError("invalid-input", "PDF URL must use HTTP or HTTPS");
44
45
  }
45
46
  if (url.username !== "" || url.password !== "") {
46
- throw new Error("PDF URL must not contain embedded credentials");
47
+ throw new PdfCaptureError("invalid-input", "PDF URL must not contain embedded credentials");
47
48
  }
48
49
  return url;
49
50
  }
@@ -69,7 +70,7 @@ function assertPdfSignature(bytes: Uint8Array): void {
69
70
  bytes.byteLength < 5
70
71
  || new TextDecoder().decode(bytes.subarray(0, 5)) !== "%PDF-"
71
72
  ) {
72
- throw new Error("remote PDF input does not have a valid PDF signature");
73
+ throw new PdfCaptureError("invalid-input", "remote PDF input does not have a valid PDF signature");
73
74
  }
74
75
  }
75
76
 
package/src/pdf/tools.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { PdfCaptureError } from "./errors.js";
1
2
  import { spawn } from "node:child_process";
2
3
  import { existsSync } from "node:fs";
3
4
  import { homedir } from "node:os";
@@ -26,7 +27,7 @@ async function readBoundedStream(stream: NodeJS.ReadableStream, maxBytes: number
26
27
  if (typeof value === "string") chunk = new TextEncoder().encode(value);
27
28
  else if (value instanceof Uint8Array) chunk = value;
28
29
  else throw new Error("PDF tool returned an unsupported output chunk");
29
- if (!bytes.append(chunk)) throw new Error(`PDF tool output exceeded ${maxBytes} bytes`);
30
+ if (!bytes.append(chunk)) throw new PdfCaptureError("limit-exceeded", `PDF tool output exceeded ${maxBytes} bytes`);
30
31
  }
31
32
  return new TextDecoder().decode(bytes.toUint8Array());
32
33
  }
@@ -37,7 +38,8 @@ export const runPdfToolCommand: PdfToolRunner = async (
37
38
  ): Promise<PdfToolCommandResult> => {
38
39
  const executable = specification.command[0];
39
40
  if (executable === undefined) throw new Error("PDF tool command is empty");
40
- const useProcessGroup = process.platform !== "win32";
41
+ if (specification.processGroup !== undefined && !["new", "inherit"].includes(specification.processGroup)) throw new PdfCaptureError("invalid-input", "Invalid PDF process group policy");
42
+ const useProcessGroup = process.platform !== "win32" && specification.processGroup !== "inherit";
41
43
  const child = spawn(executable, specification.command.slice(1), {
42
44
  cwd: specification.cwd,
43
45
  detached: useProcessGroup,
@@ -51,7 +53,7 @@ export const runPdfToolCommand: PdfToolRunner = async (
51
53
  windowsHide: true,
52
54
  });
53
55
  const exited = new Promise<number>((resolveExit, rejectExit) => {
54
- child.once("error", rejectExit);
56
+ child.once("error", (error: NodeJS.ErrnoException) => rejectExit(error.code === "ENOENT" ? new PdfCaptureError("tool-unavailable", "PDF tool executable is unavailable", { cause: error }) : error));
55
57
  child.once("close", (code) => resolveExit(code ?? 1));
56
58
  });
57
59
  const signalProcessTree = (signal: NodeJS.Signals): void => {
@@ -79,7 +81,7 @@ export const runPdfToolCommand: PdfToolRunner = async (
79
81
  forceKillTimer = setTimeout(() => signalProcessTree("SIGKILL"), 1_000);
80
82
  };
81
83
  const timer = setTimeout(() => {
82
- requestStop(new Error(`PDF tool timed out after ${specification.timeoutMs}ms`));
84
+ requestStop(new PdfCaptureError("timeout", `PDF tool timed out after ${specification.timeoutMs}ms`));
83
85
  }, specification.timeoutMs);
84
86
 
85
87
  try {
@@ -137,10 +139,10 @@ export function resolvePdfTools(dependencies: PdfCaptureDependencies = {}): PdfT
137
139
  ? discoverExecutable("tesseract", dependencies)
138
140
  : dependencies.tools.tesseract;
139
141
  if (pdfinfo === null) {
140
- throw new Error("pdfinfo is required for PDF ingestion; install the Poppler command-line tools");
142
+ throw new PdfCaptureError("tool-unavailable", "pdfinfo is required for PDF ingestion; install the Poppler command-line tools");
141
143
  }
142
144
  if (pdftohtml === null) {
143
- throw new Error("pdftohtml is required for PDF ingestion; install the Poppler command-line tools");
145
+ throw new PdfCaptureError("tool-unavailable", "pdftohtml is required for PDF ingestion; install the Poppler command-line tools");
144
146
  }
145
147
  return { pdfinfo, pdftohtml, tesseract };
146
148
  }
package/src/pdf.ts CHANGED
@@ -90,3 +90,13 @@ export type {
90
90
  PdfToolPaths,
91
91
  PdfToolRunner,
92
92
  } from "./pdf/model.js";
93
+
94
+ import { inspectPdfCaptureBundle as inspectPdfCaptureBundleImplementation, verifyPdfCaptureBundle as verifyPdfCaptureBundleImplementation } from "./pdf/bundle-reader.js";
95
+ import { PdfCaptureError as PdfCaptureErrorImplementation } from "./pdf/errors.js";
96
+ export const inspectPdfCaptureBundle: typeof inspectPdfCaptureBundleImplementation = (...arguments_) => inspectPdfCaptureBundleImplementation(...arguments_);
97
+ export const verifyPdfCaptureBundle: typeof verifyPdfCaptureBundleImplementation = (...arguments_) => verifyPdfCaptureBundleImplementation(...arguments_);
98
+ export const PdfCaptureError = PdfCaptureErrorImplementation;
99
+ export type PdfCaptureError = InstanceType<typeof PdfCaptureErrorImplementation>;
100
+ export type { PdfCaptureErrorCode } from "./pdf/errors.js";
101
+ export type { PdfBundleInspection, PdfBundleVerification, ReadPdfCaptureBundleOptions, PdfBundleIntegrity } from "./pdf/bundle-reader.js";
102
+ export type { PdfQualityIssue } from "./pdf/model.js";
@@ -0,0 +1,52 @@
1
+ import type { SearchReranker } from "./rerank.js";
2
+ import { createHostedReranker, type SystemOneTransport, type TypeSafeRerankerOptions } from "./rerank-typesafe.js";
3
+
4
+ export const CLEF_MODELS = ["clef", "clef-flash"] as const;
5
+ export type ClefModel = (typeof CLEF_MODELS)[number];
6
+ export const DEFAULT_CLEF_MODEL: ClefModel = "clef";
7
+ export type ClefTransport = SystemOneTransport;
8
+ export type ClefRerankerOptions = TypeSafeRerankerOptions & { readonly model?: ClefModel };
9
+
10
+ export function clefEndpoint(accountId: string, model: ClefModel = DEFAULT_CLEF_MODEL): string {
11
+ if (typeof accountId !== "string" || !/^[a-f0-9]{32}$/u.test(accountId) || !CLEF_MODELS.includes(model)) {
12
+ throw new Error("Invalid Cloudflare Clef account or model.");
13
+ }
14
+ return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/@cf/cloudflare/${model}`;
15
+ }
16
+
17
+ function unwrapClefResponse(value: unknown): unknown {
18
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
19
+ const envelope = value as Record<string, unknown>;
20
+ if (envelope["success"] !== true || !Object.hasOwn(envelope, "result")
21
+ || (envelope["errors"] !== undefined && (!Array.isArray(envelope["errors"]) || envelope["errors"].length !== 0))) return null;
22
+ return envelope["result"];
23
+ }
24
+
25
+ export function createClefReranker(options: ClefRerankerOptions = {}): SearchReranker {
26
+ const environment = options.environment ?? process.env;
27
+ const accountId = Object.hasOwn(environment, "CLOUDFLARE_ACCOUNT_ID") ? environment["CLOUDFLARE_ACCOUNT_ID"] : undefined;
28
+ const apiKey = Object.hasOwn(environment, "CLOUDFLARE_API_TOKEN")
29
+ ? environment["CLOUDFLARE_API_TOKEN"]
30
+ : Object.hasOwn(environment, "CLOUDFLARE_AUTH_TOKEN") ? environment["CLOUDFLARE_AUTH_TOKEN"] : undefined;
31
+ const model = options.model === undefined ? DEFAULT_CLEF_MODEL : options.model;
32
+ let endpoint: string | undefined;
33
+ try { if (accountId !== undefined) endpoint = clefEndpoint(accountId, model); } catch { endpoint = undefined; }
34
+ const reranker = createHostedReranker(options, {
35
+ id: "clef", label: "Cloudflare Clef", model, endpoint, apiKey,
36
+ unavailableMessage: "Cloudflare Clef credentials or model are missing or invalid; set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN.",
37
+ unwrap: unwrapClefResponse,
38
+ });
39
+ return {
40
+ id: reranker.id,
41
+ rerank: async (request) => {
42
+ try {
43
+ if (typeof request === "object" && request !== null && "images" in request) {
44
+ return { status: "failed", message: "Wordcell reranking accepts text only; image evidence is not supported." };
45
+ }
46
+ return await reranker.rerank(request);
47
+ } catch {
48
+ return { status: "failed", message: "Cloudflare Clef rerank request was malformed or failed." };
49
+ }
50
+ },
51
+ };
52
+ }
@@ -4,9 +4,10 @@ import { homedir } from "node:os";
4
4
  import { isAbsolute, join } from "node:path";
5
5
  import type { SearchReranker } from "./rerank.js";
6
6
  import { createTypeSafeReranker } from "./rerank-typesafe.js";
7
+ import { createClefReranker, type ClefModel } from "./rerank-clef.js";
7
8
 
8
- function unavailable(message: string): SearchReranker {
9
- return { id: "typesafe", rerank: async () => ({ status: "unavailable", message }) };
9
+ function unavailable(message: string, id = "typesafe"): SearchReranker {
10
+ return { id, rerank: async () => ({ status: "unavailable", message }) };
10
11
  }
11
12
 
12
13
  /** CLI-only credential discovery, called only after explicit --rerank typesafe. */
@@ -54,3 +55,11 @@ export async function createCliTypeSafeReranker(
54
55
  await handle?.close();
55
56
  }
56
57
  }
58
+
59
+ export async function createCliClefReranker(
60
+ environment: Readonly<Record<string, string | undefined>> = process.env,
61
+ _homeDirectory: string = homedir(),
62
+ model: ClefModel = "clef",
63
+ ): Promise<SearchReranker> {
64
+ return createClefReranker({ environment, model });
65
+ }
@@ -154,11 +154,11 @@ function tokenCount(value: unknown): number | null {
154
154
  : null;
155
155
  }
156
156
 
157
- function acceptedResponseModel(value: unknown): value is string {
158
- return value === DEFAULT_SYSTEMONE_MODEL;
157
+ function acceptedResponseModel(value: unknown, model: string): value is string {
158
+ return value === model;
159
159
  }
160
160
 
161
- function parseSystemOneResponse(value: unknown): ParsedSystemOneAnswer | null {
161
+ function parseSystemOneResponse(value: unknown, model: string): ParsedSystemOneAnswer | null {
162
162
  const root = recordValue(value);
163
163
  if (root === null || !hasExactKeys(root, ["model", "answers", "usage"])) return null;
164
164
  const answers = recordValue(root["answers"]);
@@ -176,7 +176,7 @@ function parseSystemOneResponse(value: unknown): ParsedSystemOneAnswer | null {
176
176
  const inputTokens = tokenCount(usage["input_tokens"]);
177
177
  const outputTokens = tokenCount(usage["output_tokens"]);
178
178
  if (
179
- !acceptedResponseModel(root["model"])
179
+ !acceptedResponseModel(root["model"], model)
180
180
  || inputTokens === null
181
181
  || outputTokens === null
182
182
  ) return null;
@@ -205,7 +205,7 @@ async function mapWithConcurrency<T, R>(
205
205
  }
206
206
 
207
207
  function validApiKey(value: string | undefined): value is string {
208
- return value !== undefined && /^[\x21-\x7e]{1,512}$/u.test(value);
208
+ return typeof value === "string" && /^[\x21-\x7e]{1,512}$/u.test(value);
209
209
  }
210
210
 
211
211
  function boundedPositiveInteger(
@@ -274,6 +274,30 @@ type ScoredCandidate =
274
274
  */
275
275
  export function createTypeSafeReranker(
276
276
  options: TypeSafeRerankerOptions = {},
277
+ ): SearchReranker {
278
+ const environment = options.environment ?? process.env;
279
+ return createHostedReranker(options, {
280
+ id: "typesafe", label: "TypeSafe", model: DEFAULT_SYSTEMONE_MODEL,
281
+ endpoint: DEFAULT_SYSTEMONE_ENDPOINT,
282
+ apiKey: Object.hasOwn(environment, "TYPESAFE_API_KEY") ? environment["TYPESAFE_API_KEY"] : undefined,
283
+ unavailableMessage: "TYPESAFE_API_KEY is missing or invalid; the TypeSafe rerank lane is unavailable.",
284
+ unwrap: (value) => value,
285
+ });
286
+ }
287
+
288
+ export type HostedRerankProvider = {
289
+ readonly id: string;
290
+ readonly label: string;
291
+ readonly model: string;
292
+ readonly endpoint: string | undefined;
293
+ readonly apiKey: string | undefined;
294
+ readonly unavailableMessage: string;
295
+ readonly unwrap: (value: unknown) => unknown;
296
+ };
297
+
298
+ export function createHostedReranker(
299
+ options: TypeSafeRerankerOptions,
300
+ provider: HostedRerankProvider,
277
301
  ): SearchReranker {
278
302
  const transport = options.transport ?? fetchSystemOneTransport;
279
303
  const timeoutMs = boundedPositiveInteger(
@@ -291,13 +315,10 @@ export function createTypeSafeReranker(
291
315
  DEFAULT_RERANK_CONCURRENCY,
292
316
  MAX_RERANK_CONCURRENCY,
293
317
  );
294
- const environment = options.environment ?? process.env;
295
- const apiKey = Object.hasOwn(environment, "TYPESAFE_API_KEY")
296
- ? environment["TYPESAFE_API_KEY"]
297
- : undefined;
318
+ const apiKey = provider.apiKey;
298
319
 
299
320
  return {
300
- id: "typesafe",
321
+ id: provider.id,
301
322
  rerank: async (request: SearchRerankRequest): Promise<SearchRerankResult> => {
302
323
  const started = performance.now();
303
324
  const controller = new AbortController();
@@ -330,7 +351,7 @@ export function createTypeSafeReranker(
330
351
  }),
331
352
  };
332
353
  const failed = (message: string): SearchRerankResult => ({
333
- status: "failed", message, ...details(),
354
+ status: "failed", message: message.replaceAll("TypeSafe", provider.label), ...details(),
334
355
  });
335
356
  try {
336
357
  if (timeoutMs === null || maxResponseBytes === null || concurrency === null) {
@@ -344,8 +365,8 @@ export function createTypeSafeReranker(
344
365
  if (callerSignal?.aborted === true) {
345
366
  return failed("TypeSafe rerank request was aborted.");
346
367
  }
347
- if (!validApiKey(apiKey)) {
348
- return { status: "unavailable", message: "TYPESAFE_API_KEY is missing or invalid; the TypeSafe rerank lane is unavailable." };
368
+ if (!validApiKey(apiKey) || provider.endpoint === undefined) {
369
+ return { status: "unavailable", message: provider.unavailableMessage };
349
370
  }
350
371
  if (candidateCount === 0) return { status: "ready", ordering: [], probabilities: {} };
351
372
  const encoder = new TextEncoder();
@@ -359,12 +380,14 @@ export function createTypeSafeReranker(
359
380
  if (encoder.encode(JSON.stringify(state)).byteLength > MAX_RERANK_STATE_BYTES) {
360
381
  return failed(`TypeSafe rerank state exceeds the ${MAX_RERANK_STATE_BYTES}-byte limit.`);
361
382
  }
362
- bodies.push(encoder.encode(JSON.stringify({
363
- model: DEFAULT_SYSTEMONE_MODEL, state,
383
+ const body = encoder.encode(JSON.stringify({
384
+ model: provider.model, state,
364
385
  questions: { [RELEVANCE_QUESTION]: {
365
386
  type: "noul", instructions: RELEVANCE_INSTRUCTIONS, criteria: RELEVANCE_CRITERIA,
366
387
  } },
367
- })));
388
+ }));
389
+ if (body.byteLength > 32 * 1_024) return failed("TypeSafe rerank body exceeds the 32768-byte limit.");
390
+ bodies.push(body);
368
391
  }
369
392
  const deadline = started + timeoutMs;
370
393
  const timeoutMessage = "TypeSafe rerank window exceeded its deadline.";
@@ -383,7 +406,7 @@ export function createTypeSafeReranker(
383
406
  let response: { readonly status: number; readonly body: Uint8Array };
384
407
  try {
385
408
  response = await transport({
386
- url: DEFAULT_SYSTEMONE_ENDPOINT, body,
409
+ url: provider.endpoint!, body,
387
410
  headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
388
411
  timeoutMs: Math.max(1, Math.ceil(deadline - performance.now())),
389
412
  maxResponseBytes, signal: controller.signal,
@@ -412,7 +435,8 @@ export function createTypeSafeReranker(
412
435
  return { ok: false, message: firstFailure! };
413
436
  }
414
437
  // Billing evidence is independent of whether the relevance answer is usable.
415
- const root = recordValue(parsed);
438
+ const result = provider.unwrap(parsed);
439
+ const root = recordValue(result);
416
440
  const usage = recordValue(root?.["usage"]);
417
441
  const input = tokenCount(usage?.["input_tokens"]);
418
442
  const output = tokenCount(usage?.["output_tokens"]);
@@ -426,12 +450,12 @@ export function createTypeSafeReranker(
426
450
  outputTokens += output;
427
451
  usageReceipts += 1;
428
452
  }
429
- if (acceptedResponseModel(root?.["model"])) observedModel = DEFAULT_SYSTEMONE_MODEL;
453
+ if (acceptedResponseModel(root?.["model"], provider.model)) observedModel = provider.model;
430
454
  if (response.status !== 200) {
431
455
  stop(`TypeSafe rerank request returned HTTP ${response.status}.`);
432
456
  return { ok: false, message: firstFailure! };
433
457
  }
434
- const answer = parseSystemOneResponse(parsed);
458
+ const answer = parseSystemOneResponse(result, provider.model);
435
459
  if (answer === null) {
436
460
  stop("TypeSafe rerank response was malformed.");
437
461
  return { ok: false, message: firstFailure! };
package/src/rerank.ts CHANGED
@@ -56,7 +56,7 @@ export type SearchReranker = {
56
56
  };
57
57
 
58
58
  export type KnowledgeBaseSearchRerankOptions = {
59
- readonly engine: "typesafe";
59
+ readonly engine: "clef" | "typesafe";
60
60
  /** Window size submitted to the engine, from 2 through MAX_RERANK_CANDIDATES. */
61
61
  readonly limit?: number;
62
62
  /** Cancels only the hosted rerank phase, not local retrieval or indexing. */
package/src/sdk.ts CHANGED
@@ -208,6 +208,7 @@ export type KnowledgeBaseSearchHit = {
208
208
 
209
209
  export type KnowledgeBaseSearchDiagnostic = {
210
210
  readonly lane: "exact" | "git" | "graph" | "qmd" | "rerank";
211
+ readonly engine?: string;
211
212
  /** Structured hosted-lane accounting, including known usage after fallback. */
212
213
  readonly rerank?: SearchRerankDetails;
213
214
  readonly status: "degraded" | "ready" | "unavailable";
@@ -560,8 +561,8 @@ function checkedRerankRequest(
560
561
  throw new TypeError("Search rerank options must be an options object.");
561
562
  }
562
563
  const options = value as KnowledgeBaseSearchRerankOptions;
563
- if (options.engine !== "typesafe") {
564
- throw new Error('Search rerank engine must be "typesafe".');
564
+ if (options.engine !== "clef" && options.engine !== "typesafe") {
565
+ throw new Error('Search rerank engine must be "clef" or legacy "typesafe".');
565
566
  }
566
567
  const reranker = rerankers?.find((entry) => entry.id === options.engine);
567
568
  if (reranker === undefined) {
@@ -1034,6 +1035,7 @@ export async function openKnowledgeBase(
1034
1035
  : (applied.message ?? "Rerank engine did not return a result.");
1035
1036
  diagnostics.push({
1036
1037
  lane: "rerank",
1038
+ engine: rerankRequest.reranker.id,
1037
1039
  ...(Object.keys(resultDetails).length === 0 ? {} : { rerank: resultDetails }),
1038
1040
  status: applied.status === "failed" ? "degraded" : applied.status,
1039
1041
  results: applied.status === "ready" ? applied.placements.size : 0,
File without changes