@zosmaai/pi-llm-wiki 0.3.0 → 0.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.
@@ -0,0 +1,369 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { exec } from "./utils.js";
3
+
4
+ export interface ExtractedContent {
5
+ extracted: string;
6
+ title?: string;
7
+ }
8
+
9
+ export interface FileExtractor {
10
+ format: string;
11
+ shouldReadText: boolean;
12
+ matches(filePath: string): boolean;
13
+ extract(args: FileExtractArgs): Promise<string> | string;
14
+ }
15
+
16
+ interface FileExtractArgs {
17
+ pi: ExtensionAPI;
18
+ filePath: string;
19
+ content: string;
20
+ signal?: AbortSignal;
21
+ }
22
+
23
+ interface UrlExtractor {
24
+ matches(url: string): boolean;
25
+ extract(args: UrlExtractArgs): Promise<ExtractedContent>;
26
+ }
27
+
28
+ interface UrlExtractArgs {
29
+ pi: ExtensionAPI;
30
+ url: string;
31
+ signal?: AbortSignal;
32
+ }
33
+
34
+ const DEFAULT_MARKITDOWN_TIMEOUT_MS = 180_000;
35
+ const DEFAULT_CURL_TIMEOUT_SECONDS = 30;
36
+
37
+ const FILE_EXTRACTORS: FileExtractor[] = [
38
+ {
39
+ format: "pdf",
40
+ shouldReadText: false,
41
+ matches: hasExtension(".pdf"),
42
+ extract: ({ pi, filePath, signal }) => extractPdf(pi, filePath, signal),
43
+ },
44
+ textFileExtractor("markdown", [".md"]),
45
+ textFileExtractor("text", [".txt"]),
46
+ textFileExtractor("html", [".html", ".htm"]),
47
+ {
48
+ format: "xml",
49
+ shouldReadText: true,
50
+ matches: hasExtension(".xml"),
51
+ extract: ({ content }) => xmlToMarkdown(content),
52
+ },
53
+ {
54
+ format: "json",
55
+ shouldReadText: true,
56
+ matches: hasExtension(".json"),
57
+ extract: ({ content }) => jsonToMarkdown(content),
58
+ },
59
+ textFileExtractor("docx", [".docx"]),
60
+ textFileExtractor("file", []),
61
+ ];
62
+
63
+ const URL_EXTRACTORS: UrlExtractor[] = [
64
+ {
65
+ matches: isPdfUrl,
66
+ extract: ({ pi, url, signal }) => extractPdfUrl(pi, url, signal),
67
+ },
68
+ {
69
+ matches: () => true,
70
+ extract: ({ pi, url, signal }) => extractTextUrl(pi, url, signal),
71
+ },
72
+ ];
73
+
74
+ export function fileExtractorFor(filePath: string): FileExtractor {
75
+ return (
76
+ FILE_EXTRACTORS.find((extractor) => extractor.matches(filePath)) ?? FILE_EXTRACTORS.at(-1)!
77
+ );
78
+ }
79
+
80
+ export function extractUrlContent(
81
+ pi: ExtensionAPI,
82
+ url: string,
83
+ signal?: AbortSignal,
84
+ ): Promise<ExtractedContent> {
85
+ const extractor =
86
+ URL_EXTRACTORS.find((candidate) => candidate.matches(url)) ?? URL_EXTRACTORS.at(-1)!;
87
+ return extractor.extract({ pi, url, signal });
88
+ }
89
+
90
+ export function pdfExtractionFailureMessage(source: string): string {
91
+ return `_PDF content could not be converted to markdown from ${source}. Try increasing WIKI_MARKITDOWN_TIMEOUT_MS._\n`;
92
+ }
93
+
94
+ function textFileExtractor(format: string, extensions: string[]): FileExtractor {
95
+ return {
96
+ format,
97
+ shouldReadText: true,
98
+ matches: extensions.length ? hasAnyExtension(extensions) : () => true,
99
+ extract: ({ content }) => content,
100
+ };
101
+ }
102
+
103
+ function hasExtension(extension: string): (path: string) => boolean {
104
+ return (path) => path.toLowerCase().endsWith(extension);
105
+ }
106
+
107
+ function hasAnyExtension(extensions: string[]): (path: string) => boolean {
108
+ return (path) => extensions.some((extension) => hasExtension(extension)(path));
109
+ }
110
+
111
+ async function extractPdf(pi: ExtensionAPI, source: string, signal?: AbortSignal): Promise<string> {
112
+ const extracted = await extractWithMarkItDown(pi, source, signal);
113
+ return extracted || pdfExtractionFailureMessage(source);
114
+ }
115
+
116
+ async function extractPdfUrl(
117
+ pi: ExtensionAPI,
118
+ url: string,
119
+ signal?: AbortSignal,
120
+ ): Promise<ExtractedContent> {
121
+ const extracted = await extractPdf(pi, url, signal);
122
+ return { extracted, title: titleFromMarkdown(extracted) };
123
+ }
124
+
125
+ async function extractTextUrl(
126
+ pi: ExtensionAPI,
127
+ url: string,
128
+ signal?: AbortSignal,
129
+ ): Promise<ExtractedContent> {
130
+ const markitdownExtracted = await extractWithMarkItDown(pi, url, signal);
131
+ if (markitdownExtracted) {
132
+ return { extracted: markitdownExtracted, title: titleFromMarkdown(markitdownExtracted) };
133
+ }
134
+
135
+ const curlExtracted = await fetchTextUrl(pi, url, signal);
136
+ if (!curlExtracted) return { extracted: "" };
137
+ if (looksLikePdf(curlExtracted)) return { extracted: pdfExtractionFailureMessage(url) };
138
+ return { extracted: curlExtracted, title: titleFromHtml(curlExtracted) };
139
+ }
140
+
141
+ async function extractWithMarkItDown(
142
+ pi: ExtensionAPI,
143
+ source: string,
144
+ signal?: AbortSignal,
145
+ ): Promise<string> {
146
+ if (!(await hasMarkItDown(pi, signal))) return "";
147
+
148
+ try {
149
+ const mdResult = await exec(
150
+ pi,
151
+ "sh",
152
+ ["-c", `uvx --from 'markitdown[pdf]' markitdown "${source}" 2>/dev/null || echo ""`],
153
+ { signal, timeout: markitdownTimeoutMs() },
154
+ );
155
+ return mdResult.stdout.trim() ? mdResult.stdout : "";
156
+ } catch {
157
+ return "";
158
+ }
159
+ }
160
+
161
+ async function hasMarkItDown(pi: ExtensionAPI, signal?: AbortSignal): Promise<boolean> {
162
+ const markitdown = await exec(
163
+ pi,
164
+ "sh",
165
+ ["-c", `which uvx >/dev/null 2>&1 && echo "yes" || echo "no"`],
166
+ { signal },
167
+ );
168
+ return markitdown.stdout.trim() === "yes";
169
+ }
170
+
171
+ async function fetchTextUrl(pi: ExtensionAPI, url: string, signal?: AbortSignal): Promise<string> {
172
+ try {
173
+ const curlResult = await exec(
174
+ pi,
175
+ "curl",
176
+ ["-sL", "--max-time", String(DEFAULT_CURL_TIMEOUT_SECONDS), url],
177
+ {
178
+ signal,
179
+ timeout: (DEFAULT_CURL_TIMEOUT_SECONDS + 5) * 1_000,
180
+ },
181
+ );
182
+ return curlResult.stdout || "";
183
+ } catch {
184
+ return "";
185
+ }
186
+ }
187
+
188
+ function markitdownTimeoutMs(): number {
189
+ return positiveIntegerFromEnv("WIKI_MARKITDOWN_TIMEOUT_MS", DEFAULT_MARKITDOWN_TIMEOUT_MS);
190
+ }
191
+
192
+ function positiveIntegerFromEnv(name: string, fallback: number): number {
193
+ const raw = process.env[name];
194
+ if (!raw) return fallback;
195
+ const parsed = Number.parseInt(raw, 10);
196
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
197
+ }
198
+
199
+ function isPdfUrl(url: string): boolean {
200
+ try {
201
+ return new URL(url).pathname.toLowerCase().endsWith(".pdf");
202
+ } catch {
203
+ return url.toLowerCase().split(/[?#]/, 1)[0].endsWith(".pdf");
204
+ }
205
+ }
206
+
207
+ function looksLikePdf(content: string): boolean {
208
+ return content.trimStart().startsWith("%PDF-");
209
+ }
210
+
211
+ function titleFromMarkdown(markdown: string): string | undefined {
212
+ return markdown.match(/^#\s+(.+)$/m)?.[1]?.trim();
213
+ }
214
+
215
+ function titleFromHtml(html: string): string | undefined {
216
+ return html.match(/<title>([^<]*)<\/title>/i)?.[1]?.trim();
217
+ }
218
+
219
+ /** Basic XML to markdown conversion: strip tags while preserving text structure. */
220
+ function xmlToMarkdown(xml: string): string {
221
+ let title = "";
222
+ const titleMatch = xml.match(/<title[^>]*>([^<]*)<\/title>/i);
223
+ if (titleMatch) title = titleMatch[1].trim();
224
+
225
+ let text = xml.replace(/<\?xml[^>]*\?>\s*/gi, "");
226
+ text = text.replace(/<!DOCTYPE[^>]*>\s*/gi, "");
227
+ text = text.replace(/<\/(p|div|section|article|li|h\d|tr|blockquote|pre)>/gi, "\n");
228
+ text = text.replace(/<br\s*\/?>/gi, "\n");
229
+
230
+ let prev = "";
231
+ while (prev !== text) {
232
+ prev = text;
233
+ text = text.replace(/<[a-zA-Z\/!?][^>]*>/g, "");
234
+ }
235
+ text = text.replace(/</g, "");
236
+
237
+ text = text.replace(/&(?:amp|lt|gt|quot|#\d+);/gi, (entity) => {
238
+ const map: Record<string, string> = { "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"' };
239
+ const lower = entity.toLowerCase();
240
+ if (map[lower]) return map[lower];
241
+ if (lower.startsWith("&#")) return String.fromCodePoint(Number.parseInt(entity.slice(2, -1)));
242
+ return entity;
243
+ });
244
+
245
+ text = text.replace(/\n{3,}/g, "\n\n").trim();
246
+ if (!text) return xml;
247
+
248
+ const lines = [];
249
+ if (title) lines.push(`# ${title}\n`);
250
+ lines.push(text);
251
+ return lines.join("\n\n");
252
+ }
253
+
254
+ function jsonToMarkdown(json: string): string {
255
+ let value: unknown;
256
+ try {
257
+ value = JSON.parse(json);
258
+ } catch {
259
+ return json;
260
+ }
261
+
262
+ const lines: string[] = [];
263
+ const title = titleFromValue(value) || "JSON Extract";
264
+ lines.push(`# ${title}`, "");
265
+ renderJsonValue(value, lines, 0);
266
+
267
+ const markdown = lines
268
+ .join("\n")
269
+ .replace(/\n{3,}/g, "\n\n")
270
+ .trim();
271
+ return markdown || json;
272
+ }
273
+
274
+ function titleFromValue(value: unknown): string | undefined {
275
+ if (!isRecord(value)) return undefined;
276
+ for (const key of ["title", "name", "id"]) {
277
+ const candidate = value[key];
278
+ if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
279
+ }
280
+ return undefined;
281
+ }
282
+
283
+ function isRecord(value: unknown): value is Record<string, unknown> {
284
+ return typeof value === "object" && value !== null && !Array.isArray(value);
285
+ }
286
+
287
+ function renderJsonValue(value: unknown, lines: string[], depth: number, label?: string): void {
288
+ if (Array.isArray(value)) {
289
+ renderJsonArray(value, lines, depth, label);
290
+ return;
291
+ }
292
+
293
+ if (isRecord(value)) {
294
+ renderJsonObject(value, lines, depth, label);
295
+ return;
296
+ }
297
+
298
+ if (label) lines.push(`${indent(depth)}- **${humanizeKey(label)}:** ${formatJsonScalar(value)}`);
299
+ else lines.push(`${indent(depth)}- ${formatJsonScalar(value)}`);
300
+ }
301
+
302
+ function renderJsonObject(
303
+ object: Record<string, unknown>,
304
+ lines: string[],
305
+ depth: number,
306
+ label?: string,
307
+ ): void {
308
+ if (label) {
309
+ lines.push(`${heading(depth)} ${humanizeKey(label)}`, "");
310
+ }
311
+
312
+ for (const [key, value] of Object.entries(object)) {
313
+ if (Array.isArray(value) || isRecord(value)) {
314
+ const childDepth = label ? depth + 1 : depth;
315
+ renderJsonValue(value, lines, childDepth, key);
316
+ } else {
317
+ lines.push(`${indent(depth)}- **${humanizeKey(key)}:** ${formatJsonScalar(value)}`);
318
+ }
319
+ }
320
+ lines.push("");
321
+ }
322
+
323
+ function renderJsonArray(array: unknown[], lines: string[], depth: number, label?: string): void {
324
+ if (label) lines.push(`${heading(depth)} ${humanizeKey(label)}`, "");
325
+
326
+ if (array.length === 0) {
327
+ lines.push(`${indent(depth)}- _(empty)_`, "");
328
+ return;
329
+ }
330
+
331
+ for (const [index, item] of array.entries()) {
332
+ if (isRecord(item)) {
333
+ const itemTitle = titleFromValue(item) || `Item ${index + 1}`;
334
+ const itemDepth = label ? depth + 1 : depth;
335
+ lines.push(`${heading(itemDepth)} ${itemTitle}`, "");
336
+ renderJsonObject(item, lines, itemDepth);
337
+ } else if (Array.isArray(item)) {
338
+ lines.push(`${indent(depth)}- Item ${index + 1}:`);
339
+ renderJsonArray(item, lines, depth + 1);
340
+ } else {
341
+ lines.push(`${indent(depth)}- ${formatJsonScalar(item)}`);
342
+ }
343
+ }
344
+ lines.push("");
345
+ }
346
+
347
+ function formatJsonScalar(value: unknown): string {
348
+ if (value === null) return "null";
349
+ if (typeof value === "string") return value;
350
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
351
+ return String(value);
352
+ }
353
+
354
+ function humanizeKey(key: string): string {
355
+ return key
356
+ .replace(/[_-]+/g, " ")
357
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
358
+ .replace(/\s+/g, " ")
359
+ .trim()
360
+ .replace(/^./, (char) => char.toUpperCase());
361
+ }
362
+
363
+ function heading(depth: number): string {
364
+ return "#".repeat(Math.min(depth + 2, 6));
365
+ }
366
+
367
+ function indent(depth: number): string {
368
+ return " ".repeat(Math.max(0, depth));
369
+ }