pi-quiver 5.2.4 → 5.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 +39 -0
- package/README.md +58 -17
- package/dist/bin/pi-quiver.js +703 -259
- package/dist/lib/unpdf-worker.js +58 -0
- package/extensions/doc_to_md.ts +52 -62
- package/extensions/fast-mode.ts +2 -2
- package/extensions/session-name.ts +2 -2
- package/extensions/slack.ts +1 -1
- package/extensions/sword-header.ts +1 -1
- package/lib/doc-to-md-bundle.ts +139 -0
- package/lib/doc-to-md-core.ts +261 -301
- package/lib/doc-to-md-handle.ts +108 -0
- package/lib/doc-to-md-options.ts +186 -0
- package/lib/extension-config.ts +54 -18
- package/lib/unpdf-worker.ts +54 -0
- package/package.json +6 -4
- package/scripts/doc_to_md.py +369 -0
- package/scripts/pdf_to_md.py +0 -30
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { InputType } from "./doc-to-md-options.ts";
|
|
2
|
+
|
|
3
|
+
export type Tier = "primary" | "fallback" | "unpdf" | "excel";
|
|
4
|
+
export type Engine = "pymupdf4llm" | "pymupdf-text" | "unpdf" | "openpyxl" | "xlrd";
|
|
5
|
+
export type BackendKind = "uv" | "python" | "venv" | "none";
|
|
6
|
+
|
|
7
|
+
export interface OutlineEntry { line: number; level: number; title: string; }
|
|
8
|
+
export interface TocEntry { level: number; title: string; page: number; }
|
|
9
|
+
export interface SheetInfo { index: number; name: string; hidden: boolean; rows: number | null; cols: number | null; hiddenRows: number; hiddenCols: number; }
|
|
10
|
+
|
|
11
|
+
export interface HandleData {
|
|
12
|
+
savedTo: string; imagesDir: string | null; type: InputType; engine: Engine; tier: Tier;
|
|
13
|
+
pageCount: number | null; pages: number[] | null; imageCount: number; bytes: number; lines: number;
|
|
14
|
+
degraded: string | null; fallbackReason: string | null; failedPages: number[]; emptyPages: number[];
|
|
15
|
+
notes: string[]; outline: OutlineEntry[]; outlineTotal: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface InfoData {
|
|
19
|
+
type: InputType; backend: BackendKind; pageCount: number | null; metadata: Record<string, string>;
|
|
20
|
+
toc: TocEntry[]; tocTotal: number; sheets: SheetInfo[] | null; sheetsTotal: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TITLE_MAX = 80;
|
|
24
|
+
const NOTE_MAX_LINES = 5;
|
|
25
|
+
const NOTE_MAX_CHARS = 200;
|
|
26
|
+
const META_MAX_CHARS = 120;
|
|
27
|
+
|
|
28
|
+
// Deliberate local copy of the host package's formatSize - this module must stay pi-free.
|
|
29
|
+
export function formatSize(bytes: number): string {
|
|
30
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
31
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
32
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const trunc = (s: string, max: number) => (s.length > max ? `${s.slice(0, max - 3)}...` : s);
|
|
36
|
+
|
|
37
|
+
export function compactRanges(nums: number[], maxEntries = 20): string {
|
|
38
|
+
const sorted = [...new Set(nums)].sort((a, b) => a - b);
|
|
39
|
+
const parts: string[] = [];
|
|
40
|
+
for (let i = 0; i < sorted.length;) {
|
|
41
|
+
let j = i;
|
|
42
|
+
while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1) j++;
|
|
43
|
+
parts.push(j > i ? `${sorted[i]}-${sorted[j]}` : `${sorted[i]}`);
|
|
44
|
+
i = j + 1;
|
|
45
|
+
}
|
|
46
|
+
if (parts.length <= maxEntries) return parts.join(", ");
|
|
47
|
+
return `${parts.slice(0, maxEntries).join(", ")} (+${parts.length - maxEntries} more)`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function scanOutline(md: string, max: number): { entries: OutlineEntry[]; total: number } {
|
|
51
|
+
const entries: OutlineEntry[] = [];
|
|
52
|
+
let total = 0, inFence = false;
|
|
53
|
+
md.split("\n").forEach((raw, i) => {
|
|
54
|
+
const line = raw.replace(/\r$/, "");
|
|
55
|
+
if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; return; }
|
|
56
|
+
if (inFence) return;
|
|
57
|
+
const m = line.match(/^(#{1,6}) (.*)$/);
|
|
58
|
+
if (!m) return;
|
|
59
|
+
total++;
|
|
60
|
+
if (entries.length < max) entries.push({ line: i + 1, level: m[1].length, title: trunc(m[2].trim(), TITLE_MAX) });
|
|
61
|
+
});
|
|
62
|
+
return { entries, total };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function outlineLines(entries: OutlineEntry[], total: number): string[] {
|
|
66
|
+
if (entries.length === 0) return [];
|
|
67
|
+
const width = Math.max(5, Math.max(...entries.map((e) => `L${e.line}`.length)) + 2);
|
|
68
|
+
const out = ["Outline:", ...entries.map((e) => ` ${`L${e.line}`.padEnd(width)}${"#".repeat(e.level)} ${trunc(e.title, TITLE_MAX)}`)];
|
|
69
|
+
if (total > entries.length) out.push(` (+${total - entries.length} more)`);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function formatHandle(h: HandleData): string {
|
|
74
|
+
const lines = [`Saved-To: ${h.savedTo}`];
|
|
75
|
+
if (h.imagesDir && h.imageCount > 0) lines.push(`Images-Dir: ${h.imagesDir}`);
|
|
76
|
+
lines.push(`Type: ${h.type} Engine: ${h.engine} Tier: ${h.tier}`);
|
|
77
|
+
lines.push(`Page-Count: ${h.pageCount ?? "?"} Pages: ${h.pages ? compactRanges(h.pages) : "all"} Images: ${h.imageCount} Size: ${formatSize(h.bytes)} / ${h.lines} lines`);
|
|
78
|
+
if (h.degraded) lines.push(`Degraded: ${h.degraded}`);
|
|
79
|
+
if (h.fallbackReason) lines.push(`Fallback-Reason: ${h.fallbackReason}`);
|
|
80
|
+
const fe: string[] = [];
|
|
81
|
+
if (h.failedPages.length) fe.push(`Failed-Pages: ${compactRanges(h.failedPages)}`);
|
|
82
|
+
if (h.emptyPages.length) fe.push(`Empty-Pages: ${compactRanges(h.emptyPages)}`);
|
|
83
|
+
if (fe.length) lines.push(fe.join(" "));
|
|
84
|
+
h.notes.slice(0, NOTE_MAX_LINES).forEach((n, i) => lines.push(`${i === 0 ? "Notes: " : " "}${trunc(n, NOTE_MAX_CHARS)}`));
|
|
85
|
+
lines.push(...outlineLines(h.outline, h.outlineTotal));
|
|
86
|
+
return lines.join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function formatInfoHandle(i: InfoData, max: number): string {
|
|
90
|
+
if (i.sheets) {
|
|
91
|
+
const lines = [`Type: ${i.type} Sheets: ${i.sheetsTotal}`];
|
|
92
|
+
for (const s of i.sheets.slice(0, max)) {
|
|
93
|
+
const dims = `rows=${s.rows ?? "?"} cols=${s.cols ?? "?"}`;
|
|
94
|
+
const hidden = s.hiddenRows || s.hiddenCols ? ` hiddenRows=${s.hiddenRows} hiddenCols=${s.hiddenCols}` : "";
|
|
95
|
+
lines.push(` ${trunc(s.name, TITLE_MAX)} ${s.hidden ? "hidden " : ""}${dims}${hidden}`);
|
|
96
|
+
}
|
|
97
|
+
if (i.sheetsTotal > max) lines.push(` (+${i.sheetsTotal - max} more)`);
|
|
98
|
+
return lines.join("\n");
|
|
99
|
+
}
|
|
100
|
+
const lines = [`Type: ${i.type} Page-Count: ${i.pageCount ?? "?"} Backend: ${i.backend}`];
|
|
101
|
+
const meta = Object.entries(i.metadata).filter(([, v]) => v).map(([k, v]) => `${k[0].toUpperCase()}${k.slice(1)}: ${trunc(v, META_MAX_CHARS)}`);
|
|
102
|
+
if (meta.length) lines.push(meta.join(" "));
|
|
103
|
+
if (i.toc.length) {
|
|
104
|
+
lines.push("TOC:", ...i.toc.slice(0, max).map((t) => ` L${t.level} ${trunc(t.title, TITLE_MAX)} (p${t.page})`));
|
|
105
|
+
if (i.tocTotal > max) lines.push(` (+${i.tocTotal - max} more)`);
|
|
106
|
+
}
|
|
107
|
+
return lines.join("\n");
|
|
108
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doc_to_md option descriptors - the single source of truth for the pi tool schema,
|
|
3
|
+
* the CLI flags/--help, and the `quiver.docToMd` settings shape. pi-free.
|
|
4
|
+
*/
|
|
5
|
+
import { extname } from "node:path";
|
|
6
|
+
|
|
7
|
+
export type InputType = "pdf" | "docx" | "pptx" | "xlsx" | "xls";
|
|
8
|
+
export type ImageFormat = "png" | "jpg";
|
|
9
|
+
|
|
10
|
+
export interface Tunables {
|
|
11
|
+
primaryTimeoutMs: number;
|
|
12
|
+
fallbackTimeoutMs: number;
|
|
13
|
+
sofficeTimeoutMs: number;
|
|
14
|
+
excelTimeoutMs: number;
|
|
15
|
+
warmTimeoutMs: number;
|
|
16
|
+
pymupdfVersion: string;
|
|
17
|
+
imageDpi: number;
|
|
18
|
+
imageFormat: ImageFormat;
|
|
19
|
+
maxCellsPerSheet: number;
|
|
20
|
+
maxOutputBytes: number;
|
|
21
|
+
outlineMaxEntries: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DocToMdOptions extends Tunables {
|
|
25
|
+
path: string;
|
|
26
|
+
info: boolean;
|
|
27
|
+
pages: number[] | null;
|
|
28
|
+
outputDir: string | null;
|
|
29
|
+
overwrite: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What adapters pass in: intents as raw strings/booleans, tunables optional. */
|
|
33
|
+
export interface PerCallInput extends Partial<Tunables> {
|
|
34
|
+
path: string;
|
|
35
|
+
info?: boolean;
|
|
36
|
+
pages?: string | null;
|
|
37
|
+
outputDir?: string | null;
|
|
38
|
+
overwrite?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type DescriptorType = "string" | "int" | "bool" | "pages" | "enum" | "version";
|
|
42
|
+
|
|
43
|
+
export interface OptionDescriptor {
|
|
44
|
+
key: keyof DocToMdOptions;
|
|
45
|
+
flag: string | null;
|
|
46
|
+
type: DescriptorType;
|
|
47
|
+
default: string | number | boolean | null;
|
|
48
|
+
settable: boolean;
|
|
49
|
+
env?: string;
|
|
50
|
+
enumValues?: readonly string[];
|
|
51
|
+
help: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class UsageError extends Error {}
|
|
55
|
+
|
|
56
|
+
export const MIN_PYMUPDF4LLM = "1.27.0";
|
|
57
|
+
const VERSION_RE = /^\d+(\.\d+)*$/;
|
|
58
|
+
|
|
59
|
+
export const DOC_TO_MD_OPTIONS: readonly OptionDescriptor[] = [
|
|
60
|
+
{ key: "path", flag: null, type: "string", default: null, settable: false, help: "Local .pdf .docx .pptx .xlsx .xls file" },
|
|
61
|
+
{ key: "info", flag: "--info", type: "bool", default: false, settable: false, help: "Inspect only (page count, metadata, TOC or sheet inventory); no bundle" },
|
|
62
|
+
{ key: "pages", flag: "--pages", type: "pages", default: null, settable: false, help: "Inclusive 1-based pages, e.g. \"12-15\" or \"3,7,10-12\" (PDF/DOCX/PPTX only); default all" },
|
|
63
|
+
{ key: "outputDir", flag: "--output-dir", type: "string", default: null, settable: false, help: "Bundle root for <stem>.md + images/; default a per-call temp dir" },
|
|
64
|
+
{ key: "overwrite", flag: "--overwrite", type: "bool", default: false, settable: false, help: "Replace an existing completed <stem>.md bundle" },
|
|
65
|
+
{ key: "primaryTimeoutMs", flag: "--primary-timeout", type: "int", default: 60000, settable: true, env: "PI_DOC_TO_MD_CONVERT_TIMEOUT_MS", help: "pymupdf4llm tier; also the unpdf tier" },
|
|
66
|
+
{ key: "fallbackTimeoutMs", flag: "--fallback-timeout", type: "int", default: 30000, settable: true, help: "PyMuPDF get_text tier; also info on PDF" },
|
|
67
|
+
{ key: "sofficeTimeoutMs", flag: "--soffice-timeout", type: "int", default: 120000, settable: true, env: "PI_DOC_TO_MD_SOFFICE_TIMEOUT_MS", help: "DOCX/PPTX -> PDF via LibreOffice" },
|
|
68
|
+
{ key: "excelTimeoutMs", flag: "--excel-timeout", type: "int", default: 60000, settable: true, help: "Excel child (both openpyxl loads); also info on Excel" },
|
|
69
|
+
{ key: "warmTimeoutMs", flag: "--warm-timeout", type: "int", default: 120000, settable: true, env: "PI_DOC_TO_MD_WARM_TIMEOUT_MS", help: "Absolute backend discovery/bootstrap deadline (first call per process)" },
|
|
70
|
+
{ key: "pymupdfVersion", flag: "--pymupdf-version", type: "version", default: "1.27.2.3", settable: true, env: "PI_DOC_TO_MD_PYMUPDF_VERSION", help: "pymupdf4llm pin (>= 1.27.0)" },
|
|
71
|
+
{ key: "imageDpi", flag: "--image-dpi", type: "int", default: 150, settable: true, help: "Render DPI for page images" },
|
|
72
|
+
{ key: "imageFormat", flag: "--image-format", type: "enum", default: "png", settable: true, enumValues: ["png", "jpg"], help: "Rendered image format (embedded images keep their native extension)" },
|
|
73
|
+
{ key: "maxCellsPerSheet", flag: "--max-cells-per-sheet", type: "int", default: 50000, settable: true, help: "rows x cols budget per worksheet" },
|
|
74
|
+
{ key: "maxOutputBytes", flag: "--max-output-bytes", type: "int", default: 20000000, settable: true, help: "Child stdout cap in bytes" },
|
|
75
|
+
{ key: "outlineMaxEntries", flag: "--outline-max-entries", type: "int", default: 40, settable: true, help: "Heading outline / TOC / sheet inventory cap in the handle" },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
export const TUNABLE_DEFAULTS: Tunables = Object.fromEntries(
|
|
79
|
+
DOC_TO_MD_OPTIONS.filter((d) => d.settable).map((d) => [d.key, d.default]),
|
|
80
|
+
) as unknown as Tunables;
|
|
81
|
+
|
|
82
|
+
export function versionAtLeast(v: string, min: string): boolean {
|
|
83
|
+
const a = v.split(".").map(Number), b = min.split(".").map(Number);
|
|
84
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
85
|
+
const x = a[i] ?? 0, y = b[i] ?? 0;
|
|
86
|
+
if (x !== y) return x > y;
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function coerceValue(d: OptionDescriptor, raw: unknown, fromString = false): { ok: true; value: unknown } | { ok: false; reason: string } {
|
|
92
|
+
switch (d.type) {
|
|
93
|
+
case "int": {
|
|
94
|
+
const n = fromString && typeof raw === "string" ? Number(raw) : raw;
|
|
95
|
+
return typeof n === "number" && Number.isInteger(n) && n > 0 ? { ok: true, value: n } : { ok: false, reason: "must be a positive integer" };
|
|
96
|
+
}
|
|
97
|
+
case "bool": return typeof raw === "boolean" ? { ok: true, value: raw } : { ok: false, reason: "must be true or false" };
|
|
98
|
+
case "enum": return typeof raw === "string" && d.enumValues!.includes(raw) ? { ok: true, value: raw } : { ok: false, reason: `must be one of ${d.enumValues!.join(", ")}` };
|
|
99
|
+
case "version":
|
|
100
|
+
if (typeof raw !== "string" || !VERSION_RE.test(raw)) return { ok: false, reason: "must be digits and dots" };
|
|
101
|
+
if (!versionAtLeast(raw, MIN_PYMUPDF4LLM)) return { ok: false, reason: `must be >= ${MIN_PYMUPDF4LLM}` };
|
|
102
|
+
return { ok: true, value: raw };
|
|
103
|
+
case "string": return typeof raw === "string" && raw.length > 0 ? { ok: true, value: raw } : { ok: false, reason: "must be a non-empty string" };
|
|
104
|
+
case "pages": return typeof raw === "string" ? { ok: true, value: parsePages(raw) } : { ok: false, reason: "must be a string" };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Type-validation boundary for `quiver.docToMd`: ill-typed keys are dropped with one warning each; unknown/intent keys are skipped (the settings lint reports them). */
|
|
109
|
+
export function coerceDocToMdSettings(raw: unknown, warn: (message: string) => void = console.warn): Partial<Tunables> | undefined {
|
|
110
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
111
|
+
const out: Record<string, unknown> = {};
|
|
112
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
113
|
+
const d = DOC_TO_MD_OPTIONS.find((o) => o.key === k);
|
|
114
|
+
if (!d || !d.settable) continue;
|
|
115
|
+
const c = coerceValue(d, v);
|
|
116
|
+
if (!c.ok) { warn(`pi-quiver: quiver.docToMd.${k} ${c.reason}; ignored.`); continue; }
|
|
117
|
+
out[k] = c.value;
|
|
118
|
+
}
|
|
119
|
+
return out as Partial<Tunables>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function parsePages(spec: string): number[] {
|
|
123
|
+
const out = new Set<number>();
|
|
124
|
+
const parts = spec.split(",").map((s) => s.trim());
|
|
125
|
+
if (parts.length === 0 || parts.some((p) => p === "")) throw new UsageError(`invalid --pages "${spec}": expected e.g. "12-15" or "3,7,10-12"`);
|
|
126
|
+
for (const p of parts) {
|
|
127
|
+
const m = p.match(/^(\d+)(?:-(\d+))?$/);
|
|
128
|
+
if (!m) throw new UsageError(`invalid --pages "${spec}": bad token "${p}"`);
|
|
129
|
+
const a = Number(m[1]), b = m[2] === undefined ? a : Number(m[2]);
|
|
130
|
+
if (a < 1 || b < a) throw new UsageError(`invalid --pages "${spec}": pages are 1-based and ranges ascend`);
|
|
131
|
+
for (let i = a; i <= b; i++) out.add(i);
|
|
132
|
+
}
|
|
133
|
+
return [...out].sort((x, y) => x - y);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function sanitizeStem(base: string): string {
|
|
137
|
+
const s = base.replace(/[^A-Za-z0-9._-]+/g, "_");
|
|
138
|
+
return s.length ? s : "document";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const SUPPORTED: Record<string, InputType> = { ".pdf": "pdf", ".docx": "docx", ".pptx": "pptx", ".xlsx": "xlsx", ".xls": "xls" };
|
|
142
|
+
|
|
143
|
+
export function classifyInput(filePath: string): InputType {
|
|
144
|
+
const t = SUPPORTED[extname(filePath).toLowerCase()];
|
|
145
|
+
if (!t) throw new Error(`Unsupported file type "${extname(filePath) || "(none)"}"; supported: .pdf, .docx, .pptx, .xlsx, .xls`);
|
|
146
|
+
return t;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** per-call > settings > deprecated env > default. Throws UsageError for bad per-call values or info + bundle option. */
|
|
150
|
+
export function resolveOptions(perCall: PerCallInput, settings: Partial<Tunables>, env: NodeJS.ProcessEnv): DocToMdOptions {
|
|
151
|
+
const out: Record<string, unknown> = { path: perCall.path };
|
|
152
|
+
for (const d of DOC_TO_MD_OPTIONS) {
|
|
153
|
+
if (d.key === "path") continue;
|
|
154
|
+
let value: unknown = d.default;
|
|
155
|
+
if (d.env && env[d.env] !== undefined) {
|
|
156
|
+
const c = coerceValue(d, env[d.env], true);
|
|
157
|
+
if (!c.ok) throw new UsageError(`${d.env} ${c.reason} (got "${env[d.env]}")`);
|
|
158
|
+
value = c.value;
|
|
159
|
+
}
|
|
160
|
+
if (d.settable && (settings as Record<string, unknown>)[d.key] !== undefined) value = (settings as Record<string, unknown>)[d.key];
|
|
161
|
+
const pc = (perCall as unknown as Record<string, unknown>)[d.key];
|
|
162
|
+
if (pc !== undefined && pc !== null) {
|
|
163
|
+
const c = coerceValue(d, pc);
|
|
164
|
+
if (!c.ok) throw new UsageError(`${d.flag} ${c.reason}`);
|
|
165
|
+
value = c.value;
|
|
166
|
+
}
|
|
167
|
+
out[d.key] = value;
|
|
168
|
+
}
|
|
169
|
+
const o = out as unknown as DocToMdOptions;
|
|
170
|
+
if (o.info && (o.pages !== null || o.outputDir !== null || o.overwrite)) {
|
|
171
|
+
throw new UsageError("--info cannot be combined with --pages, --output-dir or --overwrite");
|
|
172
|
+
}
|
|
173
|
+
return o;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function renderHelp(): string {
|
|
177
|
+
const row = (d: OptionDescriptor) => ` ${(d.flag ?? "<path>").padEnd(26)} ${d.help}${d.default !== null && d.key !== "info" && d.key !== "overwrite" ? ` (default ${d.default})` : ""}`;
|
|
178
|
+
return [
|
|
179
|
+
"Usage: pi-quiver doc-to-md [flags] <path>",
|
|
180
|
+
"", "Per-call:", ...DOC_TO_MD_OPTIONS.filter((d) => !d.settable).map(row),
|
|
181
|
+
"", "Tunables (also settable under quiver.docToMd in settings.json; per-call > settings > default):",
|
|
182
|
+
...DOC_TO_MD_OPTIONS.filter((d) => d.settable).map(row),
|
|
183
|
+
"", "Result: a handle (Saved-To, Images-Dir, Page-Count, Outline ...). Read the Saved-To file for the Markdown.",
|
|
184
|
+
"Exit codes: 0 success, 1 runtime error, 2 usage error.",
|
|
185
|
+
].join("\n");
|
|
186
|
+
}
|
package/lib/extension-config.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { readFileSync } from "node:fs";
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { DOC_TO_MD_OPTIONS } from "./doc-to-md-options.ts";
|
|
23
24
|
|
|
24
25
|
export function readSettings(path: string): Record<string, unknown> | undefined {
|
|
25
26
|
try {
|
|
@@ -36,6 +37,22 @@ export function settingsPaths(cwd: string): string[] {
|
|
|
36
37
|
/** Flat top-level fallback is frozen to these pre-quiver keys; never extend. */
|
|
37
38
|
const LEGACY_FLAT_KEYS = new Set(["fastMode", "sessionAutoName", "swordHeader", "providerStallWatchdog"]);
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Lint allowlist: every pi-quiver settings block and its fields. A key read
|
|
42
|
+
* by an extension but missing here is reported as unknown - register new
|
|
43
|
+
* keys here first.
|
|
44
|
+
*/
|
|
45
|
+
export const QUIVER_CONFIG_KEYS: Record<string, readonly string[]> = {
|
|
46
|
+
fastMode: ["enabled"],
|
|
47
|
+
sessionAutoName: ["enabled", "ghosttyTab", "herdrTab", "rules", "deny", "revisitFirstTurn", "revisitEveryTurns"],
|
|
48
|
+
swordHeader: ["enabled"],
|
|
49
|
+
providerStallWatchdog: ["enabled", "firstEventMs", "warningMs", "recoveryMs", "maxStallRetries"],
|
|
50
|
+
slack: ["enabled", "cachePath", "policyPath", "userTokenEnv", "botTokenEnv", "uploadThresholdChars"],
|
|
51
|
+
docToMd: DOC_TO_MD_OPTIONS.filter((o) => o.settable).map((o) => o.key),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const isPlainObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
55
|
+
|
|
39
56
|
const emittedWarnings = new Set<string>();
|
|
40
57
|
|
|
41
58
|
function emitWarning(warn: ((message: string) => void) | undefined, message: string): void {
|
|
@@ -44,14 +61,44 @@ function emitWarning(warn: ((message: string) => void) | undefined, message: str
|
|
|
44
61
|
warn(message);
|
|
45
62
|
}
|
|
46
63
|
|
|
64
|
+
/** Structural findings for one parsed settings file: flat legacy use, non-object quiver, unknown blocks, unknown fields. */
|
|
65
|
+
function lintSettings(settings: Record<string, unknown>): string[] {
|
|
66
|
+
const lines: string[] = [];
|
|
67
|
+
for (const key of Object.keys(settings)) if (LEGACY_FLAT_KEYS.has(key)) lines.push(`"${key}" at top level - move under "quiver"`);
|
|
68
|
+
const root = settings.quiver;
|
|
69
|
+
if (root === undefined) return lines;
|
|
70
|
+
if (!isPlainObject(root)) return [...lines, `"quiver" is not an object - ignored`];
|
|
71
|
+
const fieldLines: string[] = [];
|
|
72
|
+
for (const [block, value] of Object.entries(root)) {
|
|
73
|
+
if (!Object.hasOwn(QUIVER_CONFIG_KEYS, block)) {
|
|
74
|
+
lines.push(`"quiver.${block}" - unknown block; accepted: ${Object.keys(QUIVER_CONFIG_KEYS).join(", ")}`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (!isPlainObject(value)) continue;
|
|
78
|
+
const fields = QUIVER_CONFIG_KEYS[block];
|
|
79
|
+
for (const field of Object.keys(value)) if (!fields.includes(field)) fieldLines.push(`"quiver.${block}.${field}" - unknown; accepted: ${fields.join(", ")}`);
|
|
80
|
+
}
|
|
81
|
+
return [...lines, ...fieldLines];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function formatLint(findings: Array<[path: string, lines: string[]]>): string {
|
|
85
|
+
const tail = "unknown or misplaced keys - unknown ones fall back to defaults";
|
|
86
|
+
if (findings.length === 1) {
|
|
87
|
+
const [path, lines] = findings[0];
|
|
88
|
+
return [`pi-quiver settings (${path}): ${tail}`, ...lines.map((l) => ` ${l}`)].join("\n");
|
|
89
|
+
}
|
|
90
|
+
return [`pi-quiver settings: ${tail}`, ...findings.flatMap(([path, lines]) => [` ${path}`, ...lines.map((l) => ` ${l}`)])].join("\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
47
93
|
/**
|
|
48
94
|
* Resolve a single extension config key across the settings layers.
|
|
49
95
|
* `quiver.<key>` wins over flat `<key>` within a layer by presence (even
|
|
50
96
|
* when malformed); flat fallback exists only for LEGACY_FLAT_KEYS. `coerce`
|
|
51
97
|
* validates the layer's candidate into a partial patch (or `undefined` to
|
|
52
98
|
* skip); patches merge over `defaults` in layer order (project wins).
|
|
53
|
-
* `warn` receives one sentence per malformed
|
|
54
|
-
*
|
|
99
|
+
* `warn` receives one sentence per malformed key plus one condensed
|
|
100
|
+
* structural lint (flat legacy use, unknown blocks, unknown fields) - each
|
|
101
|
+
* distinct message once per process.
|
|
55
102
|
*/
|
|
56
103
|
export function resolveConfig<T extends object>(
|
|
57
104
|
cwd: string,
|
|
@@ -61,32 +108,21 @@ export function resolveConfig<T extends object>(
|
|
|
61
108
|
warn?: (message: string) => void,
|
|
62
109
|
): T {
|
|
63
110
|
const cfg: T = { ...defaults };
|
|
64
|
-
|
|
65
|
-
let flatSeen = false;
|
|
111
|
+
const findings: Array<[string, string[]]> = [];
|
|
66
112
|
for (const path of settingsPaths(cwd)) {
|
|
67
113
|
const settings = readSettings(path);
|
|
68
114
|
if (!settings) continue;
|
|
69
|
-
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
root = undefined;
|
|
73
|
-
}
|
|
74
|
-
const nested = root as Record<string, unknown> | undefined;
|
|
115
|
+
const lines = lintSettings(settings);
|
|
116
|
+
if (lines.length > 0) findings.push([path, lines]);
|
|
117
|
+
const nested = isPlainObject(settings.quiver) ? settings.quiver : undefined;
|
|
75
118
|
const hasNested = nested !== undefined && Object.hasOwn(nested, key);
|
|
76
119
|
const hasFlat = Object.hasOwn(settings, key);
|
|
77
|
-
nestedSeen ||= hasNested;
|
|
78
|
-
flatSeen ||= hasFlat;
|
|
79
120
|
if (!hasNested && !(hasFlat && LEGACY_FLAT_KEYS.has(key))) continue;
|
|
80
121
|
const candidate = hasNested ? nested![key] : settings[key];
|
|
81
122
|
const patch = coerce(candidate);
|
|
82
123
|
if (patch) Object.assign(cfg, patch);
|
|
83
124
|
else emitWarning(warn, `pi-quiver: "${key}" in ${path} has an unrecognized value; ignored.`);
|
|
84
125
|
}
|
|
85
|
-
if (
|
|
86
|
-
emitWarning(
|
|
87
|
-
warn,
|
|
88
|
-
`pi-quiver: "${key}" is set both flat and under "quiver" (nested wins within a layer; across layers the project layer wins regardless of shape) - move the flat entry under "quiver".`,
|
|
89
|
-
);
|
|
90
|
-
}
|
|
126
|
+
if (findings.length > 0) emitWarning(warn, formatLint(findings));
|
|
91
127
|
return cfg;
|
|
92
128
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* unpdf child (Node). argv[2] = mode (info | pdf-text); options JSON on stdin; JSON result on stdout.
|
|
3
|
+
* Exit 0 ok, 1 failure, 3 user error. Runs as its own process so a PDF.js stall is killable.
|
|
4
|
+
* Only selected pages are requested (getPage(n)), so an unselected toxic page cannot burn the budget.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { getDocumentProxy } from "unpdf";
|
|
8
|
+
|
|
9
|
+
const SEP = (n: number) => `--- end of page.page_number=${n} ---`;
|
|
10
|
+
|
|
11
|
+
async function readStdin(): Promise<string> {
|
|
12
|
+
const chunks: Buffer[] = [];
|
|
13
|
+
for await (const c of process.stdin) chunks.push(c as Buffer);
|
|
14
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function main(): Promise<number> {
|
|
18
|
+
const mode = process.argv[2];
|
|
19
|
+
if (mode !== "info" && mode !== "pdf-text") { process.stderr.write("usage: unpdf-worker <info|pdf-text>\n"); return 1; }
|
|
20
|
+
const o = JSON.parse((await readStdin()) || "{}") as { path: string; pages?: number[] | null };
|
|
21
|
+
try {
|
|
22
|
+
const pdf = await getDocumentProxy(new Uint8Array(readFileSync(o.path)));
|
|
23
|
+
const pageCount = pdf.numPages;
|
|
24
|
+
if (mode === "info") {
|
|
25
|
+
const meta = (await pdf.getMetadata().catch(() => null))?.info as Record<string, unknown> | undefined;
|
|
26
|
+
const metadata: Record<string, string> = {};
|
|
27
|
+
for (const k of ["Title", "Author", "Subject", "Creator", "Producer"]) if (typeof meta?.[k] === "string" && meta[k]) metadata[k.toLowerCase()] = meta[k] as string;
|
|
28
|
+
process.stdout.write(JSON.stringify({ pageCount, metadata, toc: [] }));
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
const pages = o.pages ?? Array.from({ length: pageCount }, (_, i) => i + 1);
|
|
32
|
+
const bad = pages.filter((p) => p < 1 || p > pageCount);
|
|
33
|
+
if (bad.length) { process.stdout.write(JSON.stringify({ error: `pages out of range: ${bad.join(", ")} (document has ${pageCount} pages)`, pageCount })); return 3; }
|
|
34
|
+
const out: string[] = [], emptyPages: number[] = [], failedPages: { page: number; error: string }[] = [];
|
|
35
|
+
for (const n of pages) {
|
|
36
|
+
let text = "";
|
|
37
|
+
try {
|
|
38
|
+
const page = await pdf.getPage(n);
|
|
39
|
+
const content = await page.getTextContent();
|
|
40
|
+
text = content.items.map((it) => ("str" in it ? it.str + (it.hasEOL ? "\n" : " ") : "")).join("").trim();
|
|
41
|
+
} catch (e) { failedPages.push({ page: n, error: String(e).slice(0, 300) }); }
|
|
42
|
+
if (!text) emptyPages.push(n);
|
|
43
|
+
out.push(text, SEP(n));
|
|
44
|
+
}
|
|
45
|
+
if (failedPages.length === pages.length) throw new Error(`every selected page failed: ${failedPages[0].error}`);
|
|
46
|
+
process.stdout.write(JSON.stringify({ markdown: `${out.join("\n\n")}\n`, pages, pageCount, emptyPages, failedPages, notes: ["No images: unpdf backend"] }));
|
|
47
|
+
return 0;
|
|
48
|
+
} catch (e) {
|
|
49
|
+
process.stderr.write(`${e instanceof Error ? e.stack ?? e.message : String(e)}\n`);
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
main().then((code) => { process.exitCode = code; });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-quiver",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.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",
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
"pdf",
|
|
23
23
|
"docx",
|
|
24
24
|
"pptx",
|
|
25
|
+
"xlsx",
|
|
26
|
+
"xls",
|
|
25
27
|
"cli"
|
|
26
28
|
],
|
|
27
29
|
"engines": {
|
|
@@ -34,7 +36,7 @@
|
|
|
34
36
|
"extensions",
|
|
35
37
|
"lib",
|
|
36
38
|
"dist",
|
|
37
|
-
"scripts/
|
|
39
|
+
"scripts/doc_to_md.py",
|
|
38
40
|
"types/**/*.d.ts",
|
|
39
41
|
"README.md",
|
|
40
42
|
"CHANGELOG.md"
|
|
@@ -44,7 +46,7 @@
|
|
|
44
46
|
"test": "node --test \"test/*.test.ts\"",
|
|
45
47
|
"typecheck": "npx -y tsc --noEmit",
|
|
46
48
|
"test:all": "npm run check:agents-core && npm run test && npm run typecheck",
|
|
47
|
-
"build": "esbuild bin/pi-quiver.ts --bundle --packages=external --format=esm --platform=node --
|
|
49
|
+
"build": "esbuild bin/pi-quiver.ts lib/unpdf-worker.ts --bundle --packages=external --format=esm --platform=node --outdir=dist --outbase=.",
|
|
48
50
|
"prepack": "npm run build"
|
|
49
51
|
},
|
|
50
52
|
"pi": {
|
|
@@ -82,7 +84,7 @@
|
|
|
82
84
|
"jsdom": "^26.0.0",
|
|
83
85
|
"turndown": "^7.2.0",
|
|
84
86
|
"turndown-plugin-gfm": "^1.0.2",
|
|
85
|
-
"unpdf": "^1.
|
|
87
|
+
"unpdf": "^1.8.1"
|
|
86
88
|
},
|
|
87
89
|
"devDependencies": {
|
|
88
90
|
"@earendil-works/pi-ai": "^0.84.2",
|