pi-quiver 4.3.0 → 4.4.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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ Published to npm as `pi-quiver` (`pi install npm:pi-quiver`). Pushing a
8
8
  via OIDC trusted publishing. The release helper at
9
9
  `.agents/skills/release/scripts/release.sh` cuts the tag; CI publishes.
10
10
 
11
+ ## v4.4.0 - 2026-08-26
12
+
13
+ - doc_to_md: backend ladder now tries a system Python >= 3.12 with `pymupdf4llm` importable, then a one-time managed venv (bootstrapped at the version pin into a per-OS cache dir) between the existing `uv` and `unpdf` rungs. Data plane extracted to pi-free `lib/doc-to-md-core.ts`; new `pi-quiver doc-to-md <path>` CLI subcommand and `doc-to-md` Claude Code skill. `uv`/`soffice` detection is now spawn-based (Windows-correct). The bundled Python conversion script is now resolved from the package root, fixing a path bug that broke it under the bundled CLI.
14
+
11
15
  ## v4.3.0 - 2026-08-25
12
16
 
13
17
  - fetch: GitHub Actions job URLs (`.../actions/runs/<runId>/job/<jobId>`, singular `/job/` as GitHub's UI produces) now route through `gh run view --job <jobId> --repo <slug>`; plural `/jobs/<id>` paths still fall back to plain HTTP. Both run and job fetches now make a best-effort second `gh run view ... --log-failed` call and append its output under a `## Failed step logs` heading; when nothing failed, the run is still in progress, or logs have expired, the call yields no section and behavior is unchanged (summary-only).
package/README.md CHANGED
@@ -63,7 +63,7 @@ A 300 KB changelog page never touches your context window - you get a preview an
63
63
  | Extension | Tool | What it does |
64
64
  | --- | --- | --- |
65
65
  | `extensions/fetch.ts` | `fetch` | Retrieve URLs over HTTP(S). HTML -> Markdown (Readability extraction, Turndown conversion). Binary saved untouched to a temp file. GitHub issue/PR/repo/actions-run/actions-job URLs auto-route through `gh` (falls back to HTTP); failed runs/jobs include failed-step logs (best-effort, summary-only otherwise). Same size gate as `doc_to_md`. Behavior lives in `lib/fetch-core.ts`; also exposed as the `pi-quiver fetch` CLI (see [Claude Code support](#claude-code-support)). |
66
- | `extensions/doc_to_md.ts` | `doc_to_md` | Convert a local PDF/DOCX/PPTX to Markdown. High-fidelity via `pymupdf4llm` (run through `uv`); degraded pure-JS fallback (`unpdf`) when `uv`/Python is unavailable or conversion times out. DOCX/PPTX convert via LibreOffice first. |
66
+ | `extensions/doc_to_md.ts` | `doc_to_md` | Convert a local PDF/DOCX/PPTX to Markdown. High-fidelity via `pymupdf4llm`, resolved per process (`uv` -> system Python >= 3.12 with the package -> one-time managed venv in the user cache dir); degraded pure-JS fallback (`unpdf`) otherwise. DOCX/PPTX convert via LibreOffice first. Behavior lives in `lib/doc-to-md-core.ts`; also exposed as the `pi-quiver doc-to-md` CLI (see [Claude Code support](#claude-code-support)). |
67
67
  | `extensions/session-name.ts` | `/session-name` | Manual + opt-in automatic session naming, naming rules and deny list, long-session revisits, and Ghostty tab rename. OFF by default. |
68
68
  | `extensions/sword-header.ts` | `/builtin-header` | Themed ASCII startup header replacing pi's default logo. OFF by default. |
69
69
  | `extensions/fast-mode.ts` | `/fast` | Inject Anthropic fast-mode (`speed: "fast"` + `anthropic-beta: fast-mode-2026-02-01`) into every Claude Opus 4.8 / Opus 5 request, any thinking level. `--fast` flag + `/fast [on\|off\|status]`. OFF by default. |
@@ -130,7 +130,7 @@ The npm package's bundled JS deps install automatically on `pi install`. A few *
130
130
  | Prerequisite | Needed by | If absent |
131
131
  | --- | --- | --- |
132
132
  | `gh` (GitHub CLI, installed + `gh auth login`) | `fetch` GitHub issue/PR/repo/actions-run/actions-job routing | Falls back to an HTTP fetch of the rendered page (private repos hit a login wall). |
133
- | `uv` (+ managed Python 3.14, fetched on first use) | `doc_to_md` high-fidelity PDF conversion | Degrades to the pure-JS `unpdf` fallback (no faithful tables/headings). |
133
+ | `uv` (+ managed Python 3.14, fetched on first use) | `doc_to_md` high-fidelity PDF conversion (preferred route) | Falls back to a system Python >= 3.12 with `pymupdf4llm` (or a one-time managed venv it bootstraps); only when no capable Python exists does it degrade to `unpdf`. |
134
134
  | LibreOffice (`soffice` on `PATH`) | `doc_to_md` DOCX/PPTX conversion | Office inputs error (no JS fallback for office->PDF); PDFs unaffected. |
135
135
 
136
136
  None is a hard install-time dependency of the package; they are tools you provide in the environment where pi runs.
@@ -209,9 +209,9 @@ Operational notes:
209
209
 
210
210
  `fetch`'s core (`lib/fetch-core.ts`) is also published as a CLI, so Claude Code can use the same routing, size gate, and spill behavior as pi's native tool - without pi ever seeing Claude-only files.
211
211
 
212
- **Exposed:** the `quiver` plugin, served from this repo's `.claude-plugin/marketplace.json`, with one skill: `fetch` (invoked as `quiver:fetch` / `/quiver:fetch`). The skill runs `npx -y pi-quiver@latest fetch <url> [flags]` via Bash - full parameter parity with the pi tool (`--method`, `--header`, `--body`, `--raw`, `--timeout-ms`), same GitHub `gh` routing (including failed-step logs on failed runs/jobs), same size gate, same binary-to-temp-file handling. See [doc/fetch.md](doc/fetch.md#claude-code-cli-pi-quiver-fetch) for exit codes and flags.
212
+ **Exposed:** the `quiver` plugin, served from this repo's `.claude-plugin/marketplace.json`, with two skills: `fetch` (invoked as `quiver:fetch` / `/quiver:fetch`) and `doc-to-md` (invoked as `quiver:doc-to-md` / `/quiver:doc-to-md`). The `fetch` skill runs `npx -y pi-quiver@latest fetch <url> [flags]` via Bash - full parameter parity with the pi tool (`--method`, `--header`, `--body`, `--raw`, `--timeout-ms`), same GitHub `gh` routing (including failed-step logs on failed runs/jobs), same size gate, same binary-to-temp-file handling. See [doc/fetch.md](doc/fetch.md#claude-code-cli-pi-quiver-fetch) for exit codes and flags. The `doc-to-md` skill runs `npx -y pi-quiver@latest doc-to-md <path>` via Bash - same backend ladder, size gate, and degraded-fallback marking as the pi tool. See [doc/doc-to-md.md](doc/doc-to-md.md#cli-pi-quiver-doc-to-md) for exit codes.
213
213
 
214
- **Not exposed:** pi extensions, `doc_to_md`, and everything else in this package - the marketplace allowlists only `./skills/fetch`, and the npm tarball never ships `skills/` or `.claude-plugin/` (pi's own `files` allowlist excludes them, and pi's explicit `pi.extensions` manifest makes them invisible to pi's convention-directory auto-discovery either way).
214
+ **Not exposed:** the other pi extensions in this package (`session-name`, `sword-header`, `fast-mode`, `provider-stall-watchdog`) - the marketplace allowlists only `./skills/fetch` and `./skills/doc-to-md`, and the npm tarball never ships `skills/` or `.claude-plugin/` (pi's own `files` allowlist excludes them, and pi's explicit `pi.extensions` manifest makes them invisible to pi's convention-directory auto-discovery either way).
215
215
 
216
216
  Add the marketplace and enable the plugin in `.claude/settings.json`:
217
217
 
@@ -2,8 +2,8 @@
2
2
 
3
3
  // bin/pi-quiver.ts
4
4
  import { realpathSync } from "node:fs";
5
- import { fileURLToPath } from "node:url";
6
- import { resolve } from "node:path";
5
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
6
+ import { resolve as resolve2 } from "node:path";
7
7
 
8
8
  // lib/fetch-core.ts
9
9
  import { mkdirSync, writeFileSync, createWriteStream } from "node:fs";
@@ -318,8 +318,8 @@ function binaryExtension(contentType) {
318
318
  return sub || "bin";
319
319
  }
320
320
  function writeChunk(stream, b) {
321
- return new Promise((resolve2, reject) => {
322
- stream.write(b, (err) => err ? reject(err) : resolve2());
321
+ return new Promise((resolve3, reject) => {
322
+ stream.write(b, (err) => err ? reject(err) : resolve3());
323
323
  });
324
324
  }
325
325
  async function pumpToFile(stream, reader, prefix, exhausted) {
@@ -343,7 +343,7 @@ async function pumpToFile(stream, reader, prefix, exhausted) {
343
343
  await writeChunk(stream, chunk);
344
344
  bytes += chunk.length;
345
345
  }
346
- await new Promise((resolve2, reject) => stream.end((err) => err ? reject(err) : resolve2()));
346
+ await new Promise((resolve3, reject) => stream.end((err) => err ? reject(err) : resolve3()));
347
347
  return { bytes, truncated };
348
348
  }
349
349
  async function collectBody(res, contentType, raw) {
@@ -515,9 +515,479 @@ async function fetchUrl(opts) {
515
515
  }
516
516
  }
517
517
 
518
+ // lib/doc-to-md-core.ts
519
+ import { existsSync, mkdirSync as mkdirSync2, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync as writeFileSync2 } from "node:fs";
520
+ import { spawn } from "node:child_process";
521
+ import { homedir, tmpdir as tmpdir2 } from "node:os";
522
+ import { basename, dirname, extname, join as join2, resolve } from "node:path";
523
+ import { createHash as createHash2 } from "node:crypto";
524
+ import { fileURLToPath } from "node:url";
525
+ import { extractText, getDocumentProxy } from "unpdf";
526
+ var DEFAULT_PYMUPDF_VERSION = "1.27.2.3";
527
+ var WARM_TIMEOUT_DEFAULT = 12e4;
528
+ var CONVERT_TIMEOUT_DEFAULT = 6e4;
529
+ var SOFFICE_TIMEOUT_DEFAULT = 12e4;
530
+ var STDERR_CAP = 1e6;
531
+ var INLINE_MAX_BYTES2 = 32e3;
532
+ var INLINE_MAX_LINES2 = 1e3;
533
+ var PREVIEW_LINES2 = 60;
534
+ var PREVIEW_MAX_BYTES2 = 4e3;
535
+ var VERSION_RE = /^\d+(\.\d+)*$/;
536
+ var PAGE_SEP = "\n\n---\n\n";
537
+ var OUTPUT_MAX_BYTES = 2e7;
538
+ var DEGRADED_MARKER = "[Note: degraded extraction via unpdf \u2014 structure (tables/headings) not preserved]";
539
+ function parseConfig(env) {
540
+ const version = env.PI_DOC_TO_MD_PYMUPDF_VERSION ?? DEFAULT_PYMUPDF_VERSION;
541
+ if (!VERSION_RE.test(version)) {
542
+ throw new Error(`PI_DOC_TO_MD_PYMUPDF_VERSION must be digits and dots (got "${version}")`);
543
+ }
544
+ const num = (key, def) => {
545
+ const raw = env[key];
546
+ if (raw === void 0) return def;
547
+ const n = Number.parseInt(raw, 10);
548
+ if (!Number.isInteger(n) || n <= 0) throw new Error(`${key} must be a positive integer (got "${raw}")`);
549
+ return n;
550
+ };
551
+ return {
552
+ pymupdfVersion: version,
553
+ warmTimeoutMs: num("PI_DOC_TO_MD_WARM_TIMEOUT_MS", WARM_TIMEOUT_DEFAULT),
554
+ convertTimeoutMs: num("PI_DOC_TO_MD_CONVERT_TIMEOUT_MS", CONVERT_TIMEOUT_DEFAULT),
555
+ sofficeTimeoutMs: num("PI_DOC_TO_MD_SOFFICE_TIMEOUT_MS", SOFFICE_TIMEOUT_DEFAULT)
556
+ };
557
+ }
558
+ var SUPPORTED = { ".pdf": "pdf", ".docx": "docx", ".pptx": "pptx" };
559
+ function classifyInput(filePath) {
560
+ const ext = extname(filePath).toLowerCase();
561
+ const t = SUPPORTED[ext];
562
+ if (!t) throw new Error(`Unsupported file type "${ext || "(none)"}"; supported: .pdf, .docx, .pptx`);
563
+ return t;
564
+ }
565
+ function applyGate2(body) {
566
+ const bytes = Buffer.byteLength(body, "utf8");
567
+ const lines = body.length ? body.split("\n").length : 0;
568
+ const spill = body.length > 0 && (bytes > INLINE_MAX_BYTES2 || lines > INLINE_MAX_LINES2);
569
+ return { spill, bytes, lines };
570
+ }
571
+ function withMarker(md, degraded) {
572
+ return degraded ? `${DEGRADED_MARKER}
573
+
574
+ ${md}` : md;
575
+ }
576
+ function pagesToMarkdown(pages) {
577
+ return pages.map((p) => p.trim()).filter((p) => p.length > 0).join(PAGE_SEP);
578
+ }
579
+ function tempFilePath2(inputPath, ext) {
580
+ const dir = join2(tmpdir2(), "pi-doc-to-md");
581
+ mkdirSync2(dir, { recursive: true });
582
+ const base = basename(inputPath).replace(/\.[^.]+$/, "").replace(/[^\w.-]+/g, "-");
583
+ const hash = createHash2("sha1").update(resolve(inputPath)).digest("hex").slice(0, 8);
584
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
585
+ return join2(dir, `${stamp}-${base}-${hash}.${ext}`);
586
+ }
587
+ function spillToFile2(inputPath, body) {
588
+ const file = tempFilePath2(inputPath, "md");
589
+ writeFileSync2(file, `${body}
590
+ `, "utf8");
591
+ return file;
592
+ }
593
+ function buildPreview2(body) {
594
+ let preview = body.split("\n").slice(0, PREVIEW_LINES2).join("\n");
595
+ if (preview.length > PREVIEW_MAX_BYTES2) {
596
+ preview = `${preview.slice(0, PREVIEW_MAX_BYTES2)}
597
+ \u2026[preview truncated]`;
598
+ }
599
+ return preview;
600
+ }
601
+ function warmArgs(cfg) {
602
+ return ["run", "--with", `pymupdf4llm==${cfg.pymupdfVersion}`, "--python", "3.14", "python", "-c", "import pymupdf4llm"];
603
+ }
604
+ function convertArgs(cfg, scriptPath2, pdfPath) {
605
+ return ["run", "--with", `pymupdf4llm==${cfg.pymupdfVersion}`, "--python", "3.14", "python", scriptPath2, pdfPath];
606
+ }
607
+ function soffArgs(src, profileDir, outDir) {
608
+ return [
609
+ "--headless",
610
+ "--invisible",
611
+ "--nocrashreport",
612
+ "--nodefault",
613
+ "--nofirststartwizard",
614
+ "--nolockcheck",
615
+ "--nologo",
616
+ "--norestore",
617
+ "--quickstart=no",
618
+ `-env:UserInstallation=file://${profileDir}`,
619
+ "--convert-to",
620
+ "pdf",
621
+ "--outdir",
622
+ outDir,
623
+ src
624
+ ];
625
+ }
626
+ async function runCapped(cmd, args, opts) {
627
+ return new Promise((resolveP) => {
628
+ let settled = false;
629
+ let timedOut = false;
630
+ let capped = false;
631
+ const stdoutChunks = [];
632
+ const stderrChunks = [];
633
+ let stdoutBytes = 0;
634
+ let stderrBytes = 0;
635
+ const child = spawn(cmd, args, { env: opts.env ?? process.env });
636
+ const finish = (code) => {
637
+ if (settled) return;
638
+ settled = true;
639
+ clearTimeout(timer);
640
+ opts.signal?.removeEventListener("abort", onAbort);
641
+ resolveP({
642
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
643
+ stderr: Buffer.concat(stderrChunks).toString("utf8"),
644
+ code,
645
+ timedOut,
646
+ capped
647
+ });
648
+ };
649
+ const kill = () => child.kill("SIGKILL");
650
+ const onAbort = () => {
651
+ kill();
652
+ };
653
+ if (opts.signal?.aborted) {
654
+ kill();
655
+ } else opts.signal?.addEventListener("abort", onAbort);
656
+ const timer = setTimeout(() => {
657
+ timedOut = true;
658
+ kill();
659
+ }, opts.timeoutMs);
660
+ child.stdout.on("data", (chunk) => {
661
+ if (capped) return;
662
+ const remaining = opts.capBytes - stdoutBytes;
663
+ if (remaining <= 0) {
664
+ capped = true;
665
+ kill();
666
+ return;
667
+ }
668
+ if (chunk.length > remaining) {
669
+ stdoutChunks.push(chunk.subarray(0, remaining));
670
+ stdoutBytes += remaining;
671
+ capped = true;
672
+ kill();
673
+ } else {
674
+ stdoutChunks.push(chunk);
675
+ stdoutBytes += chunk.length;
676
+ }
677
+ });
678
+ child.stderr.on("data", (chunk) => {
679
+ if (stderrBytes >= STDERR_CAP) return;
680
+ const remaining = STDERR_CAP - stderrBytes;
681
+ const slice = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
682
+ stderrChunks.push(slice);
683
+ stderrBytes += slice.length;
684
+ });
685
+ child.on("error", (err) => {
686
+ if (stderrBytes < STDERR_CAP) {
687
+ const msg = Buffer.from(err.message);
688
+ const remaining = STDERR_CAP - stderrBytes;
689
+ stderrChunks.push(msg.length > remaining ? msg.subarray(0, remaining) : msg);
690
+ }
691
+ finish(null);
692
+ });
693
+ child.on("close", (code) => finish(code));
694
+ });
695
+ }
696
+ function findPackageRoot(startDir) {
697
+ let dir = startDir;
698
+ for (; ; ) {
699
+ if (existsSync(join2(dir, "package.json"))) return dir;
700
+ const parent = dirname(dir);
701
+ if (parent === dir) throw new Error(`package.json not found walking up from ${startDir}`);
702
+ dir = parent;
703
+ }
704
+ }
705
+ function scriptPath() {
706
+ return join2(findPackageRoot(dirname(fileURLToPath(import.meta.url))), "scripts", "pdf_to_md.py");
707
+ }
708
+ function formatSize2(bytes) {
709
+ if (bytes < 1024) {
710
+ return `${bytes}B`;
711
+ } else if (bytes < 1024 * 1024) {
712
+ return `${(bytes / 1024).toFixed(1)}KB`;
713
+ } else {
714
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
715
+ }
716
+ }
717
+ async function convertViaUv(cfg, pdfPath, signal) {
718
+ const r = await runCapped("uv", convertArgs(cfg, scriptPath(), pdfPath), {
719
+ timeoutMs: cfg.convertTimeoutMs,
720
+ capBytes: OUTPUT_MAX_BYTES,
721
+ signal
722
+ });
723
+ if (r.timedOut || r.capped || r.code !== 0) {
724
+ throw new Error(`code=${r.code} timedOut=${r.timedOut} capped=${r.capped}: ${r.stderr.slice(0, 500)}`);
725
+ }
726
+ return r.stdout;
727
+ }
728
+ async function convertViaPython(cfg, exe, pdfPath, signal) {
729
+ const r = await runCapped(exe, pythonConvertArgs(scriptPath(), pdfPath), {
730
+ timeoutMs: cfg.convertTimeoutMs,
731
+ capBytes: OUTPUT_MAX_BYTES,
732
+ signal
733
+ });
734
+ if (r.timedOut || r.capped || r.code !== 0) {
735
+ throw new Error(`code=${r.code} timedOut=${r.timedOut} capped=${r.capped}: ${r.stderr.slice(0, 500)}`);
736
+ }
737
+ return r.stdout;
738
+ }
739
+ async function convertViaUnpdf(pdfPath, cfg, signal) {
740
+ if (signal?.aborted) throw new Error("aborted");
741
+ const buf = readFileSync(pdfPath);
742
+ const work = (async () => {
743
+ const pdf = await getDocumentProxy(new Uint8Array(buf));
744
+ const { text } = await extractText(pdf, { mergePages: false });
745
+ const md = pagesToMarkdown(text);
746
+ if (Buffer.byteLength(md, "utf8") > OUTPUT_MAX_BYTES) throw new Error("unpdf output exceeded cap");
747
+ return md;
748
+ })();
749
+ let timer;
750
+ const timeout = new Promise((_, rej) => {
751
+ timer = setTimeout(() => rej(new Error("unpdf conversion timed out")), cfg.convertTimeoutMs);
752
+ });
753
+ try {
754
+ return await Promise.race([work, timeout]);
755
+ } finally {
756
+ clearTimeout(timer);
757
+ }
758
+ }
759
+ var PROBE_PROGRAM = `import sys
760
+ print("PY", sys.version_info[0], sys.version_info[1])
761
+ try:
762
+ import pymupdf4llm
763
+ print("PKG", pymupdf4llm.__version__)
764
+ except Exception:
765
+ print("PKG", "none")
766
+ `;
767
+ var PROBE_TIMEOUT_MS = 5e3;
768
+ function probeArgs() {
769
+ return ["-c", PROBE_PROGRAM];
770
+ }
771
+ var PYTHON_CANDIDATES = ["python3", "python"];
772
+ function parseProbeOutput(stdout) {
773
+ const m = stdout.match(/^PY (\d+) (\d+)\r?\nPKG (\S+)\s*$/);
774
+ if (!m) return null;
775
+ return { major: Number(m[1]), minor: Number(m[2]), pkg: m[3] === "none" ? null : m[3] };
776
+ }
777
+ function meetsFloor(p) {
778
+ return p.major > 3 || p.major === 3 && p.minor >= 12;
779
+ }
780
+ function cacheDir(platform, env, home) {
781
+ if (platform === "win32") return join2(env.LOCALAPPDATA ?? join2(home, "AppData", "Local"), "pi-quiver");
782
+ if (platform === "darwin") return join2(home, "Library", "Caches", "pi-quiver");
783
+ return join2(env.XDG_CACHE_HOME ?? join2(home, ".cache"), "pi-quiver");
784
+ }
785
+ function venvPython(venvDir, platform) {
786
+ return platform === "win32" ? join2(venvDir, "Scripts", "python.exe") : join2(venvDir, "bin", "python");
787
+ }
788
+ function pythonConvertArgs(script, pdfPath) {
789
+ return [script, pdfPath];
790
+ }
791
+ var tail = (s) => s.slice(-500).trim();
792
+ async function resolveBackend(cfg, deps, signal) {
793
+ if (signal?.aborted) throw new Error("aborted");
794
+ const warm = await deps.run("uv", warmArgs(cfg), { timeoutMs: cfg.warmTimeoutMs, capBytes: OUTPUT_MAX_BYTES, env: deps.env, signal });
795
+ if (signal?.aborted) throw new Error("aborted");
796
+ if (warm.code === 0 && !warm.timedOut) return { kind: "uv" };
797
+ const uvAbsent = warm.code === null && !warm.timedOut;
798
+ const probe = async (exe) => {
799
+ const r = await deps.run(exe, probeArgs(), { timeoutMs: PROBE_TIMEOUT_MS, capBytes: 4e3, env: deps.env, signal });
800
+ if (signal?.aborted) throw new Error("aborted");
801
+ if (r.code !== 0 || r.timedOut) return null;
802
+ return parseProbeOutput(r.stdout);
803
+ };
804
+ let eligible = null;
805
+ for (const exe of PYTHON_CANDIDATES) {
806
+ const p = await probe(exe);
807
+ if (!p || !meetsFloor(p)) continue;
808
+ if (p.pkg) return { kind: "python", exe, version: p.pkg };
809
+ eligible ??= { exe, version: `${p.major}.${p.minor}` };
810
+ }
811
+ const venvDir = join2(deps.cacheRoot, "pymupdf-venv");
812
+ const venvExe = venvPython(venvDir, deps.platform);
813
+ const cached = await probe(venvExe);
814
+ if (cached && meetsFloor(cached) && cached.pkg) return { kind: "venv", exe: venvExe, version: cached.pkg };
815
+ if (eligible) {
816
+ const recheck = await probe(venvExe);
817
+ if (recheck && meetsFloor(recheck) && recheck.pkg) return { kind: "venv", exe: venvExe, version: recheck.pkg };
818
+ const tmp = `${venvDir}.tmp-${deps.pid}`;
819
+ const deadline = deps.now() + cfg.warmTimeoutMs;
820
+ const left = () => Math.max(1, deadline - deps.now());
821
+ const bootFail = (stderr) => {
822
+ deps.rmrf(tmp);
823
+ return { kind: "none", reason: `python ${eligible.version} found but venv bootstrap failed: ${tail(stderr)} - install python3-venv, or uv` };
824
+ };
825
+ const mk = await deps.run(eligible.exe, ["-m", "venv", tmp], { timeoutMs: left(), capBytes: OUTPUT_MAX_BYTES, env: deps.env, signal });
826
+ if (signal?.aborted) throw new Error("aborted");
827
+ if (mk.code !== 0 || mk.timedOut) return bootFail(mk.stderr);
828
+ const pip = await deps.run(venvPython(tmp, deps.platform), ["-m", "pip", "install", `pymupdf4llm==${cfg.pymupdfVersion}`], { timeoutMs: left(), capBytes: OUTPUT_MAX_BYTES, env: deps.env, signal });
829
+ if (signal?.aborted) throw new Error("aborted");
830
+ if (pip.code !== 0 || pip.timedOut) return bootFail(pip.stderr);
831
+ const publish = () => {
832
+ try {
833
+ deps.rename(tmp, venvDir);
834
+ return true;
835
+ } catch {
836
+ return false;
837
+ }
838
+ };
839
+ if (publish()) return { kind: "venv", exe: venvExe, version: cfg.pymupdfVersion };
840
+ const winner = await probe(venvExe);
841
+ if (winner && meetsFloor(winner) && winner.pkg) {
842
+ deps.rmrf(tmp);
843
+ return { kind: "venv", exe: venvExe, version: winner.pkg };
844
+ }
845
+ deps.rmrf(venvDir);
846
+ if (publish()) return { kind: "venv", exe: venvExe, version: cfg.pymupdfVersion };
847
+ return bootFail("rename after competing bootstrap");
848
+ }
849
+ return uvAbsent ? { kind: "none", reason: "uv not found; no python >= 3.12 on PATH - install uv, or Python 3.12+" } : { kind: "none", reason: `uv warm-up failed: ${tail(warm.stderr)}; no python >= 3.12 on PATH` };
850
+ }
851
+ var backendPromise = null;
852
+ function realDeps() {
853
+ return {
854
+ run: runCapped,
855
+ cacheRoot: cacheDir(process.platform, process.env, homedir()),
856
+ platform: process.platform,
857
+ pid: process.pid,
858
+ rename: renameSync,
859
+ rmrf: (p) => rmSync(p, { recursive: true, force: true }),
860
+ now: Date.now,
861
+ env: process.env
862
+ };
863
+ }
864
+ function getBackend(cfg, deps, signal) {
865
+ if (!backendPromise) {
866
+ const promise = resolveBackend(cfg, deps ?? realDeps(), signal);
867
+ backendPromise = promise;
868
+ promise.catch(() => {
869
+ if (backendPromise === promise) backendPromise = null;
870
+ });
871
+ }
872
+ return backendPromise;
873
+ }
874
+ async function convertOffice(cfg, src, signal, run = runCapped) {
875
+ const profileDir = mkdtempSync(join2(tmpdir2(), "pi-doc-soffice-prof-"));
876
+ const outDir = mkdtempSync(join2(tmpdir2(), "pi-doc-soffice-out-"));
877
+ const cleanup = () => {
878
+ for (const d of [profileDir, outDir]) {
879
+ try {
880
+ rmSync(d, { recursive: true, force: true });
881
+ } catch {
882
+ }
883
+ }
884
+ };
885
+ try {
886
+ const env = { ...process.env, SAL_USE_VCLPLUGIN: "svp", OOO_DISABLE_RECOVERY: "1", SAL_NO_MOUSEGRABS: "1" };
887
+ const r = await run("soffice", soffArgs(src, profileDir, outDir), { timeoutMs: cfg.sofficeTimeoutMs, capBytes: OUTPUT_MAX_BYTES, env, signal });
888
+ if (signal?.aborted) throw new Error("aborted");
889
+ if (r.code === null && !r.timedOut) {
890
+ throw new Error("LibreOffice (soffice) is required to convert .docx/.pptx but was not found on PATH. Install LibreOffice or convert the file to PDF first.");
891
+ }
892
+ if (r.timedOut || r.code !== 0) throw new Error(`soffice failed (code=${r.code} timedOut=${r.timedOut}): ${r.stderr.slice(0, 500)}`);
893
+ const base = basename(src).replace(/\.[^.]+$/, "");
894
+ const pdfPath = join2(outDir, `${base}.pdf`);
895
+ const st = statSync(pdfPath, { throwIfNoEntry: false });
896
+ if (!st || !st.isFile() || st.size === 0) {
897
+ throw new Error("LibreOffice (soffice) ran but produced no usable PDF for this file. Ensure LibreOffice can open the document, or convert it to PDF manually first.");
898
+ }
899
+ return { pdfPath, cleanup };
900
+ } catch (e) {
901
+ cleanup();
902
+ throw e;
903
+ }
904
+ }
905
+ async function runPipeline(cfg, inputPath, type, signal, seams) {
906
+ const s = {
907
+ backend: (c) => getBackend(c, void 0, signal),
908
+ convertPymupdf: (c, b, p, sig) => b.kind === "uv" ? convertViaUv(c, p, sig) : convertViaPython(c, b.exe, p, sig),
909
+ convertUnpdf: convertViaUnpdf,
910
+ ...seams
911
+ };
912
+ let pdfPath = inputPath;
913
+ let cleanup = null;
914
+ if (type === "docx" || type === "pptx") {
915
+ const o = await convertOffice(cfg, inputPath, signal);
916
+ pdfPath = o.pdfPath;
917
+ cleanup = o.cleanup;
918
+ }
919
+ try {
920
+ const backend = await s.backend(cfg);
921
+ if (backend.kind === "none") {
922
+ return { markdown: await s.convertUnpdf(pdfPath, cfg, signal), engine: "unpdf", degraded: true, fallbackReason: backend.reason, backend: "unpdf" };
923
+ }
924
+ try {
925
+ const markdown = await s.convertPymupdf(cfg, backend, pdfPath, signal);
926
+ return {
927
+ markdown,
928
+ engine: "pymupdf4llm",
929
+ degraded: false,
930
+ backend: backend.kind,
931
+ ...backend.kind === "python" || backend.kind === "venv" ? { pymupdfVersion: backend.version } : {}
932
+ };
933
+ } catch (e) {
934
+ const msg = e instanceof Error ? e.message : String(e);
935
+ return {
936
+ markdown: await s.convertUnpdf(pdfPath, cfg, signal),
937
+ engine: "unpdf",
938
+ degraded: true,
939
+ fallbackReason: `pymupdf4llm (${backend.kind}) conversion failed: ${msg} - fell back to unpdf`,
940
+ backend: "unpdf"
941
+ };
942
+ }
943
+ } finally {
944
+ cleanup?.();
945
+ }
946
+ }
947
+ async function convertDocument(path, cfg, signal, seams) {
948
+ const inputPath = resolve(path);
949
+ const st = statSync(inputPath, { throwIfNoEntry: false });
950
+ if (!st || !st.isFile()) throw new Error(`Not a readable file: ${path}`);
951
+ const type = classifyInput(inputPath);
952
+ const { markdown, engine, degraded, fallbackReason, backend, pymupdfVersion } = await runPipeline(cfg, inputPath, type, signal, seams);
953
+ const body = withMarker(markdown, degraded).replace(/\s+$/, "");
954
+ const { spill, bytes, lines } = applyGate2(body);
955
+ const details = { path: inputPath, inputType: type, engine, backend, ...pymupdfVersion !== void 0 ? { pymupdfVersion } : {}, degraded, bytes, lines, spilled: spill };
956
+ const header = [
957
+ `Source: ${inputPath}`,
958
+ `Type: ${type} Engine: ${engine}${degraded ? " (degraded fallback)" : ""}`
959
+ ];
960
+ if (fallbackReason) header.push(`Fallback-Reason: ${fallbackReason}`);
961
+ const compose = (parts) => parts.join("\n").replace(/\n+$/, "");
962
+ if (!spill) {
963
+ return { output: compose([...header, `Length: ${bytes} bytes, ${lines} lines`, "", body]), details };
964
+ }
965
+ const file = spillToFile2(inputPath, body);
966
+ return {
967
+ output: compose([
968
+ ...header,
969
+ `Body: ${formatSize2(bytes)} across ${lines} lines \u2014 written to file (too large to inline)`,
970
+ `Saved-To: ${file}`,
971
+ "",
972
+ "Read slices of this file with the read tool (offset/limit) or grep it; do not read the whole file unless you must. Markdown is grep-able by heading (^#).",
973
+ "",
974
+ "----- preview (first 60 lines) -----",
975
+ buildPreview2(body)
976
+ ]),
977
+ details: { ...details, spilled: true, file }
978
+ };
979
+ }
980
+
518
981
  // bin/pi-quiver.ts
519
- var USAGE = 'Usage: pi-quiver fetch <url> [--method GET|HEAD|POST] [--header "K: V"]... [--body <str>] [--raw] [--timeout-ms <n>]';
982
+ var USAGE = 'Usage: pi-quiver fetch <url> [--method GET|HEAD|POST] [--header "K: V"]... [--body <str>] [--raw] [--timeout-ms <n>]\n pi-quiver doc-to-md <path>';
520
983
  function parseCliArgs(argv) {
984
+ if (argv[0] === "doc-to-md") {
985
+ const rest2 = argv.slice(1);
986
+ if (rest2.length === 0) return { ok: false, error: "missing <path>" };
987
+ if (rest2[0].startsWith("--")) return { ok: false, error: `unknown flag: ${rest2[0]}` };
988
+ if (rest2.length > 1) return { ok: false, error: `unexpected argument: ${rest2[1]}` };
989
+ return { ok: true, cmd: "doc-to-md", path: rest2[0] };
990
+ }
521
991
  if (argv[0] !== "fetch") return { ok: false, error: `unknown command: ${argv[0] ?? "(none)"}` };
522
992
  const rest = argv.slice(1);
523
993
  let url;
@@ -565,7 +1035,7 @@ function parseCliArgs(argv) {
565
1035
  if (body !== void 0) opts.body = body;
566
1036
  if (raw !== void 0) opts.raw = raw;
567
1037
  if (timeoutMs !== void 0) opts.timeoutMs = timeoutMs;
568
- return { ok: true, opts };
1038
+ return { ok: true, cmd: "fetch", opts };
569
1039
  }
570
1040
  async function main() {
571
1041
  const parsed = parseCliArgs(process.argv.slice(2));
@@ -575,6 +1045,18 @@ ${USAGE}
575
1045
  `);
576
1046
  return 2;
577
1047
  }
1048
+ if (parsed.cmd === "doc-to-md") {
1049
+ try {
1050
+ const result = await convertDocument(parsed.path, parseConfig(process.env));
1051
+ process.stdout.write(`${result.output}
1052
+ `);
1053
+ return 0;
1054
+ } catch (err) {
1055
+ process.stderr.write(`doc-to-md failed: ${err instanceof Error ? err.message : String(err)}
1056
+ `);
1057
+ return 1;
1058
+ }
1059
+ }
578
1060
  try {
579
1061
  const result = await fetchUrl(parsed.opts);
580
1062
  process.stdout.write(`${result.output}
@@ -589,7 +1071,7 @@ ${USAGE}
589
1071
  function isMainEntry() {
590
1072
  if (!process.argv[1]) return false;
591
1073
  try {
592
- return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(resolve(process.argv[1]));
1074
+ return realpathSync(fileURLToPath2(import.meta.url)) === realpathSync(resolve2(process.argv[1]));
593
1075
  } catch {
594
1076
  return false;
595
1077
  }