pi-quiver 4.2.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 +8 -0
- package/README.md +11 -7
- package/dist/bin/pi-quiver.js +521 -13
- package/extensions/doc_to_md.ts +4 -382
- package/extensions/fetch.ts +1 -1
- package/lib/doc-to-md-core.ts +591 -0
- package/lib/fetch-core.ts +33 -6
- package/package.json +1 -1
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doc-to-md core (pi-free)
|
|
3
|
+
*
|
|
4
|
+
* Converts a local PDF, DOCX, or PPTX file to Markdown. Primary engine:
|
|
5
|
+
* pymupdf4llm via ephemeral `uv run --with` (warm-once per process, no repo
|
|
6
|
+
* venv). Fallback: unpdf pure-JS text extraction (degraded, explicitly
|
|
7
|
+
* marked). DOCX/PPTX convert to PDF first via headless soffice, then feed
|
|
8
|
+
* the PDF pipeline. Output over 32 KB or 1000 lines is spilled to a temp
|
|
9
|
+
* .md file with a 60-line preview; smaller content is returned inline.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { homedir, tmpdir } from "node:os";
|
|
15
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { extractText, getDocumentProxy } from "unpdf";
|
|
19
|
+
|
|
20
|
+
// --- Types ---
|
|
21
|
+
|
|
22
|
+
export type InputType = "pdf" | "docx" | "pptx";
|
|
23
|
+
export type Engine = "pymupdf4llm" | "unpdf";
|
|
24
|
+
|
|
25
|
+
export interface DocToMdConfig {
|
|
26
|
+
pymupdfVersion: string;
|
|
27
|
+
warmTimeoutMs: number;
|
|
28
|
+
convertTimeoutMs: number;
|
|
29
|
+
sofficeTimeoutMs: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DocToMdDetails {
|
|
33
|
+
path: string;
|
|
34
|
+
inputType: InputType;
|
|
35
|
+
engine: Engine;
|
|
36
|
+
backend: BackendKind;
|
|
37
|
+
pymupdfVersion?: string;
|
|
38
|
+
degraded: boolean;
|
|
39
|
+
bytes: number;
|
|
40
|
+
lines: number;
|
|
41
|
+
spilled: boolean;
|
|
42
|
+
file?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CappedResult {
|
|
46
|
+
stdout: string;
|
|
47
|
+
stderr: string;
|
|
48
|
+
code: number | null;
|
|
49
|
+
timedOut: boolean;
|
|
50
|
+
capped: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// --- Constants ---
|
|
54
|
+
|
|
55
|
+
const DEFAULT_PYMUPDF_VERSION = "1.27.2.3";
|
|
56
|
+
const WARM_TIMEOUT_DEFAULT = 120_000;
|
|
57
|
+
const CONVERT_TIMEOUT_DEFAULT = 60_000;
|
|
58
|
+
const SOFFICE_TIMEOUT_DEFAULT = 120_000;
|
|
59
|
+
const STDERR_CAP = 1_000_000;
|
|
60
|
+
const INLINE_MAX_BYTES = 32_000;
|
|
61
|
+
const INLINE_MAX_LINES = 1_000;
|
|
62
|
+
const PREVIEW_LINES = 60;
|
|
63
|
+
const PREVIEW_MAX_BYTES = 4_000;
|
|
64
|
+
const VERSION_RE = /^\d+(\.\d+)*$/;
|
|
65
|
+
const PAGE_SEP = "\n\n---\n\n";
|
|
66
|
+
export const OUTPUT_MAX_BYTES = 20_000_000;
|
|
67
|
+
|
|
68
|
+
export const DEGRADED_MARKER =
|
|
69
|
+
"[Note: degraded extraction via unpdf — structure (tables/headings) not preserved]";
|
|
70
|
+
|
|
71
|
+
// --- Config ---
|
|
72
|
+
|
|
73
|
+
export function parseConfig(env: NodeJS.ProcessEnv): DocToMdConfig {
|
|
74
|
+
const version = env.PI_DOC_TO_MD_PYMUPDF_VERSION ?? DEFAULT_PYMUPDF_VERSION;
|
|
75
|
+
if (!VERSION_RE.test(version)) {
|
|
76
|
+
throw new Error(`PI_DOC_TO_MD_PYMUPDF_VERSION must be digits and dots (got "${version}")`);
|
|
77
|
+
}
|
|
78
|
+
const num = (key: string, def: number): number => {
|
|
79
|
+
const raw = env[key];
|
|
80
|
+
if (raw === undefined) return def;
|
|
81
|
+
const n = Number.parseInt(raw, 10);
|
|
82
|
+
if (!Number.isInteger(n) || n <= 0) throw new Error(`${key} must be a positive integer (got "${raw}")`);
|
|
83
|
+
return n;
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
pymupdfVersion: version,
|
|
87
|
+
warmTimeoutMs: num("PI_DOC_TO_MD_WARM_TIMEOUT_MS", WARM_TIMEOUT_DEFAULT),
|
|
88
|
+
convertTimeoutMs: num("PI_DOC_TO_MD_CONVERT_TIMEOUT_MS", CONVERT_TIMEOUT_DEFAULT),
|
|
89
|
+
sofficeTimeoutMs: num("PI_DOC_TO_MD_SOFFICE_TIMEOUT_MS", SOFFICE_TIMEOUT_DEFAULT),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- Input classification ---
|
|
94
|
+
|
|
95
|
+
const SUPPORTED: Record<string, InputType> = { ".pdf": "pdf", ".docx": "docx", ".pptx": "pptx" };
|
|
96
|
+
|
|
97
|
+
export function classifyInput(filePath: string): InputType {
|
|
98
|
+
const ext = extname(filePath).toLowerCase();
|
|
99
|
+
const t = SUPPORTED[ext];
|
|
100
|
+
if (!t) throw new Error(`Unsupported file type "${ext || "(none)"}"; supported: .pdf, .docx, .pptx`);
|
|
101
|
+
return t;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- Size gate ---
|
|
105
|
+
|
|
106
|
+
export function applyGate(body: string): { spill: boolean; bytes: number; lines: number } {
|
|
107
|
+
const bytes = Buffer.byteLength(body, "utf8");
|
|
108
|
+
const lines = body.length ? body.split("\n").length : 0;
|
|
109
|
+
const spill = body.length > 0 && (bytes > INLINE_MAX_BYTES || lines > INLINE_MAX_LINES);
|
|
110
|
+
return { spill, bytes, lines };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- Markdown helpers ---
|
|
114
|
+
|
|
115
|
+
export function withMarker(md: string, degraded: boolean): string {
|
|
116
|
+
return degraded ? `${DEGRADED_MARKER}\n\n${md}` : md;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function pagesToMarkdown(pages: string[]): string {
|
|
120
|
+
return pages.map((p) => p.trim()).filter((p) => p.length > 0).join(PAGE_SEP);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- Temp file helpers ---
|
|
124
|
+
|
|
125
|
+
export function tempFilePath(inputPath: string, ext: string): string {
|
|
126
|
+
const dir = join(tmpdir(), "pi-doc-to-md");
|
|
127
|
+
mkdirSync(dir, { recursive: true });
|
|
128
|
+
const base = basename(inputPath).replace(/\.[^.]+$/, "").replace(/[^\w.-]+/g, "-");
|
|
129
|
+
const hash = createHash("sha1").update(resolve(inputPath)).digest("hex").slice(0, 8);
|
|
130
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
131
|
+
return join(dir, `${stamp}-${base}-${hash}.${ext}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function spillToFile(inputPath: string, body: string): string {
|
|
135
|
+
const file = tempFilePath(inputPath, "md");
|
|
136
|
+
writeFileSync(file, `${body}\n`, "utf8");
|
|
137
|
+
return file;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function buildPreview(body: string): string {
|
|
141
|
+
let preview = body.split("\n").slice(0, PREVIEW_LINES).join("\n");
|
|
142
|
+
if (preview.length > PREVIEW_MAX_BYTES) {
|
|
143
|
+
preview = `${preview.slice(0, PREVIEW_MAX_BYTES)}\n…[preview truncated]`;
|
|
144
|
+
}
|
|
145
|
+
return preview;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- Subprocess argv builders ---
|
|
149
|
+
|
|
150
|
+
export function warmArgs(cfg: DocToMdConfig): string[] {
|
|
151
|
+
return ["run", "--with", `pymupdf4llm==${cfg.pymupdfVersion}`, "--python", "3.14", "python", "-c", "import pymupdf4llm"];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function convertArgs(cfg: DocToMdConfig, scriptPath: string, pdfPath: string): string[] {
|
|
155
|
+
return ["run", "--with", `pymupdf4llm==${cfg.pymupdfVersion}`, "--python", "3.14", "python", scriptPath, pdfPath];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function soffArgs(src: string, profileDir: string, outDir: string): string[] {
|
|
159
|
+
return [
|
|
160
|
+
"--headless", "--invisible", "--nocrashreport", "--nodefault", "--nofirststartwizard",
|
|
161
|
+
"--nolockcheck", "--nologo", "--norestore", "--quickstart=no",
|
|
162
|
+
`-env:UserInstallation=file://${profileDir}`,
|
|
163
|
+
"--convert-to", "pdf", "--outdir", outDir, src,
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// --- Subprocess runner ---
|
|
168
|
+
|
|
169
|
+
export async function runCapped(
|
|
170
|
+
cmd: string,
|
|
171
|
+
args: string[],
|
|
172
|
+
opts: { timeoutMs: number; capBytes: number; env?: NodeJS.ProcessEnv; signal?: AbortSignal },
|
|
173
|
+
): Promise<CappedResult> {
|
|
174
|
+
return new Promise((resolveP) => {
|
|
175
|
+
let settled = false;
|
|
176
|
+
let timedOut = false;
|
|
177
|
+
let capped = false;
|
|
178
|
+
const stdoutChunks: Buffer[] = [];
|
|
179
|
+
const stderrChunks: Buffer[] = [];
|
|
180
|
+
let stdoutBytes = 0;
|
|
181
|
+
let stderrBytes = 0;
|
|
182
|
+
|
|
183
|
+
const child = spawn(cmd, args, { env: opts.env ?? process.env });
|
|
184
|
+
|
|
185
|
+
const finish = (code: number | null) => {
|
|
186
|
+
if (settled) return;
|
|
187
|
+
settled = true;
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
190
|
+
resolveP({
|
|
191
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
192
|
+
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
193
|
+
code,
|
|
194
|
+
timedOut,
|
|
195
|
+
capped,
|
|
196
|
+
});
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const kill = () => child.kill("SIGKILL");
|
|
200
|
+
|
|
201
|
+
const onAbort = () => { kill(); };
|
|
202
|
+
if (opts.signal?.aborted) { kill(); }
|
|
203
|
+
else opts.signal?.addEventListener("abort", onAbort);
|
|
204
|
+
|
|
205
|
+
const timer = setTimeout(() => { timedOut = true; kill(); }, opts.timeoutMs);
|
|
206
|
+
|
|
207
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
208
|
+
if (capped) return;
|
|
209
|
+
const remaining = opts.capBytes - stdoutBytes;
|
|
210
|
+
if (remaining <= 0) { capped = true; kill(); return; }
|
|
211
|
+
if (chunk.length > remaining) {
|
|
212
|
+
stdoutChunks.push(chunk.subarray(0, remaining));
|
|
213
|
+
stdoutBytes += remaining;
|
|
214
|
+
capped = true;
|
|
215
|
+
kill();
|
|
216
|
+
} else {
|
|
217
|
+
stdoutChunks.push(chunk);
|
|
218
|
+
stdoutBytes += chunk.length;
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
223
|
+
if (stderrBytes >= STDERR_CAP) return;
|
|
224
|
+
const remaining = STDERR_CAP - stderrBytes;
|
|
225
|
+
const slice = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
|
|
226
|
+
stderrChunks.push(slice);
|
|
227
|
+
stderrBytes += slice.length;
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
child.on("error", (err) => {
|
|
231
|
+
if (stderrBytes < STDERR_CAP) {
|
|
232
|
+
const msg = Buffer.from(err.message);
|
|
233
|
+
const remaining = STDERR_CAP - stderrBytes;
|
|
234
|
+
stderrChunks.push(msg.length > remaining ? msg.subarray(0, remaining) : msg);
|
|
235
|
+
}
|
|
236
|
+
finish(null);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
child.on("close", (code) => finish(code));
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// --- Engine orchestration ---
|
|
244
|
+
|
|
245
|
+
export function findPackageRoot(startDir: string): string {
|
|
246
|
+
let dir = startDir;
|
|
247
|
+
for (;;) {
|
|
248
|
+
if (existsSync(join(dir, "package.json"))) return dir;
|
|
249
|
+
const parent = dirname(dir);
|
|
250
|
+
if (parent === dir) throw new Error(`package.json not found walking up from ${startDir}`);
|
|
251
|
+
dir = parent;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function scriptPath(): string {
|
|
256
|
+
return join(findPackageRoot(dirname(fileURLToPath(import.meta.url))), "scripts", "pdf_to_md.py");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Deliberate local copy of the host package's formatSize - this core must stay pi-free (see test/layout.test.ts purity check).
|
|
260
|
+
function formatSize(bytes: number): string {
|
|
261
|
+
if (bytes < 1024) {
|
|
262
|
+
return `${bytes}B`;
|
|
263
|
+
} else if (bytes < 1024 * 1024) {
|
|
264
|
+
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
265
|
+
} else {
|
|
266
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function convertViaUv(cfg: DocToMdConfig, pdfPath: string, signal?: AbortSignal): Promise<string> {
|
|
271
|
+
const r = await runCapped("uv", convertArgs(cfg, scriptPath(), pdfPath), {
|
|
272
|
+
timeoutMs: cfg.convertTimeoutMs, capBytes: OUTPUT_MAX_BYTES, signal,
|
|
273
|
+
});
|
|
274
|
+
if (r.timedOut || r.capped || r.code !== 0) {
|
|
275
|
+
throw new Error(`code=${r.code} timedOut=${r.timedOut} capped=${r.capped}: ${r.stderr.slice(0, 500)}`);
|
|
276
|
+
}
|
|
277
|
+
return r.stdout;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function convertViaPython(cfg: DocToMdConfig, exe: string, pdfPath: string, signal?: AbortSignal): Promise<string> {
|
|
281
|
+
const r = await runCapped(exe, pythonConvertArgs(scriptPath(), pdfPath), {
|
|
282
|
+
timeoutMs: cfg.convertTimeoutMs, capBytes: OUTPUT_MAX_BYTES, signal,
|
|
283
|
+
});
|
|
284
|
+
if (r.timedOut || r.capped || r.code !== 0) {
|
|
285
|
+
throw new Error(`code=${r.code} timedOut=${r.timedOut} capped=${r.capped}: ${r.stderr.slice(0, 500)}`);
|
|
286
|
+
}
|
|
287
|
+
return r.stdout;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function convertViaUnpdf(pdfPath: string, cfg: DocToMdConfig, signal?: AbortSignal): Promise<string> {
|
|
291
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
292
|
+
const buf = readFileSync(pdfPath);
|
|
293
|
+
const work = (async () => {
|
|
294
|
+
const pdf = await getDocumentProxy(new Uint8Array(buf));
|
|
295
|
+
const { text } = await extractText(pdf, { mergePages: false });
|
|
296
|
+
const md = pagesToMarkdown(text);
|
|
297
|
+
if (Buffer.byteLength(md, "utf8") > OUTPUT_MAX_BYTES) throw new Error("unpdf output exceeded cap");
|
|
298
|
+
return md;
|
|
299
|
+
})();
|
|
300
|
+
let timer: NodeJS.Timeout;
|
|
301
|
+
const timeout = new Promise<never>((_, rej) => {
|
|
302
|
+
timer = setTimeout(() => rej(new Error("unpdf conversion timed out")), cfg.convertTimeoutMs);
|
|
303
|
+
});
|
|
304
|
+
try { return await Promise.race([work, timeout]); } finally { clearTimeout(timer!); }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// --- Backend resolver ---
|
|
308
|
+
|
|
309
|
+
export const PROBE_PROGRAM = `import sys
|
|
310
|
+
print("PY", sys.version_info[0], sys.version_info[1])
|
|
311
|
+
try:
|
|
312
|
+
import pymupdf4llm
|
|
313
|
+
print("PKG", pymupdf4llm.__version__)
|
|
314
|
+
except Exception:
|
|
315
|
+
print("PKG", "none")
|
|
316
|
+
`;
|
|
317
|
+
export const PROBE_TIMEOUT_MS = 5000;
|
|
318
|
+
|
|
319
|
+
export function probeArgs(): string[] {
|
|
320
|
+
return ["-c", PROBE_PROGRAM];
|
|
321
|
+
}
|
|
322
|
+
export const PYTHON_CANDIDATES = ["python3", "python"] as const;
|
|
323
|
+
|
|
324
|
+
export type BackendKind = "uv" | "python" | "venv" | "unpdf";
|
|
325
|
+
export type Backend =
|
|
326
|
+
| { kind: "uv" }
|
|
327
|
+
| { kind: "python"; exe: string; version: string }
|
|
328
|
+
| { kind: "venv"; exe: string; version: string }
|
|
329
|
+
| { kind: "none"; reason: string };
|
|
330
|
+
|
|
331
|
+
export interface ProbeResult { major: number; minor: number; pkg: string | null; }
|
|
332
|
+
|
|
333
|
+
export function parseProbeOutput(stdout: string): ProbeResult | null {
|
|
334
|
+
const m = stdout.match(/^PY (\d+) (\d+)\r?\nPKG (\S+)\s*$/);
|
|
335
|
+
if (!m) return null;
|
|
336
|
+
return { major: Number(m[1]), minor: Number(m[2]), pkg: m[3] === "none" ? null : m[3] };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function meetsFloor(p: ProbeResult): boolean {
|
|
340
|
+
return p.major > 3 || (p.major === 3 && p.minor >= 12);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function cacheDir(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, home: string): string {
|
|
344
|
+
if (platform === "win32") return join(env.LOCALAPPDATA ?? join(home, "AppData", "Local"), "pi-quiver");
|
|
345
|
+
if (platform === "darwin") return join(home, "Library", "Caches", "pi-quiver");
|
|
346
|
+
return join(env.XDG_CACHE_HOME ?? join(home, ".cache"), "pi-quiver");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function venvPython(venvDir: string, platform: NodeJS.Platform): string {
|
|
350
|
+
return platform === "win32" ? join(venvDir, "Scripts", "python.exe") : join(venvDir, "bin", "python");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function pythonConvertArgs(script: string, pdfPath: string): string[] {
|
|
354
|
+
return [script, pdfPath];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export type RunFn = (cmd: string, args: string[], opts: { timeoutMs: number; capBytes: number; env?: NodeJS.ProcessEnv; signal?: AbortSignal }) => Promise<CappedResult>;
|
|
358
|
+
|
|
359
|
+
export interface ResolverDeps {
|
|
360
|
+
run: RunFn;
|
|
361
|
+
cacheRoot: string;
|
|
362
|
+
platform: NodeJS.Platform;
|
|
363
|
+
pid: number;
|
|
364
|
+
rename: (from: string, to: string) => void;
|
|
365
|
+
rmrf: (path: string) => void;
|
|
366
|
+
now: () => number;
|
|
367
|
+
env?: NodeJS.ProcessEnv;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const tail = (s: string) => s.slice(-500).trim();
|
|
371
|
+
|
|
372
|
+
export async function resolveBackend(cfg: DocToMdConfig, deps: ResolverDeps, signal?: AbortSignal): Promise<Backend> {
|
|
373
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
374
|
+
const warm = await deps.run("uv", warmArgs(cfg), { timeoutMs: cfg.warmTimeoutMs, capBytes: OUTPUT_MAX_BYTES, env: deps.env, signal });
|
|
375
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
376
|
+
if (warm.code === 0 && !warm.timedOut) return { kind: "uv" };
|
|
377
|
+
const uvAbsent = warm.code === null && !warm.timedOut; // spawn error (ENOENT)
|
|
378
|
+
|
|
379
|
+
const probe = async (exe: string): Promise<ProbeResult | null> => {
|
|
380
|
+
const r = await deps.run(exe, probeArgs(), { timeoutMs: PROBE_TIMEOUT_MS, capBytes: 4000, env: deps.env, signal });
|
|
381
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
382
|
+
if (r.code !== 0 || r.timedOut) return null;
|
|
383
|
+
return parseProbeOutput(r.stdout);
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
let eligible: { exe: string; version: string } | null = null;
|
|
387
|
+
for (const exe of PYTHON_CANDIDATES) {
|
|
388
|
+
const p = await probe(exe);
|
|
389
|
+
if (!p || !meetsFloor(p)) continue;
|
|
390
|
+
if (p.pkg) return { kind: "python", exe, version: p.pkg };
|
|
391
|
+
eligible ??= { exe, version: `${p.major}.${p.minor}` };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const venvDir = join(deps.cacheRoot, "pymupdf-venv");
|
|
395
|
+
const venvExe = venvPython(venvDir, deps.platform);
|
|
396
|
+
const cached = await probe(venvExe);
|
|
397
|
+
if (cached && meetsFloor(cached) && cached.pkg) return { kind: "venv", exe: venvExe, version: cached.pkg };
|
|
398
|
+
|
|
399
|
+
if (eligible) {
|
|
400
|
+
const recheck = await probe(venvExe); // a competing process may have published since the first probe
|
|
401
|
+
if (recheck && meetsFloor(recheck) && recheck.pkg) return { kind: "venv", exe: venvExe, version: recheck.pkg };
|
|
402
|
+
// Build in a sibling tmp dir without touching venvDir - a concurrent process can never probe a half-built venv.
|
|
403
|
+
const tmp = `${venvDir}.tmp-${deps.pid}`;
|
|
404
|
+
const deadline = deps.now() + cfg.warmTimeoutMs;
|
|
405
|
+
const left = () => Math.max(1, deadline - deps.now());
|
|
406
|
+
const bootFail = (stderr: string): Backend => {
|
|
407
|
+
deps.rmrf(tmp);
|
|
408
|
+
return { kind: "none", reason: `python ${eligible!.version} found but venv bootstrap failed: ${tail(stderr)} - install python3-venv, or uv` };
|
|
409
|
+
};
|
|
410
|
+
const mk = await deps.run(eligible.exe, ["-m", "venv", tmp], { timeoutMs: left(), capBytes: OUTPUT_MAX_BYTES, env: deps.env, signal });
|
|
411
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
412
|
+
if (mk.code !== 0 || mk.timedOut) return bootFail(mk.stderr);
|
|
413
|
+
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 });
|
|
414
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
415
|
+
if (pip.code !== 0 || pip.timedOut) return bootFail(pip.stderr);
|
|
416
|
+
const publish = (): boolean => {
|
|
417
|
+
try { deps.rename(tmp, venvDir); return true; } catch { return false; }
|
|
418
|
+
};
|
|
419
|
+
if (publish()) return { kind: "venv", exe: venvExe, version: cfg.pymupdfVersion };
|
|
420
|
+
// Rename failed - a competing process may have published first, or venvDir holds a stale/broken dir.
|
|
421
|
+
const winner = await probe(venvExe);
|
|
422
|
+
if (winner && meetsFloor(winner) && winner.pkg) {
|
|
423
|
+
deps.rmrf(tmp); // healthy winner - clean up our loser
|
|
424
|
+
return { kind: "venv", exe: venvExe, version: winner.pkg };
|
|
425
|
+
}
|
|
426
|
+
deps.rmrf(venvDir); // unhealthy/absent dest - clear it and retry the rename once
|
|
427
|
+
if (publish()) return { kind: "venv", exe: venvExe, version: cfg.pymupdfVersion };
|
|
428
|
+
return bootFail("rename after competing bootstrap");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return uvAbsent
|
|
432
|
+
? { kind: "none", reason: "uv not found; no python >= 3.12 on PATH - install uv, or Python 3.12+" }
|
|
433
|
+
: { kind: "none", reason: `uv warm-up failed: ${tail(warm.stderr)}; no python >= 3.12 on PATH` };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
let backendPromise: Promise<Backend> | null = null;
|
|
437
|
+
|
|
438
|
+
function realDeps(): ResolverDeps {
|
|
439
|
+
return {
|
|
440
|
+
run: runCapped,
|
|
441
|
+
cacheRoot: cacheDir(process.platform, process.env, homedir()),
|
|
442
|
+
platform: process.platform,
|
|
443
|
+
pid: process.pid,
|
|
444
|
+
rename: renameSync,
|
|
445
|
+
rmrf: (p) => rmSync(p, { recursive: true, force: true }),
|
|
446
|
+
now: Date.now,
|
|
447
|
+
env: process.env,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function getBackend(cfg: DocToMdConfig, deps?: ResolverDeps, signal?: AbortSignal): Promise<Backend> {
|
|
452
|
+
if (!backendPromise) {
|
|
453
|
+
const promise = resolveBackend(cfg, deps ?? realDeps(), signal);
|
|
454
|
+
backendPromise = promise;
|
|
455
|
+
// An aborted (or otherwise failed) first resolution must not poison the session for later callers.
|
|
456
|
+
promise.catch(() => { if (backendPromise === promise) backendPromise = null; });
|
|
457
|
+
}
|
|
458
|
+
return backendPromise;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function resetBackendCacheForTests(): void {
|
|
462
|
+
backendPromise = null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export async function convertOffice(
|
|
466
|
+
cfg: DocToMdConfig,
|
|
467
|
+
src: string,
|
|
468
|
+
signal?: AbortSignal,
|
|
469
|
+
run: RunFn = runCapped,
|
|
470
|
+
): Promise<{ pdfPath: string; cleanup: () => void }> {
|
|
471
|
+
const profileDir = mkdtempSync(join(tmpdir(), "pi-doc-soffice-prof-"));
|
|
472
|
+
const outDir = mkdtempSync(join(tmpdir(), "pi-doc-soffice-out-"));
|
|
473
|
+
const cleanup = () => {
|
|
474
|
+
for (const d of [profileDir, outDir]) {
|
|
475
|
+
try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
try {
|
|
479
|
+
const env = { ...process.env, SAL_USE_VCLPLUGIN: "svp", OOO_DISABLE_RECOVERY: "1", SAL_NO_MOUSEGRABS: "1" };
|
|
480
|
+
const r = await run("soffice", soffArgs(src, profileDir, outDir), { timeoutMs: cfg.sofficeTimeoutMs, capBytes: OUTPUT_MAX_BYTES, env, signal });
|
|
481
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
482
|
+
if (r.code === null && !r.timedOut) {
|
|
483
|
+
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.");
|
|
484
|
+
}
|
|
485
|
+
if (r.timedOut || r.code !== 0) throw new Error(`soffice failed (code=${r.code} timedOut=${r.timedOut}): ${r.stderr.slice(0, 500)}`);
|
|
486
|
+
const base = basename(src).replace(/\.[^.]+$/, "");
|
|
487
|
+
const pdfPath = join(outDir, `${base}.pdf`);
|
|
488
|
+
const st = statSync(pdfPath, { throwIfNoEntry: false });
|
|
489
|
+
if (!st || !st.isFile() || st.size === 0) {
|
|
490
|
+
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.");
|
|
491
|
+
}
|
|
492
|
+
return { pdfPath, cleanup };
|
|
493
|
+
} catch (e) { cleanup(); throw e; }
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export interface PipelineSeams {
|
|
497
|
+
backend: (cfg: DocToMdConfig) => Promise<Backend>;
|
|
498
|
+
convertPymupdf: (cfg: DocToMdConfig, backend: Backend, pdfPath: string, signal?: AbortSignal) => Promise<string>;
|
|
499
|
+
convertUnpdf: (pdfPath: string, cfg: DocToMdConfig, signal?: AbortSignal) => Promise<string>;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export async function runPipeline(
|
|
503
|
+
cfg: DocToMdConfig,
|
|
504
|
+
inputPath: string,
|
|
505
|
+
type: InputType,
|
|
506
|
+
signal?: AbortSignal,
|
|
507
|
+
seams?: Partial<PipelineSeams>,
|
|
508
|
+
): Promise<{ markdown: string; engine: Engine; degraded: boolean; fallbackReason?: string; backend: BackendKind; pymupdfVersion?: string }> {
|
|
509
|
+
const s: PipelineSeams = {
|
|
510
|
+
backend: (c) => getBackend(c, undefined, signal),
|
|
511
|
+
convertPymupdf: (c, b, p, sig) => (b.kind === "uv" ? convertViaUv(c, p, sig) : convertViaPython(c, (b as { exe: string }).exe, p, sig)),
|
|
512
|
+
convertUnpdf: convertViaUnpdf,
|
|
513
|
+
...seams,
|
|
514
|
+
};
|
|
515
|
+
let pdfPath = inputPath;
|
|
516
|
+
let cleanup: (() => void) | null = null;
|
|
517
|
+
if (type === "docx" || type === "pptx") {
|
|
518
|
+
const o = await convertOffice(cfg, inputPath, signal);
|
|
519
|
+
pdfPath = o.pdfPath;
|
|
520
|
+
cleanup = o.cleanup;
|
|
521
|
+
}
|
|
522
|
+
try {
|
|
523
|
+
const backend = await s.backend(cfg);
|
|
524
|
+
if (backend.kind === "none") {
|
|
525
|
+
return { markdown: await s.convertUnpdf(pdfPath, cfg, signal), engine: "unpdf", degraded: true, fallbackReason: backend.reason, backend: "unpdf" };
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
const markdown = await s.convertPymupdf(cfg, backend, pdfPath, signal);
|
|
529
|
+
return {
|
|
530
|
+
markdown, engine: "pymupdf4llm", degraded: false, backend: backend.kind,
|
|
531
|
+
...(backend.kind === "python" || backend.kind === "venv" ? { pymupdfVersion: backend.version } : {}),
|
|
532
|
+
};
|
|
533
|
+
} catch (e) {
|
|
534
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
535
|
+
return {
|
|
536
|
+
markdown: await s.convertUnpdf(pdfPath, cfg, signal), engine: "unpdf", degraded: true,
|
|
537
|
+
fallbackReason: `pymupdf4llm (${backend.kind}) conversion failed: ${msg} - fell back to unpdf`, backend: "unpdf",
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
} finally {
|
|
541
|
+
cleanup?.();
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// --- Public entry point ---
|
|
546
|
+
|
|
547
|
+
export interface ConvertOutcome {
|
|
548
|
+
output: string;
|
|
549
|
+
details: DocToMdDetails;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export async function convertDocument(
|
|
553
|
+
path: string,
|
|
554
|
+
cfg: DocToMdConfig,
|
|
555
|
+
signal?: AbortSignal,
|
|
556
|
+
seams?: Partial<PipelineSeams>,
|
|
557
|
+
): Promise<ConvertOutcome> {
|
|
558
|
+
const inputPath = resolve(path);
|
|
559
|
+
const st = statSync(inputPath, { throwIfNoEntry: false });
|
|
560
|
+
if (!st || !st.isFile()) throw new Error(`Not a readable file: ${path}`);
|
|
561
|
+
const type = classifyInput(inputPath);
|
|
562
|
+
const { markdown, engine, degraded, fallbackReason, backend, pymupdfVersion } = await runPipeline(cfg, inputPath, type, signal, seams);
|
|
563
|
+
// The formatter's output carries no trailing newline (the CLI appends exactly one); engines vary here, so normalize once.
|
|
564
|
+
const body = withMarker(markdown, degraded).replace(/\s+$/, "");
|
|
565
|
+
const { spill, bytes, lines } = applyGate(body);
|
|
566
|
+
const details: DocToMdDetails = { path: inputPath, inputType: type, engine, backend, ...(pymupdfVersion !== undefined ? { pymupdfVersion } : {}), degraded, bytes, lines, spilled: spill };
|
|
567
|
+
const header: string[] = [
|
|
568
|
+
`Source: ${inputPath}`,
|
|
569
|
+
`Type: ${type} Engine: ${engine}${degraded ? " (degraded fallback)" : ""}`,
|
|
570
|
+
];
|
|
571
|
+
if (fallbackReason) header.push(`Fallback-Reason: ${fallbackReason}`);
|
|
572
|
+
// The formatter's output carries no trailing newline (the CLI appends exactly one); strip once more on the composed string, since an empty body leaves a trailing blank-separator newline.
|
|
573
|
+
const compose = (parts: string[]) => parts.join("\n").replace(/\n+$/, "");
|
|
574
|
+
if (!spill) {
|
|
575
|
+
return { output: compose([...header, `Length: ${bytes} bytes, ${lines} lines`, "", body]), details };
|
|
576
|
+
}
|
|
577
|
+
const file = spillToFile(inputPath, body);
|
|
578
|
+
return {
|
|
579
|
+
output: compose([
|
|
580
|
+
...header,
|
|
581
|
+
`Body: ${formatSize(bytes)} across ${lines} lines — written to file (too large to inline)`,
|
|
582
|
+
`Saved-To: ${file}`,
|
|
583
|
+
"",
|
|
584
|
+
"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 (^#).",
|
|
585
|
+
"",
|
|
586
|
+
"----- preview (first 60 lines) -----",
|
|
587
|
+
buildPreview(body),
|
|
588
|
+
]),
|
|
589
|
+
details: { ...details, spilled: true, file },
|
|
590
|
+
};
|
|
591
|
+
}
|
package/lib/fetch-core.ts
CHANGED
|
@@ -29,7 +29,8 @@ export type GhTarget =
|
|
|
29
29
|
| { kind: "issue"; url: string }
|
|
30
30
|
| { kind: "pr"; url: string }
|
|
31
31
|
| { kind: "repo"; slug: string }
|
|
32
|
-
| { kind: "run"; slug: string; runId: string; url: string }
|
|
32
|
+
| { kind: "run"; slug: string; runId: string; url: string }
|
|
33
|
+
| { kind: "job"; slug: string; jobId: string; url: string };
|
|
33
34
|
|
|
34
35
|
export function formatSize(bytes: number): string {
|
|
35
36
|
if (bytes < 1024) {
|
|
@@ -80,6 +81,9 @@ export function classifyGitHubTarget(url: URL): GhTarget | null {
|
|
|
80
81
|
if (segs.length === 5 && segs[2] === "actions" && segs[3] === "runs" && /^\d+$/.test(segs[4])) {
|
|
81
82
|
return { kind: "run", slug: `${owner}/${repo}`, runId: segs[4], url: `https://github.com/${owner}/${repo}/actions/runs/${segs[4]}` };
|
|
82
83
|
}
|
|
84
|
+
if (segs.length === 7 && segs[2] === "actions" && segs[3] === "runs" && /^\d+$/.test(segs[4]) && segs[5] === "job" && /^\d+$/.test(segs[6])) {
|
|
85
|
+
return { kind: "job", slug: `${owner}/${repo}`, jobId: segs[6], url: `https://github.com/${owner}/${repo}/actions/runs/${segs[4]}/job/${segs[6]}` };
|
|
86
|
+
}
|
|
83
87
|
if (segs.length === 2) {
|
|
84
88
|
return { kind: "repo", slug: `${owner}/${repo}` };
|
|
85
89
|
}
|
|
@@ -90,9 +94,16 @@ export function buildGhArgs(target: GhTarget): string[] {
|
|
|
90
94
|
if (target.kind === "issue") return ["issue", "view", target.url, "--comments"];
|
|
91
95
|
if (target.kind === "pr") return ["pr", "view", target.url, "--comments"];
|
|
92
96
|
if (target.kind === "run") return ["run", "view", target.runId, "--repo", target.slug];
|
|
97
|
+
if (target.kind === "job") return ["run", "view", "--job", target.jobId, "--repo", target.slug];
|
|
93
98
|
return ["repo", "view", target.slug];
|
|
94
99
|
}
|
|
95
100
|
|
|
101
|
+
export function buildGhLogArgs(target: GhTarget): string[] | null {
|
|
102
|
+
if (target.kind === "run") return ["run", "view", target.runId, "--log-failed", "--repo", target.slug];
|
|
103
|
+
if (target.kind === "job") return ["run", "view", "--job", target.jobId, "--log-failed", "--repo", target.slug];
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
96
107
|
const GH_MAX_BUFFER = 10_000_000; // 10 MB — an order above PARSABLE_MAX_BYTES
|
|
97
108
|
|
|
98
109
|
type GhResult = { ok: true; stdout: string } | { ok: false };
|
|
@@ -135,6 +146,7 @@ function ghCommandLabel(target: GhTarget): string {
|
|
|
135
146
|
if (target.kind === "issue") return "issue view --comments";
|
|
136
147
|
if (target.kind === "pr") return "pr view --comments";
|
|
137
148
|
if (target.kind === "run") return "run view";
|
|
149
|
+
if (target.kind === "job") return "run view --job";
|
|
138
150
|
return "repo view";
|
|
139
151
|
}
|
|
140
152
|
|
|
@@ -142,11 +154,14 @@ function ghSourceLine(target: GhTarget, ref: string): string {
|
|
|
142
154
|
if (target.kind === "issue") return `gh issue view ${ref} --comments`;
|
|
143
155
|
if (target.kind === "pr") return `gh pr view ${ref} --comments`;
|
|
144
156
|
if (target.kind === "run") return `gh run view ${target.runId} --repo ${target.slug}`;
|
|
157
|
+
if (target.kind === "job") return `gh run view --job ${target.jobId} --repo ${target.slug}`;
|
|
145
158
|
return `gh repo view ${ref}`;
|
|
146
159
|
}
|
|
147
160
|
|
|
148
|
-
function renderGhResult(target: GhTarget, stdout: string): FetchResult {
|
|
149
|
-
const body =
|
|
161
|
+
function renderGhResult(target: GhTarget, stdout: string, failedLogs?: string): FetchResult {
|
|
162
|
+
const body = failedLogs !== undefined
|
|
163
|
+
? `${stdout.trimEnd()}\n\n## Failed step logs\n\n${failedLogs.trimEnd()}`
|
|
164
|
+
: stdout.trimEnd();
|
|
150
165
|
const ref = target.kind === "repo" ? target.slug : target.url;
|
|
151
166
|
const { spill, bytes, lines } = applyGate(body);
|
|
152
167
|
const baseDetails: FetchToolDetails = {
|
|
@@ -157,7 +172,9 @@ function renderGhResult(target: GhTarget, stdout: string): FetchResult {
|
|
|
157
172
|
via: "gh",
|
|
158
173
|
ghCommand: ghCommandLabel(target),
|
|
159
174
|
};
|
|
160
|
-
const source =
|
|
175
|
+
const source = failedLogs !== undefined
|
|
176
|
+
? `Source: ${ghSourceLine(target, ref)}\nSource: gh ${buildGhLogArgs(target)!.join(" ")}`
|
|
177
|
+
: `Source: ${ghSourceLine(target, ref)}`;
|
|
161
178
|
if (!spill) {
|
|
162
179
|
return {
|
|
163
180
|
output: [source, "", body].join("\n"),
|
|
@@ -189,9 +206,19 @@ export async function executeGhRouting(
|
|
|
189
206
|
): Promise<FetchResult | null> {
|
|
190
207
|
const target = planGhRouting(params, url);
|
|
191
208
|
if (!target) return null;
|
|
192
|
-
const
|
|
209
|
+
const timeoutMs = params.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
210
|
+
signal?.throwIfAborted();
|
|
211
|
+
const gh = await runner(buildGhArgs(target), timeoutMs, signal);
|
|
212
|
+
signal?.throwIfAborted();
|
|
193
213
|
if (!gh.ok) return null;
|
|
194
|
-
|
|
214
|
+
const logArgs = buildGhLogArgs(target);
|
|
215
|
+
let failedLogs: string | undefined;
|
|
216
|
+
if (logArgs) {
|
|
217
|
+
const logs = await runner(logArgs, timeoutMs, signal);
|
|
218
|
+
signal?.throwIfAborted();
|
|
219
|
+
if (logs.ok && logs.stdout !== "") failedLogs = logs.stdout;
|
|
220
|
+
}
|
|
221
|
+
return renderGhResult(target, gh.stdout, failedLogs);
|
|
195
222
|
}
|
|
196
223
|
|
|
197
224
|
const PARSABLE_MAX_BYTES = 1_000_000; // text/markdown/json download ceiling
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-quiver",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"description": "Personal pack of Pi coding-agent extensions: context-safe fetch, doc_to_md PDF/DOCX/PPTX-to-Markdown conversion, session naming, a themed ASCII startup header, Opus 4.8 fast mode, and a provider-stall watchdog.",
|
|
5
5
|
"author": "Jacek Juraszek",
|
|
6
6
|
"license": "MIT",
|