pi-quiver 6.3.1 → 6.5.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 +9 -0
- package/README.md +16 -10
- package/dist/bin/pi-quiver.js +184 -102
- package/extensions/doc_to_md.ts +1 -1
- package/extensions/provider-stall-watchdog.ts +70 -17
- package/lib/doc-to-md-core.ts +137 -87
- package/lib/doc-to-md-handle.ts +29 -11
- package/lib/doc-to-md-options.ts +3 -3
- package/lib/extension-config.ts +1 -1
- package/package.json +1 -1
- package/scripts/doc_to_md.py +288 -6
|
@@ -7,14 +7,20 @@ export const DEFAULT_CONFIG = {
|
|
|
7
7
|
firstEventMs: 20_000,
|
|
8
8
|
warningMs: 120_000,
|
|
9
9
|
recoveryMs: 240_000,
|
|
10
|
+
models: {},
|
|
10
11
|
} as const;
|
|
11
12
|
|
|
12
|
-
export type
|
|
13
|
-
enabled: boolean;
|
|
13
|
+
export type WatchdogThresholds = {
|
|
14
14
|
firstEventMs: number;
|
|
15
15
|
warningMs: number;
|
|
16
16
|
recoveryMs: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type WatchdogConfig = WatchdogThresholds & {
|
|
20
|
+
enabled: boolean;
|
|
17
21
|
maxStallRetries: number;
|
|
22
|
+
/** Per-model threshold overrides keyed by glob; see thresholdsFor. */
|
|
23
|
+
models: Record<string, Partial<WatchdogThresholds>>;
|
|
18
24
|
};
|
|
19
25
|
|
|
20
26
|
export type WatchdogRuntime = {
|
|
@@ -30,6 +36,7 @@ export type ConfigCandidate = {
|
|
|
30
36
|
warningMs?: unknown;
|
|
31
37
|
recoveryMs?: unknown;
|
|
32
38
|
maxStallRetries?: unknown;
|
|
39
|
+
models?: unknown;
|
|
33
40
|
};
|
|
34
41
|
|
|
35
42
|
export type ConfigValidation =
|
|
@@ -45,12 +52,17 @@ export function coerce(raw: unknown): ConfigCandidate | undefined {
|
|
|
45
52
|
|
|
46
53
|
const source = raw as Record<string, unknown>;
|
|
47
54
|
const candidate: ConfigCandidate = { blockIsObject: true };
|
|
48
|
-
for (const key of ["enabled", "firstEventMs", "warningMs", "recoveryMs", "maxStallRetries"] as const) {
|
|
55
|
+
for (const key of ["enabled", "firstEventMs", "warningMs", "recoveryMs", "maxStallRetries", "models"] as const) {
|
|
49
56
|
if (Object.hasOwn(source, key)) candidate[key] = source[key];
|
|
50
57
|
}
|
|
51
58
|
return candidate;
|
|
52
59
|
}
|
|
53
60
|
|
|
61
|
+
const THRESHOLD_KEYS = ["firstEventMs", "warningMs", "recoveryMs"] as const;
|
|
62
|
+
|
|
63
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
64
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
65
|
+
|
|
54
66
|
export function validateConfig(candidate: ConfigCandidate): ConfigValidation {
|
|
55
67
|
if (candidate.blockIsObject !== true) return { ok: false, error: "providerStallWatchdog must be an object" };
|
|
56
68
|
if (typeof candidate.enabled !== "boolean") return { ok: false, error: "enabled must be a boolean" };
|
|
@@ -59,6 +71,21 @@ export function validateConfig(candidate: ConfigCandidate): ConfigValidation {
|
|
|
59
71
|
if (!isTimerDelay(candidate.recoveryMs)) return { ok: false, error: "recoveryMs must be a positive timer delay" };
|
|
60
72
|
if (candidate.warningMs >= candidate.recoveryMs) return { ok: false, error: "warningMs must be less than recoveryMs" };
|
|
61
73
|
if (!isNonNegativeInteger(candidate.maxStallRetries)) return { ok: false, error: "maxStallRetries must be a non-negative integer" };
|
|
74
|
+
if (!isRecord(candidate.models)) return { ok: false, error: "models must be an object" };
|
|
75
|
+
for (const [pattern, override] of Object.entries(candidate.models)) {
|
|
76
|
+
if (!isRecord(override)) return { ok: false, error: `models["${pattern}"] must be an object` };
|
|
77
|
+
for (const key of Object.keys(override)) {
|
|
78
|
+
if (!(THRESHOLD_KEYS as readonly string[]).includes(key)) {
|
|
79
|
+
return { ok: false, error: `models["${pattern}"] has unknown key "${key}"; accepted: ${THRESHOLD_KEYS.join(", ")}` };
|
|
80
|
+
}
|
|
81
|
+
if (!isTimerDelay(override[key])) return { ok: false, error: `models["${pattern}"].${key} must be a positive timer delay` };
|
|
82
|
+
}
|
|
83
|
+
const mergedWarning = (override.warningMs ?? candidate.warningMs) as number;
|
|
84
|
+
const mergedRecovery = (override.recoveryMs ?? candidate.recoveryMs) as number;
|
|
85
|
+
if (mergedWarning >= mergedRecovery) {
|
|
86
|
+
return { ok: false, error: `models["${pattern}"] leaves warningMs (${mergedWarning}) >= recoveryMs (${mergedRecovery})` };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
62
89
|
return {
|
|
63
90
|
ok: true,
|
|
64
91
|
config: {
|
|
@@ -67,6 +94,7 @@ export function validateConfig(candidate: ConfigCandidate): ConfigValidation {
|
|
|
67
94
|
warningMs: candidate.warningMs,
|
|
68
95
|
recoveryMs: candidate.recoveryMs,
|
|
69
96
|
maxStallRetries: candidate.maxStallRetries,
|
|
97
|
+
models: candidate.models as Record<string, Partial<WatchdogThresholds>>,
|
|
70
98
|
},
|
|
71
99
|
};
|
|
72
100
|
}
|
|
@@ -144,20 +172,41 @@ function formatElapsed(ms: number): string {
|
|
|
144
172
|
|
|
145
173
|
const ABORT_STUCK_NOTICE = `The stalled request did not stop within ${formatElapsed(ABORT_GRACE_MS)} of being aborted; the provider connection is unresponsive. No automatic retry will run - the turn will not end until the HTTP idle timeout expires.`;
|
|
146
174
|
|
|
147
|
-
function warningNotice(
|
|
148
|
-
return `No model progress for ${formatElapsed(
|
|
175
|
+
function warningNotice(thresholds: WatchdogThresholds): string {
|
|
176
|
+
return `No model progress for ${formatElapsed(thresholds.warningMs)}; aborting and asking Pi to retry in ${formatElapsed(thresholds.recoveryMs - thresholds.warningMs)} (Esc aborts now)`;
|
|
149
177
|
}
|
|
150
178
|
|
|
151
179
|
function exhaustedNotice(config: WatchdogConfig): string {
|
|
152
180
|
return `Stall retry budget (${config.maxStallRetries}) exhausted; aborting without another automatic retry. Submit the message again manually.`;
|
|
153
181
|
}
|
|
154
182
|
|
|
155
|
-
function firstEventRetryNotice(
|
|
156
|
-
return `Provider sent no response for ${formatElapsed(
|
|
183
|
+
function firstEventRetryNotice(thresholds: WatchdogThresholds): string {
|
|
184
|
+
return `Provider sent no response for ${formatElapsed(thresholds.firstEventMs)}; stopping and retrying the request.`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function firstEventExhaustedNotice(thresholds: WatchdogThresholds): string {
|
|
188
|
+
return `Provider sent no response for ${formatElapsed(thresholds.firstEventMs)} and the stall-retry budget is spent; the request was stopped.`;
|
|
157
189
|
}
|
|
158
190
|
|
|
159
|
-
|
|
160
|
-
|
|
191
|
+
/** A glob where `*` matches any run of characters; matching is case-insensitive. */
|
|
192
|
+
function modelPattern(pattern: string): RegExp {
|
|
193
|
+
const source = pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
|
|
194
|
+
return new RegExp(`^${source}$`, "i");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Effective thresholds for one request: the base knobs overlaid with the
|
|
199
|
+
* first `models` entry whose glob matches the request's `provider/model`
|
|
200
|
+
* label. First match wins; later entries for the same model are ignored.
|
|
201
|
+
*/
|
|
202
|
+
export function thresholdsFor(config: WatchdogConfig, model: { provider: string; id: string } | undefined): WatchdogThresholds {
|
|
203
|
+
const base: WatchdogThresholds = { firstEventMs: config.firstEventMs, warningMs: config.warningMs, recoveryMs: config.recoveryMs };
|
|
204
|
+
if (model === undefined) return base;
|
|
205
|
+
const label = `${model.provider}/${model.id}`;
|
|
206
|
+
for (const [pattern, override] of Object.entries(config.models)) {
|
|
207
|
+
if (modelPattern(pattern).test(label)) return { ...base, ...override };
|
|
208
|
+
}
|
|
209
|
+
return base;
|
|
161
210
|
}
|
|
162
211
|
|
|
163
212
|
export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRuntime): (pi: ExtensionAPI) => void {
|
|
@@ -171,6 +220,7 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
|
|
|
171
220
|
let config: WatchdogConfig | undefined;
|
|
172
221
|
let generation = 0;
|
|
173
222
|
let activeGeneration: number | undefined;
|
|
223
|
+
let activeModel: { provider: string; id: string } | undefined;
|
|
174
224
|
let lastSemanticAt = 0;
|
|
175
225
|
let warned = false;
|
|
176
226
|
let deadlineEpoch = 0;
|
|
@@ -281,9 +331,10 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
|
|
|
281
331
|
const armFirstEvent = (ctx: { abort(): void }) => {
|
|
282
332
|
if (activeGeneration === undefined || !config) return;
|
|
283
333
|
const cfg = config;
|
|
334
|
+
const thresholds = thresholdsFor(cfg, activeModel);
|
|
284
335
|
const capturedGeneration = activeGeneration;
|
|
285
336
|
const capturedDeadlineEpoch = ++deadlineEpoch;
|
|
286
|
-
const threshold =
|
|
337
|
+
const threshold = thresholds.firstEventMs;
|
|
287
338
|
const run = () => {
|
|
288
339
|
if (capturedGeneration !== activeGeneration || capturedDeadlineEpoch !== deadlineEpoch || !activeRun || firstEventSeen) return;
|
|
289
340
|
const elapsed = runtime.now() - lastSemanticAt;
|
|
@@ -292,15 +343,16 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
|
|
|
292
343
|
return;
|
|
293
344
|
}
|
|
294
345
|
abortStall(ctx, capturedGeneration, {
|
|
295
|
-
retry: () => firstEventRetryNotice(
|
|
296
|
-
exhausted: () => firstEventExhaustedNotice(
|
|
297
|
-
}, `Provider first-event timeout after ${
|
|
346
|
+
retry: () => firstEventRetryNotice(thresholds),
|
|
347
|
+
exhausted: () => firstEventExhaustedNotice(thresholds),
|
|
348
|
+
}, `Provider first-event timeout after ${thresholds.firstEventMs} ms without a stream event`);
|
|
298
349
|
};
|
|
299
350
|
timers.firstEvent = runtime.setTimeout(run, threshold);
|
|
300
351
|
};
|
|
301
352
|
const schedule = (ctx: { abort(): void }) => {
|
|
302
353
|
if (activeGeneration === undefined || !config) return;
|
|
303
354
|
const cfg = config;
|
|
355
|
+
const thresholds = thresholdsFor(cfg, activeModel);
|
|
304
356
|
const capturedGeneration = activeGeneration;
|
|
305
357
|
const capturedDeadlineEpoch = ++deadlineEpoch;
|
|
306
358
|
const run = (kind: "warning" | "recovery", threshold: number) => () => {
|
|
@@ -312,17 +364,17 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
|
|
|
312
364
|
}
|
|
313
365
|
if (kind === "warning" && !warned) {
|
|
314
366
|
warned = true;
|
|
315
|
-
announce(warningNotice(
|
|
367
|
+
announce(warningNotice(thresholds), "warning");
|
|
316
368
|
}
|
|
317
369
|
if (kind === "recovery") {
|
|
318
370
|
abortStall(ctx, capturedGeneration, {
|
|
319
371
|
retry: () => `No model progress for ${formatElapsed(elapsed)}; aborting now. Pi will retry (${stallRetriesUsed}/${cfg.maxStallRetries}) if retry is enabled and capacity remains. Pending follow-ups are returned to the editor.`,
|
|
320
372
|
exhausted: () => exhaustedNotice(cfg),
|
|
321
|
-
}, `Provider semantic timeout after ${
|
|
373
|
+
}, `Provider semantic timeout after ${thresholds.recoveryMs} ms without progress`);
|
|
322
374
|
}
|
|
323
375
|
};
|
|
324
|
-
timers.warning = runtime.setTimeout(run("warning",
|
|
325
|
-
timers.recovery = runtime.setTimeout(run("recovery",
|
|
376
|
+
timers.warning = runtime.setTimeout(run("warning", thresholds.warningMs), thresholds.warningMs);
|
|
377
|
+
timers.recovery = runtime.setTimeout(run("recovery", thresholds.recoveryMs), thresholds.recoveryMs);
|
|
326
378
|
};
|
|
327
379
|
|
|
328
380
|
pi.on("before_provider_request", (_event, ctx) => {
|
|
@@ -347,6 +399,7 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
|
|
|
347
399
|
if (!activeRun) return;
|
|
348
400
|
disarm();
|
|
349
401
|
activeGeneration = ++generation;
|
|
402
|
+
activeModel = ctx.model;
|
|
350
403
|
lastSemanticAt = runtime.now();
|
|
351
404
|
const target = ctx.signal;
|
|
352
405
|
if (target) {
|
package/lib/doc-to-md-core.ts
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Converts local PDF, DOCX, PPTX, XLSX, and XLS documents into disk bundles
|
|
5
5
|
* with concise handles. PDF uses primary pymupdf4llm, PyMuPDF-text fallback,
|
|
6
|
-
* then unpdf when no Python backend exists. DOCX
|
|
7
|
-
* headless soffice
|
|
6
|
+
* then unpdf when no Python backend exists. DOCX converts directly in the Python child (mammoth, python-docx fallback)
|
|
7
|
+
* and falls back to headless soffice -> PDF; PPTX always goes through soffice.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { existsSync, mkdtempSync, renameSync, rmSync, statSync } from "node:fs";
|
|
10
|
+
import { existsSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
|
|
11
11
|
import { type ChildProcess, spawn } from "node:child_process";
|
|
12
12
|
import { homedir, tmpdir } from "node:os";
|
|
13
13
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
@@ -22,10 +22,10 @@ export type { Engine, HandleData, InfoData, OutlineEntry, SheetInfo, Tier, TocEn
|
|
|
22
22
|
|
|
23
23
|
// --- Types ---
|
|
24
24
|
|
|
25
|
-
export const PACKAGE_PINS = { pymupdf4llm: TUNABLE_DEFAULTS.pymupdfVersion, openpyxl: "3.1.5", xlrd: "2.0.2", pillow: "12.3.0" } as const;
|
|
25
|
+
export const PACKAGE_PINS = { pymupdf4llm: TUNABLE_DEFAULTS.pymupdfVersion, openpyxl: "3.1.5", xlrd: "2.0.2", pillow: "12.3.0", mammoth: "1.13.0", markdownify: "1.2.3", "python-docx": "1.2.0" } as const;
|
|
26
26
|
export const KILL_GRACE_MS = 2000;
|
|
27
|
-
export const VENV_DIR_NAME = "doc-to-md-venv-
|
|
28
|
-
export const
|
|
27
|
+
export const VENV_DIR_NAME = "doc-to-md-venv-v3";
|
|
28
|
+
export const LEGACY_VENV_DIR_NAMES = ["pymupdf-venv", "doc-to-md-venv-v2"] as const;
|
|
29
29
|
const STDERR_CAP = 1_000_000;
|
|
30
30
|
export const OUTPUT_MAX_BYTES = 20_000_000;
|
|
31
31
|
export const EXCEL_PDF_FILTER = 'pdf:calc_pdf_Export:{"SinglePageSheets":{"type":"boolean","value":"true"}}';
|
|
@@ -42,16 +42,14 @@ export interface CappedResult {
|
|
|
42
42
|
|
|
43
43
|
// --- Subprocess argv builders ---
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
return ["--with", `pymupdf4llm==${cfg.pymupdfVersion}`, "--with", `openpyxl==${PACKAGE_PINS.openpyxl}`, "--with", `xlrd==${PACKAGE_PINS.xlrd}`, "--with", `pillow==${PACKAGE_PINS.pillow}`];
|
|
47
|
-
}
|
|
45
|
+
const pinSpecs = (cfg: BackendConfig) => [`pymupdf4llm==${cfg.pymupdfVersion}`, `openpyxl==${PACKAGE_PINS.openpyxl}`, `xlrd==${PACKAGE_PINS.xlrd}`, `pillow==${PACKAGE_PINS.pillow}`, `mammoth==${PACKAGE_PINS.mammoth}`, `markdownify==${PACKAGE_PINS.markdownify}`, `python-docx==${PACKAGE_PINS["python-docx"]}`];
|
|
48
46
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
47
|
+
function withArgs(cfg: BackendConfig): string[] { return pinSpecs(cfg).flatMap((spec) => ["--with", spec]); }
|
|
48
|
+
|
|
49
|
+
export function pipInstallArgs(cfg: BackendConfig): string[] { return ["-m", "pip", "install", ...pinSpecs(cfg)]; }
|
|
52
50
|
|
|
53
51
|
export function warmArgs(cfg: BackendConfig): string[] {
|
|
54
|
-
return ["run", ...withArgs(cfg), "--python", "3.14", "python", "-c", "import pymupdf4llm, openpyxl, xlrd, PIL"];
|
|
52
|
+
return ["run", ...withArgs(cfg), "--python", "3.14", "python", "-c", "import pymupdf4llm, openpyxl, xlrd, PIL, mammoth, markdownify, docx"];
|
|
55
53
|
}
|
|
56
54
|
|
|
57
55
|
export function uvChildArgs(cfg: BackendConfig, script: string, mode: string): string[] {
|
|
@@ -201,6 +199,11 @@ try:
|
|
|
201
199
|
print("XLSX", "yes")
|
|
202
200
|
except Exception:
|
|
203
201
|
print("XLSX", "no")
|
|
202
|
+
try:
|
|
203
|
+
import mammoth, markdownify, docx
|
|
204
|
+
print("DOCX", "yes")
|
|
205
|
+
except Exception:
|
|
206
|
+
print("DOCX", "no")
|
|
204
207
|
`;
|
|
205
208
|
export const PROBE_TIMEOUT_MS = 5000;
|
|
206
209
|
|
|
@@ -211,16 +214,16 @@ export const PYTHON_CANDIDATES = ["python3", "python"] as const;
|
|
|
211
214
|
|
|
212
215
|
export type BackendKind = "uv" | "python" | "venv" | "none";
|
|
213
216
|
export type Backend =
|
|
214
|
-
| { kind: "uv"; pdf: true; xlsx: true }
|
|
215
|
-
| { kind: "python"; exe: string; pdf: boolean; xlsx: boolean }
|
|
216
|
-
| { kind: "venv"; exe: string; pdf: true; xlsx: true }
|
|
217
|
+
| { kind: "uv"; pdf: true; xlsx: true; docx: true }
|
|
218
|
+
| { kind: "python"; exe: string; pdf: boolean; xlsx: boolean; docx: boolean }
|
|
219
|
+
| { kind: "venv"; exe: string; pdf: true; xlsx: true; docx: true }
|
|
217
220
|
| { kind: "none"; reason: string };
|
|
218
221
|
|
|
219
|
-
export interface ProbeResult { major: number; minor: number; pdf: boolean; xlsx: boolean; }
|
|
222
|
+
export interface ProbeResult { major: number; minor: number; pdf: boolean; xlsx: boolean; docx: boolean; }
|
|
220
223
|
|
|
221
224
|
export function parseProbeOutput(stdout: string): ProbeResult | null {
|
|
222
|
-
const m = stdout.match(/^PY (\d+) (\d+)\r?\nPDF (yes|no)\r?\nXLSX (yes|no)\s*$/);
|
|
223
|
-
return m ? { major: Number(m[1]), minor: Number(m[2]), pdf: m[3] === "yes", xlsx: m[4] === "yes" } : null;
|
|
225
|
+
const m = stdout.match(/^PY (\d+) (\d+)\r?\nPDF (yes|no)\r?\nXLSX (yes|no)\r?\nDOCX (yes|no)\s*$/);
|
|
226
|
+
return m ? { major: Number(m[1]), minor: Number(m[2]), pdf: m[3] === "yes", xlsx: m[4] === "yes", docx: m[5] === "yes" } : null;
|
|
224
227
|
}
|
|
225
228
|
|
|
226
229
|
export function meetsFloor(p: ProbeResult): boolean {
|
|
@@ -263,7 +266,7 @@ export async function resolveBackend(cfg: BackendConfig, deps: ResolverDeps, sig
|
|
|
263
266
|
};
|
|
264
267
|
const warm = await deps.run("uv", warmArgs(cfg), { timeoutMs: left(), capBytes: OUTPUT_MAX_BYTES, env: deps.env, signal });
|
|
265
268
|
if (signal?.aborted) throw new Error("aborted");
|
|
266
|
-
if (warm.code === 0 && !warm.timedOut) return { kind: "uv", pdf: true, xlsx: true };
|
|
269
|
+
if (warm.code === 0 && !warm.timedOut) return { kind: "uv", pdf: true, xlsx: true, docx: true };
|
|
267
270
|
const uvAbsent = warm.code === null && !warm.timedOut; // spawn error (ENOENT)
|
|
268
271
|
|
|
269
272
|
type ProbeOutcome = ProbeResult | Extract<Backend, { kind: "none" }> | null;
|
|
@@ -282,20 +285,21 @@ export async function resolveBackend(cfg: BackendConfig, deps: ResolverDeps, sig
|
|
|
282
285
|
const p = await probe(exe);
|
|
283
286
|
if (isDeadline(p)) return p;
|
|
284
287
|
if (!p || !meetsFloor(p)) continue;
|
|
285
|
-
if (p.pdf) return { kind: "python", exe, pdf: true, xlsx: p.xlsx };
|
|
288
|
+
if (p.pdf) return { kind: "python", exe, pdf: true, xlsx: p.xlsx, docx: p.docx };
|
|
286
289
|
eligible ??= { exe, version: `${p.major}.${p.minor}` };
|
|
287
290
|
}
|
|
288
291
|
|
|
292
|
+
const healthy = (p: ProbeResult) => meetsFloor(p) && p.pdf && p.xlsx && p.docx;
|
|
289
293
|
const venvDir = join(deps.cacheRoot, VENV_DIR_NAME);
|
|
290
294
|
const venvExe = venvPython(venvDir, deps.platform);
|
|
291
295
|
const cached = await probe(venvExe);
|
|
292
296
|
if (isDeadline(cached)) return cached;
|
|
293
|
-
if (cached &&
|
|
297
|
+
if (cached && healthy(cached)) return { kind: "venv", exe: venvExe, pdf: true, xlsx: true, docx: true };
|
|
294
298
|
|
|
295
299
|
if (eligible) {
|
|
296
300
|
const recheck = await probe(venvExe); // a competing process may have published since the first probe
|
|
297
301
|
if (isDeadline(recheck)) return recheck;
|
|
298
|
-
if (recheck &&
|
|
302
|
+
if (recheck && healthy(recheck)) return { kind: "venv", exe: venvExe, pdf: true, xlsx: true, docx: true };
|
|
299
303
|
// Build in a sibling tmp dir without touching venvDir - a concurrent process can never probe a half-built venv.
|
|
300
304
|
const tmp = `${venvDir}.tmp-${deps.pid}`;
|
|
301
305
|
const bootFail = (stderr: string): Backend => {
|
|
@@ -316,20 +320,20 @@ export async function resolveBackend(cfg: BackendConfig, deps: ResolverDeps, sig
|
|
|
316
320
|
try { deps.rename(tmp, venvDir); return true; } catch { return false; }
|
|
317
321
|
};
|
|
318
322
|
if (publish()) {
|
|
319
|
-
deps.rmrf(join(deps.cacheRoot,
|
|
320
|
-
return { kind: "venv", exe: venvExe, pdf: true, xlsx: true };
|
|
323
|
+
for (const legacy of LEGACY_VENV_DIR_NAMES) deps.rmrf(join(deps.cacheRoot, legacy));
|
|
324
|
+
return { kind: "venv", exe: venvExe, pdf: true, xlsx: true, docx: true };
|
|
321
325
|
}
|
|
322
326
|
// Rename failed - a competing process may have published first, or venvDir holds a stale/broken dir.
|
|
323
327
|
const winner = await probe(venvExe, tmp);
|
|
324
328
|
if (isDeadline(winner)) return winner;
|
|
325
|
-
if (winner &&
|
|
329
|
+
if (winner && healthy(winner)) {
|
|
326
330
|
deps.rmrf(tmp); // healthy winner - clean up our loser
|
|
327
|
-
return { kind: "venv", exe: venvExe, pdf: true, xlsx: true };
|
|
331
|
+
return { kind: "venv", exe: venvExe, pdf: true, xlsx: true, docx: true };
|
|
328
332
|
}
|
|
329
333
|
deps.rmrf(venvDir); // unhealthy/absent dest - clear it and retry the rename once
|
|
330
334
|
if (publish()) {
|
|
331
|
-
deps.rmrf(join(deps.cacheRoot,
|
|
332
|
-
return { kind: "venv", exe: venvExe, pdf: true, xlsx: true };
|
|
335
|
+
for (const legacy of LEGACY_VENV_DIR_NAMES) deps.rmrf(join(deps.cacheRoot, legacy));
|
|
336
|
+
return { kind: "venv", exe: venvExe, pdf: true, xlsx: true, docx: true };
|
|
333
337
|
}
|
|
334
338
|
return bootFail("rename after competing bootstrap");
|
|
335
339
|
}
|
|
@@ -391,23 +395,28 @@ export async function tryConvertOffice(sofficeTimeoutMs: number, src: string, si
|
|
|
391
395
|
} catch (e) { cleanup(); throw e; }
|
|
392
396
|
}
|
|
393
397
|
|
|
394
|
-
export
|
|
395
|
-
|
|
396
|
-
if (r.
|
|
397
|
-
|
|
398
|
-
if (r.kind === "no-pdf") 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.");
|
|
399
|
-
throw new Error(`soffice failed (code=${r.code} timedOut=${r.timedOut}): ${r.stderr}`);
|
|
398
|
+
export function officeFailure(r: Extract<OfficeResult, { ok: false }>): Error {
|
|
399
|
+
if (r.kind === "missing") return 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.");
|
|
400
|
+
if (r.kind === "no-pdf") return 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.");
|
|
401
|
+
return new Error(`soffice failed (code=${r.code} timedOut=${r.timedOut}): ${r.stderr}`);
|
|
400
402
|
}
|
|
401
403
|
|
|
402
|
-
|
|
403
404
|
// --- Conversion tiers and bundle orchestration ---
|
|
404
405
|
|
|
405
406
|
export const DEGRADED_TEXT = "PyMuPDF text extraction - layout/tables not preserved";
|
|
406
407
|
export const DEGRADED_UNPDF = "unpdf text extraction - structure not preserved";
|
|
408
|
+
export const DEGRADED_DOCX_TEXT = "python-docx text extraction - footnotes, hyperlinks, images not preserved";
|
|
409
|
+
export const DEGRADED_DOCX_OFFICE = "LibreOffice PDF route - heading styles and explicit page breaks not preserved; page numbers are LibreOffice pagination";
|
|
410
|
+
export const DOCX_PIP = "pip install mammoth markdownify python-docx";
|
|
411
|
+
export const DOCX_PAGES_OFFICE = `--pages on a DOCX needs the Python DOCX backend (explicit page-break segments); the LibreOffice route has none. Remedy: install uv, or ${DOCX_PIP}`;
|
|
412
|
+
const docxRemedy = `Remedy: install uv, or ${DOCX_PIP} into a Python that already has pymupdf4llm`;
|
|
413
|
+
const backendState = (b: Backend, missing: string) => b.kind === "none" ? b.reason : missing;
|
|
414
|
+
const lacksDocx = (b: Backend) => b.kind === "none" || !b.docx;
|
|
415
|
+
const clearStaging = (b: Pick<Bundle, "stagingDir">) => { for (const f of readdirSync(b.stagingDir)) rmSync(join(b.stagingDir, f), { recursive: true, force: true }); };
|
|
407
416
|
export const EXCEL_REMEDY = "Remedy: install uv, or pip install openpyxl xlrd pillow";
|
|
408
417
|
|
|
409
|
-
export type Mode = "info" | "pdf-primary" | "pdf-fallback" | "xlsx" | "pdf-text" | "render-pages";
|
|
410
|
-
export interface TierJson { markdown?: string; pages?: number[]; pageCount?: number; emptyPages?: number[]; failedPages?: { page: number; error: string }[]; notes?: string[]; images?: { sheetIndex: number; file: string }[]; metadata?: Record<string, string>; toc?: [number, string, number][]; sheets?: SheetInfo[]; renderPages?: number[]; sheetCount?: number; ok?: boolean; reason?: string; rendered?: { idx: number; file: string; dpi: number }[]; failed?: { idx: number; reason: string }[]; }
|
|
418
|
+
export type Mode = "info" | "pdf-primary" | "pdf-fallback" | "xlsx" | "pdf-text" | "render-pages" | "docx";
|
|
419
|
+
export interface TierJson { markdown?: string; pages?: number[]; pageCount?: number; emptyPages?: number[]; failedPages?: { page: number; error: string }[]; notes?: string[]; images?: { sheetIndex: number; file: string }[]; metadata?: Record<string, string>; toc?: [number, string, number | null][]; explicitBreaks?: number; engine?: string; degraded?: boolean; fallbackReason?: string | null; sheets?: SheetInfo[]; renderPages?: number[]; sheetCount?: number; ok?: boolean; reason?: string; rendered?: { idx: number; file: string; dpi: number }[]; failed?: { idx: number; reason: string }[]; }
|
|
411
420
|
export type TierResult = { ok: true; json: TierJson } | { ok: false; reason: string; detail?: string } | { ok: false; userError: string; pageCount?: number };
|
|
412
421
|
|
|
413
422
|
export interface PipelineSeams {
|
|
@@ -491,59 +500,94 @@ export async function convertDocument(o: DocToMdOptions, signal?: AbortSignal, s
|
|
|
491
500
|
let office: { pdfPath: string; cleanup: () => void } | null = null;
|
|
492
501
|
try {
|
|
493
502
|
let pdfPath = inputPath;
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
let
|
|
503
|
+
const base = { path: inputPath, pages: o.pages, stagingDir: b.stagingDir, sheetsStagingDir: b.sheetsStagingDir, imageDpi: o.imageDpi, imageFormat: o.imageFormat, maxOutputBytes: o.maxOutputBytes, pymupdfVersion: o.pymupdfVersion };
|
|
504
|
+
let tier: Tier | undefined, engine: Engine | undefined, json: TierJson | undefined, degraded: string | null = null, fallbackReason: string | null = null;
|
|
505
|
+
let explicitBreaks: number | null = null;
|
|
497
506
|
let notes: string[] = [];
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
507
|
+
let officeRoute: string | null = null;
|
|
508
|
+
if (type === "docx" && !lacksDocx(backend)) {
|
|
509
|
+
const d = await s.runTier("docx", base, b, signal, o.primaryTimeoutMs, backend);
|
|
510
|
+
if (d.ok) {
|
|
511
|
+
publishStaged(b);
|
|
512
|
+
tier = "docx"; engine = d.json.engine === "python-docx" ? "python-docx" : "mammoth"; json = d.json; explicitBreaks = d.json.explicitBreaks ?? 0;
|
|
513
|
+
if (d.json.degraded) { degraded = DEGRADED_DOCX_TEXT; fallbackReason = d.json.fallbackReason ?? null; }
|
|
514
|
+
} else if ("userError" in d) throw new Error(d.userError);
|
|
515
|
+
else if (d.reason === "exit 1") { officeRoute = `docx ${d.reason}${detailSuffix(d)}`; clearStaging(b); }
|
|
516
|
+
else throw new Error(`Conversion failed: docx ${d.reason}${detailSuffix(d)}`);
|
|
517
|
+
} else if (type === "docx") officeRoute = backend.kind === "none" ? backend.reason : "python backend lacks DOCX packages";
|
|
518
|
+
if (type === "docx" && officeRoute !== null) {
|
|
519
|
+
if (o.pages) throw new Error(officeRoute.startsWith("docx exit") ? `${DOCX_PAGES_OFFICE} (${officeRoute})` : DOCX_PAGES_OFFICE);
|
|
520
|
+
const r = await s.office(o.sofficeTimeoutMs, inputPath, signal);
|
|
521
|
+
if (!r.ok && r.kind === "missing") {
|
|
522
|
+
if (officeRoute.startsWith("docx exit")) throw new Error(`Conversion failed: ${officeRoute}; LibreOffice (soffice) not found on PATH`);
|
|
523
|
+
throw new Error(`DOCX conversion needs the Python DOCX packages or LibreOffice. Python backend: ${backendState(backend, "found without mammoth/markdownify/python-docx")}. ${docxRemedy}, or install LibreOffice (soffice)`);
|
|
504
524
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
const
|
|
510
|
-
if (
|
|
511
|
-
|
|
512
|
-
|
|
525
|
+
if (!r.ok) throw officeRoute.startsWith("docx exit") ? new Error(`Conversion failed: ${officeRoute}; ${officeFailure(r).message}`) : officeFailure(r);
|
|
526
|
+
office = r; pdfPath = r.pdfPath;
|
|
527
|
+
}
|
|
528
|
+
if (type === "pptx") {
|
|
529
|
+
const r = await s.office(o.sofficeTimeoutMs, inputPath, signal);
|
|
530
|
+
if (!r.ok && r.kind === "missing") throw new Error(`PPTX conversion needs LibreOffice (soffice); direct conversion is not available. Python backend: ${backendState(backend, "available")}. Remedy: install LibreOffice`);
|
|
531
|
+
if (!r.ok) throw officeFailure(r);
|
|
532
|
+
office = r; pdfPath = r.pdfPath;
|
|
533
|
+
}
|
|
534
|
+
const pdfBase = { ...base, path: pdfPath };
|
|
535
|
+
if (json === undefined) {
|
|
536
|
+
if (isExcel) {
|
|
537
|
+
const r = await s.runTier("xlsx", base, b, signal, o.excelTimeoutMs, backend);
|
|
538
|
+
if (!r.ok) {
|
|
539
|
+
if ("userError" in r) throw new Error(r.userError);
|
|
540
|
+
const remedy = r.reason.startsWith("timeout after") || r.reason === "output exceeded maxOutputBytes" ? ". Remedy: raise excelTimeoutMs" : "";
|
|
541
|
+
throw new Error(`Excel conversion failed: ${r.reason}${detailSuffix(r)}${remedy}`);
|
|
542
|
+
}
|
|
543
|
+
publishSheetImages(b); publishSheetCsvs(b);
|
|
544
|
+
tier = "excel"; engine = type === "xls" ? "xlrd" : "openpyxl"; json = r.json; notes = [...(json.notes ?? [])];
|
|
545
|
+
const renderPages = json.renderPages ?? [];
|
|
546
|
+
let skip: string | null = null;
|
|
547
|
+
const perSheet = new Map<number, string>();
|
|
548
|
+
if (renderPages.length) {
|
|
549
|
+
const off = await s.office(o.sofficeTimeoutMs, inputPath, signal, runCapped, EXCEL_PDF_FILTER);
|
|
550
|
+
if (!off.ok) skip = off.kind === "missing" ? "LibreOffice not found" : off.kind === "timeout" ? `soffice failed: timeout after ${o.sofficeTimeoutMs}ms` : off.kind === "exit" ? `soffice failed: exit ${off.code}` : "soffice produced no PDF";
|
|
551
|
+
else {
|
|
552
|
+
try {
|
|
553
|
+
const rp = await s.runTier("render-pages", { path: off.pdfPath, sheetIndices: renderPages, expectedPages: json.sheetCount, imageDpi: o.imageDpi, imageFormat: o.imageFormat, stagingDir: b.stagingDir, maxOutputBytes: o.maxOutputBytes, pymupdfVersion: o.pymupdfVersion }, b, signal, o.fallbackTimeoutMs, backend);
|
|
554
|
+
if (!rp.ok) skip = `render failed: ${"userError" in rp ? rp.userError : rp.reason}`;
|
|
555
|
+
else if (rp.json.ok === false) skip = rp.json.reason ?? "render failed";
|
|
556
|
+
else {
|
|
557
|
+
publishSheetImages(b);
|
|
558
|
+
for (const f of rp.json.failed ?? []) perSheet.set(f.idx, f.reason);
|
|
559
|
+
for (const d of rp.json.rendered ?? []) if (d.dpi < o.imageDpi) notes.push(`Rendered view s${d.idx}: rendered at ${d.dpi} dpi`);
|
|
560
|
+
}
|
|
561
|
+
} finally { off.cleanup(); }
|
|
562
|
+
}
|
|
563
|
+
if (skip) notes.push(`Rendered views skipped: ${skip}`);
|
|
564
|
+
else if (perSheet.size) notes.push(`Rendered views: ${perSheet.size} of ${renderPages.length} unavailable`);
|
|
565
|
+
}
|
|
566
|
+
json = { ...json, markdown: reconcileRenderMarkers(json.markdown ?? "", renderPages, o.imageFormat, b.sourceMap, (idx) => perSheet.get(idx) ?? skip ?? "render failed") };
|
|
567
|
+
} else if (backend.kind === "none") {
|
|
568
|
+
const r = await s.runTier("pdf-text", pdfBase, b, signal, o.primaryTimeoutMs, backend);
|
|
569
|
+
if (!r.ok) throw new Error("userError" in r ? r.userError : `Conversion failed: unpdf ${r.reason}${detailSuffix(r)}`);
|
|
570
|
+
tier = "unpdf"; engine = "unpdf"; json = r.json; degraded = DEGRADED_UNPDF;
|
|
571
|
+
} else {
|
|
572
|
+
const p = await s.runTier("pdf-primary", pdfBase, b, signal, o.primaryTimeoutMs, backend);
|
|
573
|
+
const kept = publishStaged(b);
|
|
574
|
+
if (p.ok) { tier = "primary"; engine = "pymupdf4llm"; json = p.json; }
|
|
575
|
+
else if ("userError" in p) throw new Error(p.userError);
|
|
513
576
|
else {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
for (const f of rp.json.failed ?? []) perSheet.set(f.idx, f.reason);
|
|
521
|
-
for (const d of rp.json.rendered ?? []) if (d.dpi < o.imageDpi) notes.push(`Rendered view s${d.idx}: rendered at ${d.dpi} dpi`);
|
|
522
|
-
}
|
|
523
|
-
} finally { off.cleanup(); }
|
|
577
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
578
|
+
const keepPages = Object.fromEntries([...kept.entries()].map(([k, v]) => [String(k), v]));
|
|
579
|
+
const f = await s.runTier("pdf-fallback", { ...pdfBase, keepPages }, b, signal, o.fallbackTimeoutMs, backend);
|
|
580
|
+
publishStaged(b);
|
|
581
|
+
if (!f.ok) throw new Error("userError" in f ? f.userError : `Conversion failed: primary ${p.reason}; fallback ${f.reason}${detailSuffix(f)}`);
|
|
582
|
+
tier = "fallback"; engine = "pymupdf-text"; json = f.json; degraded = DEGRADED_TEXT; fallbackReason = `primary ${p.reason}`;
|
|
524
583
|
}
|
|
525
|
-
if (skip) notes.push(`Rendered views skipped: ${skip}`);
|
|
526
|
-
else if (perSheet.size) notes.push(`Rendered views: ${perSheet.size} of ${renderPages.length} unavailable`);
|
|
527
|
-
}
|
|
528
|
-
json = { ...json, markdown: reconcileRenderMarkers(json.markdown ?? "", renderPages, o.imageFormat, b.sourceMap, (idx) => perSheet.get(idx) ?? skip ?? "render failed") };
|
|
529
|
-
} else if (backend.kind === "none") {
|
|
530
|
-
const r = await s.runTier("pdf-text", base, b, signal, o.primaryTimeoutMs, backend);
|
|
531
|
-
if (!r.ok) throw new Error("userError" in r ? r.userError : `Conversion failed: unpdf ${r.reason}${detailSuffix(r)}`);
|
|
532
|
-
tier = "unpdf"; engine = "unpdf"; json = r.json; degraded = DEGRADED_UNPDF;
|
|
533
|
-
} else {
|
|
534
|
-
const p = await s.runTier("pdf-primary", base, b, signal, o.primaryTimeoutMs, backend);
|
|
535
|
-
const kept = publishStaged(b);
|
|
536
|
-
if (p.ok) { tier = "primary"; engine = "pymupdf4llm"; json = p.json; }
|
|
537
|
-
else if ("userError" in p) throw new Error(p.userError);
|
|
538
|
-
else {
|
|
539
|
-
if (signal?.aborted) throw new Error("aborted");
|
|
540
|
-
const keepPages = Object.fromEntries([...kept.entries()].map(([k, v]) => [String(k), v]));
|
|
541
|
-
const f = await s.runTier("pdf-fallback", { ...base, keepPages }, b, signal, o.fallbackTimeoutMs, backend);
|
|
542
|
-
publishStaged(b);
|
|
543
|
-
if (!f.ok) throw new Error("userError" in f ? f.userError : `Conversion failed: primary ${p.reason}; fallback ${f.reason}${detailSuffix(f)}`);
|
|
544
|
-
tier = "fallback"; engine = "pymupdf-text"; json = f.json; degraded = DEGRADED_TEXT; fallbackReason = `primary ${p.reason}`;
|
|
545
584
|
}
|
|
546
585
|
}
|
|
586
|
+
if (officeRoute !== null) {
|
|
587
|
+
degraded = DEGRADED_DOCX_OFFICE;
|
|
588
|
+
fallbackReason = fallbackReason ? `${officeRoute}; ${fallbackReason}` : officeRoute;
|
|
589
|
+
}
|
|
590
|
+
if (tier === undefined || engine === undefined || json === undefined) throw new Error("internal: no tier produced output");
|
|
547
591
|
if (!isExcel) notes = json.notes ?? [];
|
|
548
592
|
const body = rewriteLinks(json.markdown ?? "", b.sourceMap);
|
|
549
593
|
validateImageLinks(body, b.manifest, b.csvManifest);
|
|
@@ -556,7 +600,7 @@ export async function convertDocument(o: DocToMdOptions, signal?: AbortSignal, s
|
|
|
556
600
|
const markdown = (head.length ? `${head.join("\n")}\n\n` : "") + body;
|
|
557
601
|
commitBundle(b, markdown);
|
|
558
602
|
const outline = scanOutline(markdown, o.outlineMaxEntries);
|
|
559
|
-
const details: DocToMdDetails = { path: inputPath, backend: backend.kind, pymupdfVersion: o.pymupdfVersion, inputType: type, file: b.mdPath, outputDir: b.root, savedTo: b.mdPath, imagesDir: b.imagesDir, sheetsDir: b.csvManifest.size ? b.sheetsDir : null, type, engine, tier, pageCount: json.pageCount ?? null, pages: o.pages, imageCount: b.manifest.size, bytes: Buffer.byteLength(markdown, "utf8"), lines: markdown.split("\n").length, degraded, fallbackReason, failedPages: (json.failedPages ?? []).map((f) => f.page), emptyPages: json.emptyPages ?? [], notes, outline: outline.entries, outlineTotal: outline.total };
|
|
603
|
+
const details: DocToMdDetails = { path: inputPath, backend: backend.kind, pymupdfVersion: o.pymupdfVersion, inputType: type, file: b.mdPath, outputDir: b.root, savedTo: b.mdPath, imagesDir: b.imagesDir, sheetsDir: b.csvManifest.size ? b.sheetsDir : null, type, engine, tier, pageCount: json.pageCount ?? null, pages: o.pages, explicitBreaks, imageCount: b.manifest.size, bytes: Buffer.byteLength(markdown, "utf8"), lines: markdown.split("\n").length, degraded, fallbackReason, failedPages: (json.failedPages ?? []).map((f) => f.page), emptyPages: json.emptyPages ?? [], notes, outline: outline.entries, outlineTotal: outline.total };
|
|
560
604
|
return { output: formatHandle(details), details };
|
|
561
605
|
} catch (e) { abortBundle(b); throw e; }
|
|
562
606
|
finally { office?.cleanup(); }
|
|
@@ -574,7 +618,13 @@ export async function inspectDocument(o: DocToMdOptions, signal?: AbortSignal, s
|
|
|
574
618
|
let office: { pdfPath: string; cleanup: () => void } | null = null;
|
|
575
619
|
try {
|
|
576
620
|
let path = inputPath;
|
|
577
|
-
if (type === "docx"
|
|
621
|
+
if (type === "docx") {
|
|
622
|
+
if (lacksDocx(backend)) throw new Error(`DOCX inspection needs the Python DOCX packages. Python backend: ${backendState(backend, "found without mammoth/markdownify/python-docx")}. ${docxRemedy}`);
|
|
623
|
+
} else if (type === "pptx") {
|
|
624
|
+
const r = await s.office(o.sofficeTimeoutMs, inputPath, signal);
|
|
625
|
+
if (!r.ok) throw officeFailure(r);
|
|
626
|
+
office = r; path = r.pdfPath;
|
|
627
|
+
}
|
|
578
628
|
const r = await s.runTier("info", { path, maxOutputBytes: o.maxOutputBytes, pymupdfVersion: o.pymupdfVersion }, { stagingDir: "" }, signal, isExcel ? o.excelTimeoutMs : o.fallbackTimeoutMs, backend);
|
|
579
629
|
if (!r.ok) {
|
|
580
630
|
if ("userError" in r) throw new Error(r.userError);
|