pi-quiver 3.0.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 +92 -0
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/doc_to_md.ts +471 -0
- package/extension-config.ts +49 -0
- package/fetch.ts +541 -0
- package/package.json +89 -0
- package/scripts/pdf_to_md.py +30 -0
- package/session-name.ts +295 -0
- package/sword-header.ts +86 -0
- package/types/turndown-plugin-gfm.d.ts +8 -0
package/doc_to_md.ts
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doc_to_md Extension
|
|
3
|
+
*
|
|
4
|
+
* Registers a `doc_to_md` tool that converts a local PDF, DOCX, or PPTX file
|
|
5
|
+
* to Markdown. Primary engine: pymupdf4llm via ephemeral `uv run --with`
|
|
6
|
+
* (warm-once per process, no repo venv). Fallback: unpdf pure-JS text
|
|
7
|
+
* extraction (degraded, explicitly marked). DOCX/PPTX convert to PDF first
|
|
8
|
+
* via headless soffice, then feed the PDF pipeline. Output over 32 KB or
|
|
9
|
+
* 1000 lines is spilled to a temp .md file with a 60-line preview; smaller
|
|
10
|
+
* content is returned inline.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { extractText, getDocumentProxy } from "unpdf";
|
|
20
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { formatSize, keyHint } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
23
|
+
import { Type } from "@sinclair/typebox";
|
|
24
|
+
|
|
25
|
+
// --- Types ---
|
|
26
|
+
|
|
27
|
+
export type InputType = "pdf" | "docx" | "pptx";
|
|
28
|
+
export type Engine = "pymupdf4llm" | "unpdf";
|
|
29
|
+
|
|
30
|
+
export interface DocToMdConfig {
|
|
31
|
+
pymupdfVersion: string;
|
|
32
|
+
warmTimeoutMs: number;
|
|
33
|
+
convertTimeoutMs: number;
|
|
34
|
+
sofficeTimeoutMs: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DocToMdDetails {
|
|
38
|
+
path: string;
|
|
39
|
+
inputType: InputType;
|
|
40
|
+
engine: Engine;
|
|
41
|
+
degraded: boolean;
|
|
42
|
+
bytes: number;
|
|
43
|
+
lines: number;
|
|
44
|
+
spilled: boolean;
|
|
45
|
+
file?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CappedResult {
|
|
49
|
+
stdout: string;
|
|
50
|
+
stderr: string;
|
|
51
|
+
code: number | null;
|
|
52
|
+
timedOut: boolean;
|
|
53
|
+
capped: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- Constants ---
|
|
57
|
+
|
|
58
|
+
const DEFAULT_PYMUPDF_VERSION = "1.27.2.3";
|
|
59
|
+
const WARM_TIMEOUT_DEFAULT = 120_000;
|
|
60
|
+
const CONVERT_TIMEOUT_DEFAULT = 60_000;
|
|
61
|
+
const SOFFICE_TIMEOUT_DEFAULT = 120_000;
|
|
62
|
+
const STDERR_CAP = 1_000_000;
|
|
63
|
+
const INLINE_MAX_BYTES = 32_000;
|
|
64
|
+
const INLINE_MAX_LINES = 1_000;
|
|
65
|
+
const PREVIEW_LINES = 60;
|
|
66
|
+
const PREVIEW_MAX_BYTES = 4_000;
|
|
67
|
+
const VERSION_RE = /^\d+(\.\d+)*$/;
|
|
68
|
+
const PAGE_SEP = "\n\n---\n\n";
|
|
69
|
+
export const OUTPUT_MAX_BYTES = 20_000_000;
|
|
70
|
+
|
|
71
|
+
export const DEGRADED_MARKER =
|
|
72
|
+
"[Note: degraded extraction via unpdf — structure (tables/headings) not preserved]";
|
|
73
|
+
|
|
74
|
+
// --- Config ---
|
|
75
|
+
|
|
76
|
+
export function parseConfig(env: NodeJS.ProcessEnv): DocToMdConfig {
|
|
77
|
+
const version = env.PI_DOC_TO_MD_PYMUPDF_VERSION ?? DEFAULT_PYMUPDF_VERSION;
|
|
78
|
+
if (!VERSION_RE.test(version)) {
|
|
79
|
+
throw new Error(`PI_DOC_TO_MD_PYMUPDF_VERSION must be digits and dots (got "${version}")`);
|
|
80
|
+
}
|
|
81
|
+
const num = (key: string, def: number): number => {
|
|
82
|
+
const raw = env[key];
|
|
83
|
+
if (raw === undefined) return def;
|
|
84
|
+
const n = Number.parseInt(raw, 10);
|
|
85
|
+
if (!Number.isInteger(n) || n <= 0) throw new Error(`${key} must be a positive integer (got "${raw}")`);
|
|
86
|
+
return n;
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
pymupdfVersion: version,
|
|
90
|
+
warmTimeoutMs: num("PI_DOC_TO_MD_WARM_TIMEOUT_MS", WARM_TIMEOUT_DEFAULT),
|
|
91
|
+
convertTimeoutMs: num("PI_DOC_TO_MD_CONVERT_TIMEOUT_MS", CONVERT_TIMEOUT_DEFAULT),
|
|
92
|
+
sofficeTimeoutMs: num("PI_DOC_TO_MD_SOFFICE_TIMEOUT_MS", SOFFICE_TIMEOUT_DEFAULT),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- Input classification ---
|
|
97
|
+
|
|
98
|
+
const SUPPORTED: Record<string, InputType> = { ".pdf": "pdf", ".docx": "docx", ".pptx": "pptx" };
|
|
99
|
+
|
|
100
|
+
export function classifyInput(filePath: string): InputType {
|
|
101
|
+
const ext = extname(filePath).toLowerCase();
|
|
102
|
+
const t = SUPPORTED[ext];
|
|
103
|
+
if (!t) throw new Error(`Unsupported file type "${ext || "(none)"}"; supported: .pdf, .docx, .pptx`);
|
|
104
|
+
return t;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- Size gate ---
|
|
108
|
+
|
|
109
|
+
export function applyGate(body: string): { spill: boolean; bytes: number; lines: number } {
|
|
110
|
+
const bytes = Buffer.byteLength(body, "utf8");
|
|
111
|
+
const lines = body.length ? body.split("\n").length : 0;
|
|
112
|
+
const spill = body.length > 0 && (bytes > INLINE_MAX_BYTES || lines > INLINE_MAX_LINES);
|
|
113
|
+
return { spill, bytes, lines };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- Markdown helpers ---
|
|
117
|
+
|
|
118
|
+
export function withMarker(md: string, degraded: boolean): string {
|
|
119
|
+
return degraded ? `${DEGRADED_MARKER}\n\n${md}` : md;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function pagesToMarkdown(pages: string[]): string {
|
|
123
|
+
return pages.map((p) => p.trim()).filter((p) => p.length > 0).join(PAGE_SEP);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- Temp file helpers ---
|
|
127
|
+
|
|
128
|
+
export function tempFilePath(inputPath: string, ext: string): string {
|
|
129
|
+
const dir = join(tmpdir(), "pi-doc-to-md");
|
|
130
|
+
mkdirSync(dir, { recursive: true });
|
|
131
|
+
const base = basename(inputPath).replace(/\.[^.]+$/, "").replace(/[^\w.-]+/g, "-");
|
|
132
|
+
const hash = createHash("sha1").update(resolve(inputPath)).digest("hex").slice(0, 8);
|
|
133
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
134
|
+
return join(dir, `${stamp}-${base}-${hash}.${ext}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function spillToFile(inputPath: string, body: string): string {
|
|
138
|
+
const file = tempFilePath(inputPath, "md");
|
|
139
|
+
writeFileSync(file, body, "utf8");
|
|
140
|
+
return file;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function buildPreview(body: string): string {
|
|
144
|
+
let preview = body.split("\n").slice(0, PREVIEW_LINES).join("\n");
|
|
145
|
+
if (preview.length > PREVIEW_MAX_BYTES) {
|
|
146
|
+
preview = `${preview.slice(0, PREVIEW_MAX_BYTES)}\n…[preview truncated]`;
|
|
147
|
+
}
|
|
148
|
+
return preview;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- Subprocess argv builders ---
|
|
152
|
+
|
|
153
|
+
export function warmArgs(cfg: DocToMdConfig): string[] {
|
|
154
|
+
return ["run", "--with", `pymupdf4llm==${cfg.pymupdfVersion}`, "--python", "3.14", "python", "-c", "import pymupdf4llm"];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function convertArgs(cfg: DocToMdConfig, scriptPath: string, pdfPath: string): string[] {
|
|
158
|
+
return ["run", "--with", `pymupdf4llm==${cfg.pymupdfVersion}`, "--python", "3.14", "python", scriptPath, pdfPath];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function soffArgs(src: string, profileDir: string, outDir: string): string[] {
|
|
162
|
+
return [
|
|
163
|
+
"--headless", "--invisible", "--nocrashreport", "--nodefault", "--nofirststartwizard",
|
|
164
|
+
"--nolockcheck", "--nologo", "--norestore", "--quickstart=no",
|
|
165
|
+
`-env:UserInstallation=file://${profileDir}`,
|
|
166
|
+
"--convert-to", "pdf", "--outdir", outDir, src,
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- Subprocess runner ---
|
|
171
|
+
|
|
172
|
+
export async function runCapped(
|
|
173
|
+
cmd: string,
|
|
174
|
+
args: string[],
|
|
175
|
+
opts: { timeoutMs: number; capBytes: number; env?: NodeJS.ProcessEnv; signal?: AbortSignal },
|
|
176
|
+
): Promise<CappedResult> {
|
|
177
|
+
return new Promise((resolveP) => {
|
|
178
|
+
let settled = false;
|
|
179
|
+
let timedOut = false;
|
|
180
|
+
let capped = false;
|
|
181
|
+
const stdoutChunks: Buffer[] = [];
|
|
182
|
+
const stderrChunks: Buffer[] = [];
|
|
183
|
+
let stdoutBytes = 0;
|
|
184
|
+
let stderrBytes = 0;
|
|
185
|
+
|
|
186
|
+
const child = spawn(cmd, args, { env: opts.env ?? process.env });
|
|
187
|
+
|
|
188
|
+
const finish = (code: number | null) => {
|
|
189
|
+
if (settled) return;
|
|
190
|
+
settled = true;
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
193
|
+
resolveP({
|
|
194
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
195
|
+
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
196
|
+
code,
|
|
197
|
+
timedOut,
|
|
198
|
+
capped,
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const kill = () => child.kill("SIGKILL");
|
|
203
|
+
|
|
204
|
+
const onAbort = () => { kill(); };
|
|
205
|
+
if (opts.signal?.aborted) { kill(); }
|
|
206
|
+
else opts.signal?.addEventListener("abort", onAbort);
|
|
207
|
+
|
|
208
|
+
const timer = setTimeout(() => { timedOut = true; kill(); }, opts.timeoutMs);
|
|
209
|
+
|
|
210
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
211
|
+
if (capped) return;
|
|
212
|
+
const remaining = opts.capBytes - stdoutBytes;
|
|
213
|
+
if (remaining <= 0) { capped = true; kill(); return; }
|
|
214
|
+
if (chunk.length > remaining) {
|
|
215
|
+
stdoutChunks.push(chunk.subarray(0, remaining));
|
|
216
|
+
stdoutBytes += remaining;
|
|
217
|
+
capped = true;
|
|
218
|
+
kill();
|
|
219
|
+
} else {
|
|
220
|
+
stdoutChunks.push(chunk);
|
|
221
|
+
stdoutBytes += chunk.length;
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
226
|
+
if (stderrBytes >= STDERR_CAP) return;
|
|
227
|
+
const remaining = STDERR_CAP - stderrBytes;
|
|
228
|
+
const slice = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
|
|
229
|
+
stderrChunks.push(slice);
|
|
230
|
+
stderrBytes += slice.length;
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
child.on("error", (err) => {
|
|
234
|
+
if (stderrBytes < STDERR_CAP) {
|
|
235
|
+
const msg = Buffer.from(err.message);
|
|
236
|
+
const remaining = STDERR_CAP - stderrBytes;
|
|
237
|
+
stderrChunks.push(msg.length > remaining ? msg.subarray(0, remaining) : msg);
|
|
238
|
+
}
|
|
239
|
+
finish(null);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
child.on("close", (code) => finish(code));
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- Engine orchestration ---
|
|
247
|
+
|
|
248
|
+
let pymupdfState: "unknown" | "warm" | "unavailable" = "unknown";
|
|
249
|
+
let warmPromise: Promise<"warm" | "unavailable"> | null = null;
|
|
250
|
+
let uvAvailable: boolean | null = null;
|
|
251
|
+
|
|
252
|
+
function scriptPath(): string {
|
|
253
|
+
return fileURLToPath(new URL("./scripts/pdf_to_md.py", import.meta.url));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function onPath(cmd: string): Promise<boolean> {
|
|
257
|
+
const r = await runCapped("which", [cmd], { timeoutMs: 5000, capBytes: 4000 });
|
|
258
|
+
return r.code === 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function warmPymupdf(cfg: DocToMdConfig): Promise<"warm" | "unavailable"> {
|
|
262
|
+
if (pymupdfState !== "unknown") return pymupdfState;
|
|
263
|
+
if (warmPromise === null) {
|
|
264
|
+
warmPromise = (async () => {
|
|
265
|
+
const r = await runCapped("uv", warmArgs(cfg), { timeoutMs: cfg.warmTimeoutMs, capBytes: OUTPUT_MAX_BYTES });
|
|
266
|
+
pymupdfState = (r.code === 0 && !r.timedOut) ? "warm" : "unavailable";
|
|
267
|
+
return pymupdfState;
|
|
268
|
+
})();
|
|
269
|
+
}
|
|
270
|
+
return warmPromise;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function convertViaPymupdf(cfg: DocToMdConfig, pdfPath: string, signal?: AbortSignal): Promise<string> {
|
|
274
|
+
const r = await runCapped("uv", convertArgs(cfg, scriptPath(), pdfPath), {
|
|
275
|
+
timeoutMs: cfg.convertTimeoutMs, capBytes: OUTPUT_MAX_BYTES, signal,
|
|
276
|
+
});
|
|
277
|
+
if (r.timedOut || r.capped || r.code !== 0) {
|
|
278
|
+
throw new Error(`pymupdf4llm convert failed (code=${r.code} timedOut=${r.timedOut} capped=${r.capped}): ${r.stderr.slice(0, 500)}`);
|
|
279
|
+
}
|
|
280
|
+
return r.stdout;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function convertViaUnpdf(pdfPath: string, cfg: DocToMdConfig, signal?: AbortSignal): Promise<string> {
|
|
284
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
285
|
+
const buf = readFileSync(pdfPath);
|
|
286
|
+
const work = (async () => {
|
|
287
|
+
const pdf = await getDocumentProxy(new Uint8Array(buf));
|
|
288
|
+
const { text } = await extractText(pdf, { mergePages: false });
|
|
289
|
+
const md = pagesToMarkdown(text);
|
|
290
|
+
if (Buffer.byteLength(md, "utf8") > OUTPUT_MAX_BYTES) throw new Error("unpdf output exceeded cap");
|
|
291
|
+
return md;
|
|
292
|
+
})();
|
|
293
|
+
let timer: NodeJS.Timeout;
|
|
294
|
+
const timeout = new Promise<never>((_, rej) => {
|
|
295
|
+
timer = setTimeout(() => rej(new Error("unpdf conversion timed out")), cfg.convertTimeoutMs);
|
|
296
|
+
});
|
|
297
|
+
try { return await Promise.race([work, timeout]); } finally { clearTimeout(timer!); }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function convertOffice(cfg: DocToMdConfig, src: string): Promise<{ pdfPath: string; cleanup: () => void }> {
|
|
301
|
+
const profileDir = mkdtempSync(join(tmpdir(), "pi-doc-soffice-prof-"));
|
|
302
|
+
const outDir = mkdtempSync(join(tmpdir(), "pi-doc-soffice-out-"));
|
|
303
|
+
const cleanup = () => {
|
|
304
|
+
for (const d of [profileDir, outDir]) {
|
|
305
|
+
try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
try {
|
|
309
|
+
const env = { ...process.env, SAL_USE_VCLPLUGIN: "svp", OOO_DISABLE_RECOVERY: "1", SAL_NO_MOUSEGRABS: "1" };
|
|
310
|
+
const r = await runCapped("soffice", soffArgs(src, profileDir, outDir), { timeoutMs: cfg.sofficeTimeoutMs, capBytes: OUTPUT_MAX_BYTES, env });
|
|
311
|
+
if (r.timedOut || r.code !== 0) throw new Error(`soffice failed (code=${r.code} timedOut=${r.timedOut}): ${r.stderr.slice(0, 500)}`);
|
|
312
|
+
const base = basename(src).replace(/\.[^.]+$/, "");
|
|
313
|
+
const pdfPath = join(outDir, `${base}.pdf`);
|
|
314
|
+
const st = statSync(pdfPath, { throwIfNoEntry: false });
|
|
315
|
+
if (!st || !st.isFile() || st.size === 0) throw new Error("soffice produced no usable PDF");
|
|
316
|
+
return { pdfPath, cleanup };
|
|
317
|
+
} catch (e) { cleanup(); throw e; }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export async function runPipeline(
|
|
321
|
+
cfg: DocToMdConfig,
|
|
322
|
+
inputPath: string,
|
|
323
|
+
type: InputType,
|
|
324
|
+
signal?: AbortSignal,
|
|
325
|
+
): Promise<{ markdown: string; engine: Engine; degraded: boolean; fallbackReason?: string }> {
|
|
326
|
+
let pdfPath = inputPath;
|
|
327
|
+
let cleanup: (() => void) | null = null;
|
|
328
|
+
if (type === "docx" || type === "pptx") {
|
|
329
|
+
const o = await convertOffice(cfg, inputPath);
|
|
330
|
+
pdfPath = o.pdfPath;
|
|
331
|
+
cleanup = o.cleanup;
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
if (uvAvailable === null) uvAvailable = await onPath("uv");
|
|
335
|
+
if (!uvAvailable) {
|
|
336
|
+
return { markdown: await convertViaUnpdf(pdfPath, cfg, signal), engine: "unpdf", degraded: true, fallbackReason: "uv not on PATH" };
|
|
337
|
+
}
|
|
338
|
+
const state = await warmPymupdf(cfg);
|
|
339
|
+
if (state === "warm") {
|
|
340
|
+
try {
|
|
341
|
+
return { markdown: await convertViaPymupdf(cfg, pdfPath, signal), engine: "pymupdf4llm", degraded: false };
|
|
342
|
+
} catch (e) {
|
|
343
|
+
const fallbackReason = e instanceof Error ? e.message : String(e);
|
|
344
|
+
return { markdown: await convertViaUnpdf(pdfPath, cfg, signal), engine: "unpdf", degraded: true, fallbackReason };
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return { markdown: await convertViaUnpdf(pdfPath, cfg, signal), engine: "unpdf", degraded: true, fallbackReason: "pymupdf4llm engine unavailable (warm probe failed)" };
|
|
348
|
+
} finally {
|
|
349
|
+
cleanup?.();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// --- Extension entry point ---
|
|
354
|
+
|
|
355
|
+
export default function docToMdExtension(pi: ExtensionAPI) {
|
|
356
|
+
pi.registerTool({
|
|
357
|
+
name: "doc_to_md",
|
|
358
|
+
label: "Convert doc to Markdown",
|
|
359
|
+
description:
|
|
360
|
+
"Convert a local PDF/DOCX/PPTX file to Markdown. High-fidelity conversion via pymupdf4llm (run through uv, fetched on first use); falls back to a degraded pure-JS text extractor (unpdf) when uv/Python is unavailable or conversion times out. DOCX/PPTX require LibreOffice (soffice) for the office->PDF step. Output over 32KB or 1000 lines is written to a temp .md file with a preview instead of inlined. Input must be a local file path (use fetch first for URLs).",
|
|
361
|
+
promptSnippet: "Convert a local PDF/DOCX/PPTX to Markdown",
|
|
362
|
+
promptGuidelines: [
|
|
363
|
+
"Input is a LOCAL file path, not a URL — use the fetch tool first to download remote documents, then pass the saved path here.",
|
|
364
|
+
"Supported types: .pdf, .docx, .pptx. Spreadsheets and other formats are not supported.",
|
|
365
|
+
"DOCX/PPTX conversion requires LibreOffice (soffice) on PATH; without it those inputs error.",
|
|
366
|
+
"A degraded result (marked in the output) means the pure-JS fallback ran — tables and headings are NOT faithfully preserved; treat structure with suspicion.",
|
|
367
|
+
"When output is written to a file, grep it or read with offset/limit; Markdown is grep-able by heading (^#).",
|
|
368
|
+
],
|
|
369
|
+
parameters: Type.Object({
|
|
370
|
+
path: Type.String({ description: "Local path to a .pdf, .docx, or .pptx file" }),
|
|
371
|
+
}),
|
|
372
|
+
|
|
373
|
+
async execute(_toolCallId, params, signal) {
|
|
374
|
+
const cfg = parseConfig(process.env);
|
|
375
|
+
const inputPath = resolve(params.path);
|
|
376
|
+
const st = statSync(inputPath, { throwIfNoEntry: false });
|
|
377
|
+
if (!st || !st.isFile()) throw new Error(`Not a readable file: ${params.path}`);
|
|
378
|
+
const type = classifyInput(inputPath);
|
|
379
|
+
if (type !== "pdf") {
|
|
380
|
+
if (!(await onPath("soffice"))) {
|
|
381
|
+
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.");
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const { markdown, engine, degraded, fallbackReason } = await runPipeline(cfg, inputPath, type, signal);
|
|
385
|
+
const body = withMarker(markdown, degraded);
|
|
386
|
+
const { spill, bytes, lines } = applyGate(body);
|
|
387
|
+
const details: DocToMdDetails = { path: inputPath, inputType: type, engine, degraded, bytes, lines, spilled: spill };
|
|
388
|
+
|
|
389
|
+
const header: string[] = [
|
|
390
|
+
`Source: ${inputPath}`,
|
|
391
|
+
`Type: ${type} Engine: ${engine}${degraded ? " (degraded fallback)" : ""}`,
|
|
392
|
+
];
|
|
393
|
+
if (fallbackReason) header.push(`Fallback-Reason: ${fallbackReason}`);
|
|
394
|
+
|
|
395
|
+
if (!spill) {
|
|
396
|
+
return {
|
|
397
|
+
content: [{ type: "text" as const, text: [...header, `Length: ${bytes} bytes, ${lines} lines`, "", body].join("\n") }],
|
|
398
|
+
details: { ...details, spilled: false },
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const file = spillToFile(inputPath, body);
|
|
403
|
+
return {
|
|
404
|
+
content: [{
|
|
405
|
+
type: "text" as const,
|
|
406
|
+
text: [
|
|
407
|
+
...header,
|
|
408
|
+
`Body: ${formatSize(bytes)} across ${lines} lines — written to file (too large to inline)`,
|
|
409
|
+
`Saved-To: ${file}`,
|
|
410
|
+
"",
|
|
411
|
+
"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 (^#).",
|
|
412
|
+
"",
|
|
413
|
+
"----- preview (first 60 lines) -----",
|
|
414
|
+
buildPreview(body),
|
|
415
|
+
].join("\n"),
|
|
416
|
+
}],
|
|
417
|
+
details: { ...details, spilled: true, file },
|
|
418
|
+
};
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
renderCall(args, theme, _context) {
|
|
422
|
+
let text = theme.fg("toolTitle", theme.bold("doc_to_md "));
|
|
423
|
+
text += theme.fg("accent", args.path ?? "");
|
|
424
|
+
return new Text(text, 0, 0);
|
|
425
|
+
},
|
|
426
|
+
|
|
427
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
428
|
+
if (isPartial) {
|
|
429
|
+
return new Text(theme.fg("warning", "Converting..."), 0, 0);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const details = result.details as DocToMdDetails | undefined;
|
|
433
|
+
const content = result.content[0];
|
|
434
|
+
const fullText = content?.type === "text" ? content.text : "";
|
|
435
|
+
|
|
436
|
+
if (context.isError) {
|
|
437
|
+
const firstLine = fullText.split("\n")[0] || "doc_to_md failed";
|
|
438
|
+
return new Text(theme.fg("error", firstLine), 0, 0);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const sep = theme.fg("dim", " · ");
|
|
442
|
+
const parts: string[] = [];
|
|
443
|
+
parts.push(theme.fg("muted", details?.inputType ?? "?"));
|
|
444
|
+
parts.push(
|
|
445
|
+
details?.degraded
|
|
446
|
+
? theme.fg("warning", "unpdf (degraded)")
|
|
447
|
+
: theme.fg("muted", "pymupdf4llm"),
|
|
448
|
+
);
|
|
449
|
+
parts.push(theme.fg("dim", formatSize(details?.bytes ?? 0)));
|
|
450
|
+
if (details?.spilled) parts.push(theme.fg("warning", "→ file"));
|
|
451
|
+
|
|
452
|
+
let text = parts.join(sep);
|
|
453
|
+
|
|
454
|
+
if (!expanded) {
|
|
455
|
+
const lineCount = details?.lines ?? (fullText ? fullText.split("\n").length : 0);
|
|
456
|
+
if (lineCount > 0) {
|
|
457
|
+
text += sep + theme.fg("dim", `${lineCount} lines`);
|
|
458
|
+
}
|
|
459
|
+
text += " " + theme.fg("dim", `(${keyHint("app.tools.expand", "to expand")})`);
|
|
460
|
+
return new Text(text, 0, 0);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (fullText) {
|
|
464
|
+
for (const line of fullText.split("\n")) {
|
|
465
|
+
text += `\n${theme.fg("toolOutput", line)}`;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return new Text(text, 0, 0);
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared settings-resolution for pi-quiver extensions that read opt-in
|
|
3
|
+
* config from pi's `settings.json`.
|
|
4
|
+
*
|
|
5
|
+
* Layers, lowest precedence first:
|
|
6
|
+
* 1. global - `<getAgentDir()>/settings.json`
|
|
7
|
+
* 2. project - `<cwd>/.pi/settings.json`
|
|
8
|
+
*
|
|
9
|
+
* The global path comes from pi's own `getAgentDir()`, which honours the
|
|
10
|
+
* `PI_CODING_AGENT_DIR` env override (else `~/.pi/agent`). That keeps it
|
|
11
|
+
* correct when these extensions are consumed as a git-tag-pinned package -
|
|
12
|
+
* unlike deriving the path from `import.meta.url`, which only held while an
|
|
13
|
+
* extension lived inside `<agentHome>/extensions/`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
|
|
20
|
+
export function readSettings(path: string): Record<string, unknown> | undefined {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function settingsPaths(cwd: string): string[] {
|
|
29
|
+
return [join(getAgentDir(), "settings.json"), join(cwd, ".pi", "settings.json")];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a single extension config key across the settings layers. `coerce`
|
|
34
|
+
* validates each layer's raw value into a partial patch (or `undefined` to
|
|
35
|
+
* skip); patches merge over `defaults` in layer order (project wins).
|
|
36
|
+
*/
|
|
37
|
+
export function resolveConfig<T extends object>(
|
|
38
|
+
cwd: string,
|
|
39
|
+
key: string,
|
|
40
|
+
defaults: T,
|
|
41
|
+
coerce: (raw: unknown) => Partial<T> | undefined,
|
|
42
|
+
): T {
|
|
43
|
+
const cfg: T = { ...defaults };
|
|
44
|
+
for (const path of settingsPaths(cwd)) {
|
|
45
|
+
const patch = coerce(readSettings(path)?.[key]);
|
|
46
|
+
if (patch) Object.assign(cfg, patch);
|
|
47
|
+
}
|
|
48
|
+
return cfg;
|
|
49
|
+
}
|