@zosmaai/pi-llm-wiki 0.2.2 → 0.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.
|
@@ -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> = { "&": "&", "<": "<", ">": ">", """: '"' };
|
|
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
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { extname, join } from "node:path";
|
|
3
3
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
4
4
|
import { appendEvent } from "./metadata.js";
|
|
5
|
+
import { type ExtractedContent, extractUrlContent, fileExtractorFor } from "./source-extractors.js";
|
|
5
6
|
import { type VaultPaths, exec, fmtDate, nextSourceId, readText, writeJson } from "./utils.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -22,287 +23,206 @@ export interface CaptureResult {
|
|
|
22
23
|
extracted: string;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
function markitdownTimeoutMs(): number {
|
|
29
|
-
return positiveIntegerFromEnv("WIKI_MARKITDOWN_TIMEOUT_MS", DEFAULT_MARKITDOWN_TIMEOUT_MS);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function positiveIntegerFromEnv(name: string, fallback: number): number {
|
|
33
|
-
const raw = process.env[name];
|
|
34
|
-
if (!raw) return fallback;
|
|
35
|
-
const parsed = Number.parseInt(raw, 10);
|
|
36
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function isPdfUrl(url: string): boolean {
|
|
40
|
-
try {
|
|
41
|
-
return new URL(url).pathname.toLowerCase().endsWith(".pdf");
|
|
42
|
-
} catch {
|
|
43
|
-
return url.toLowerCase().split(/[?#]/, 1)[0].endsWith(".pdf");
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function looksLikePdf(content: string): boolean {
|
|
48
|
-
return content.trimStart().startsWith("%PDF-");
|
|
26
|
+
interface SourcePacket {
|
|
27
|
+
sourceId: string;
|
|
28
|
+
packetPath: string;
|
|
49
29
|
}
|
|
50
30
|
|
|
51
|
-
|
|
52
|
-
|
|
31
|
+
interface CaptureSource {
|
|
32
|
+
needsOriginalDir: boolean;
|
|
33
|
+
fallbackText: string;
|
|
34
|
+
preserveOriginal?(packetPath: string): Promise<void>;
|
|
35
|
+
extract(): Promise<ExtractedContent> | ExtractedContent;
|
|
36
|
+
manifest(content: ExtractedContent): Record<string, unknown>;
|
|
37
|
+
event(content: ExtractedContent): Record<string, unknown>;
|
|
53
38
|
}
|
|
54
39
|
|
|
55
40
|
const URL_ORIGINAL_EXTENSIONS = new Set([".html", ".htm", ".md", ".pdf", ".txt", ".xml", ".json"]);
|
|
56
41
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const parsed = new URL(url);
|
|
60
|
-
const ext = extname(parsed.pathname).toLowerCase();
|
|
61
|
-
if (URL_ORIGINAL_EXTENSIONS.has(ext)) return `source${ext}`;
|
|
62
|
-
} catch {
|
|
63
|
-
const path = url.split(/[?#]/, 1)[0] ?? "";
|
|
64
|
-
const ext = extname(path).toLowerCase();
|
|
65
|
-
if (URL_ORIGINAL_EXTENSIONS.has(ext)) return `source${ext}`;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return "source.html";
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async function preserveUrlOriginal(
|
|
42
|
+
/** Capture a URL into a source packet. */
|
|
43
|
+
export async function captureUrl(
|
|
72
44
|
pi: ExtensionAPI,
|
|
73
|
-
|
|
45
|
+
paths: VaultPaths,
|
|
74
46
|
url: string,
|
|
75
47
|
signal?: AbortSignal,
|
|
76
|
-
): Promise<
|
|
77
|
-
|
|
78
|
-
try {
|
|
79
|
-
await exec(pi, "curl", ["-sL", "--max-time", "30", "-o", originalPath, url], {
|
|
80
|
-
signal,
|
|
81
|
-
timeout: 35_000,
|
|
82
|
-
});
|
|
83
|
-
} catch {
|
|
84
|
-
// Preserve best-effort extraction behavior even when the original artifact cannot be saved.
|
|
85
|
-
}
|
|
48
|
+
): Promise<CaptureResult> {
|
|
49
|
+
return captureSource(paths, urlCaptureSource(pi, url, signal));
|
|
86
50
|
}
|
|
87
51
|
|
|
88
|
-
/** Capture a
|
|
89
|
-
export async function
|
|
52
|
+
/** Capture a local file into a source packet. */
|
|
53
|
+
export async function captureFile(
|
|
90
54
|
pi: ExtensionAPI,
|
|
91
55
|
paths: VaultPaths,
|
|
92
|
-
|
|
56
|
+
filePath: string,
|
|
93
57
|
signal?: AbortSignal,
|
|
94
58
|
): Promise<CaptureResult> {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
mkdirSync(packetPath, { recursive: true });
|
|
98
|
-
mkdirSync(join(packetPath, "original"), { recursive: true });
|
|
99
|
-
mkdirSync(join(packetPath, "attachments"), { recursive: true });
|
|
59
|
+
return captureSource(paths, fileCaptureSource(pi, filePath, signal));
|
|
60
|
+
}
|
|
100
61
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
let title = url;
|
|
106
|
-
const isPdf = isPdfUrl(url);
|
|
107
|
-
|
|
108
|
-
// Try MarkItDown first.
|
|
109
|
-
const markitdown = await exec(
|
|
110
|
-
pi,
|
|
111
|
-
"sh",
|
|
112
|
-
["-c", `which uvx >/dev/null 2>&1 && echo "yes" || echo "no"`],
|
|
113
|
-
{ signal },
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
if (markitdown.stdout.trim() === "yes") {
|
|
117
|
-
try {
|
|
118
|
-
const mdResult = await exec(
|
|
119
|
-
pi,
|
|
120
|
-
"sh",
|
|
121
|
-
["-c", `uvx --from 'markitdown[pdf]' markitdown "${url}" 2>/dev/null || echo ""`],
|
|
122
|
-
{ signal, timeout: markitdownTimeoutMs() },
|
|
123
|
-
);
|
|
124
|
-
if (mdResult.stdout.trim()) {
|
|
125
|
-
extracted = mdResult.stdout;
|
|
126
|
-
// Try to extract title from first h1
|
|
127
|
-
const h1Match = extracted.match(/^#\s+(.+)$/m);
|
|
128
|
-
if (h1Match) title = h1Match[1].trim();
|
|
129
|
-
}
|
|
130
|
-
} catch {
|
|
131
|
-
// markitdown failed, fall through
|
|
132
|
-
}
|
|
133
|
-
}
|
|
62
|
+
/** Capture pasted text into a source packet. */
|
|
63
|
+
export function captureText(paths: VaultPaths, text: string, title?: string): CaptureResult {
|
|
64
|
+
return captureSourceSync(paths, textCaptureSource(text, title));
|
|
65
|
+
}
|
|
134
66
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
try {
|
|
142
|
-
const curlResult = await exec(
|
|
143
|
-
pi,
|
|
144
|
-
"curl",
|
|
145
|
-
["-sL", "--max-time", String(DEFAULT_CURL_TIMEOUT_SECONDS), url],
|
|
146
|
-
{
|
|
147
|
-
signal,
|
|
148
|
-
timeout: (DEFAULT_CURL_TIMEOUT_SECONDS + 5) * 1_000,
|
|
149
|
-
},
|
|
150
|
-
);
|
|
151
|
-
if (curlResult.stdout) {
|
|
152
|
-
if (looksLikePdf(curlResult.stdout)) {
|
|
153
|
-
extracted = pdfExtractionFailureMessage(url);
|
|
154
|
-
} else {
|
|
155
|
-
extracted = curlResult.stdout;
|
|
156
|
-
// Try to extract title from HTML
|
|
157
|
-
const titleMatch = extracted.match(/<title>([^<]*)<\/title>/i);
|
|
158
|
-
if (titleMatch) title = titleMatch[1].trim();
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
} catch {
|
|
162
|
-
// curl failed too
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
67
|
+
async function captureSource(paths: VaultPaths, source: CaptureSource): Promise<CaptureResult> {
|
|
68
|
+
const packet = createSourcePacket(paths, source.needsOriginalDir);
|
|
69
|
+
await source.preserveOriginal?.(packet.packetPath);
|
|
70
|
+
const content = await source.extract();
|
|
71
|
+
return finalizeCapture(paths, packet, source, content);
|
|
72
|
+
}
|
|
166
73
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
);
|
|
74
|
+
function captureSourceSync(paths: VaultPaths, source: CaptureSource): CaptureResult {
|
|
75
|
+
const packet = createSourcePacket(paths, source.needsOriginalDir);
|
|
76
|
+
const content = source.extract() as ExtractedContent;
|
|
77
|
+
return finalizeCapture(paths, packet, source, content);
|
|
78
|
+
}
|
|
173
79
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
url,
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
80
|
+
function urlCaptureSource(pi: ExtensionAPI, url: string, signal?: AbortSignal): CaptureSource {
|
|
81
|
+
return {
|
|
82
|
+
needsOriginalDir: true,
|
|
83
|
+
fallbackText: contentExtractionFailureMessage(url),
|
|
84
|
+
preserveOriginal: (packetPath) => preserveUrlOriginal(pi, packetPath, url, signal),
|
|
85
|
+
extract: () => extractUrlContent(pi, url, signal),
|
|
86
|
+
manifest: (content) => ({
|
|
87
|
+
title: content.title || url,
|
|
88
|
+
url,
|
|
89
|
+
format: "web",
|
|
90
|
+
}),
|
|
91
|
+
event: () => ({ url, format: "web" }),
|
|
182
92
|
};
|
|
183
|
-
writeJson(join(packetPath, "manifest.json"), manifest);
|
|
184
|
-
|
|
185
|
-
// Create skeleton source page in wiki
|
|
186
|
-
const sourcePagePath = join(paths.wiki, "sources", `${sourceId}.md`);
|
|
187
|
-
const sourcePageContent = buildSourcePageSkeleton(manifest, extracted);
|
|
188
|
-
writeFileSync(sourcePagePath, sourcePageContent, "utf-8");
|
|
189
|
-
|
|
190
|
-
// Log event
|
|
191
|
-
appendEvent(paths, { kind: "capture", source_id: sourceId, url, format: "web" });
|
|
192
|
-
|
|
193
|
-
return { sourceId, packetPath, sourcePagePath, extracted };
|
|
194
93
|
}
|
|
195
94
|
|
|
196
|
-
|
|
197
|
-
export async function captureFile(
|
|
95
|
+
function fileCaptureSource(
|
|
198
96
|
pi: ExtensionAPI,
|
|
199
|
-
paths: VaultPaths,
|
|
200
97
|
filePath: string,
|
|
201
98
|
signal?: AbortSignal,
|
|
202
|
-
):
|
|
99
|
+
): CaptureSource {
|
|
100
|
+
const fileName = filePath.split("/").pop() || "unknown";
|
|
101
|
+
const extractor = fileExtractorFor(filePath);
|
|
102
|
+
const content = extractor.shouldReadText ? readText(filePath) : "";
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
needsOriginalDir: true,
|
|
106
|
+
fallbackText: "",
|
|
107
|
+
preserveOriginal: (packetPath) =>
|
|
108
|
+
preserveFileOriginal(pi, packetPath, filePath, fileName, content, signal),
|
|
109
|
+
extract: async () => ({
|
|
110
|
+
extracted: await extractor.extract({ pi, filePath, content, signal }),
|
|
111
|
+
}),
|
|
112
|
+
manifest: () => ({
|
|
113
|
+
title: fileName,
|
|
114
|
+
file_path: filePath,
|
|
115
|
+
format: extractor.format,
|
|
116
|
+
}),
|
|
117
|
+
event: () => ({ file_path: filePath, format: extractor.format }),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function textCaptureSource(text: string, title?: string): CaptureSource {
|
|
122
|
+
return {
|
|
123
|
+
needsOriginalDir: false,
|
|
124
|
+
fallbackText: "",
|
|
125
|
+
extract: () => ({ extracted: text }),
|
|
126
|
+
manifest: () => ({
|
|
127
|
+
title: title || `Pasted text — ${fmtDate()}`,
|
|
128
|
+
format: "text",
|
|
129
|
+
}),
|
|
130
|
+
event: () => ({ format: "text" }),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function createSourcePacket(paths: VaultPaths, needsOriginalDir: boolean): SourcePacket {
|
|
203
135
|
const sourceId = nextSourceId(paths);
|
|
204
136
|
const packetPath = join(paths.rawSources, sourceId);
|
|
205
137
|
mkdirSync(packetPath, { recursive: true });
|
|
206
|
-
mkdirSync(join(packetPath, "original"), { recursive: true });
|
|
207
138
|
mkdirSync(join(packetPath, "attachments"), { recursive: true });
|
|
139
|
+
if (needsOriginalDir) mkdirSync(join(packetPath, "original"), { recursive: true });
|
|
140
|
+
return { sourceId, packetPath };
|
|
141
|
+
}
|
|
208
142
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const markitdown = await exec(
|
|
217
|
-
pi,
|
|
218
|
-
"sh",
|
|
219
|
-
["-c", `which uvx >/dev/null 2>&1 && echo "yes" || echo "no"`],
|
|
220
|
-
{ signal },
|
|
221
|
-
);
|
|
222
|
-
|
|
223
|
-
if (markitdown.stdout.trim() === "yes") {
|
|
224
|
-
try {
|
|
225
|
-
const mdResult = await exec(
|
|
226
|
-
pi,
|
|
227
|
-
"sh",
|
|
228
|
-
["-c", `uvx --from 'markitdown[pdf]' markitdown "${filePath}" 2>/dev/null || echo ""`],
|
|
229
|
-
{ signal, timeout: markitdownTimeoutMs() },
|
|
230
|
-
);
|
|
231
|
-
if (mdResult.stdout.trim()) extracted = mdResult.stdout;
|
|
232
|
-
} catch {
|
|
233
|
-
extracted = pdfExtractionFailureMessage(filePath);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
if (!extracted) extracted = pdfExtractionFailureMessage(filePath);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Copy original to packet
|
|
240
|
-
try {
|
|
241
|
-
await exec(pi, "cp", [filePath, join(packetPath, "original", fileName)], { signal });
|
|
242
|
-
} catch {
|
|
243
|
-
// If cp fails, just write the content
|
|
244
|
-
writeFileSync(join(packetPath, "original", fileName), content, "utf-8");
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// Write extracted text
|
|
248
|
-
writeFileSync(join(packetPath, "extracted.md"), extracted, "utf-8");
|
|
249
|
-
|
|
250
|
-
// Write manifest
|
|
143
|
+
function finalizeCapture(
|
|
144
|
+
paths: VaultPaths,
|
|
145
|
+
packet: SourcePacket,
|
|
146
|
+
source: CaptureSource,
|
|
147
|
+
content: ExtractedContent,
|
|
148
|
+
): CaptureResult {
|
|
149
|
+
const extracted = content.extracted || source.fallbackText;
|
|
251
150
|
const manifest = {
|
|
252
|
-
id: sourceId,
|
|
253
|
-
title: fileName,
|
|
254
|
-
file_path: filePath,
|
|
151
|
+
id: packet.sourceId,
|
|
255
152
|
captured: fmtDate(),
|
|
256
|
-
format: guessFormat(filePath),
|
|
257
153
|
packet_version: "1.0",
|
|
154
|
+
...source.manifest({ ...content, extracted }),
|
|
258
155
|
};
|
|
259
|
-
writeJson(join(packetPath, "manifest.json"), manifest);
|
|
260
156
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
157
|
+
writeFileSync(join(packet.packetPath, "extracted.md"), extracted, "utf-8");
|
|
158
|
+
writeJson(join(packet.packetPath, "manifest.json"), manifest);
|
|
159
|
+
|
|
160
|
+
const sourcePagePath = join(paths.wiki, "sources", `${packet.sourceId}.md`);
|
|
161
|
+
writeFileSync(sourcePagePath, buildSourcePageSkeleton(manifest, extracted), "utf-8");
|
|
265
162
|
|
|
266
|
-
// Log event
|
|
267
163
|
appendEvent(paths, {
|
|
268
164
|
kind: "capture",
|
|
269
|
-
source_id: sourceId,
|
|
270
|
-
|
|
271
|
-
format: manifest.format,
|
|
165
|
+
source_id: packet.sourceId,
|
|
166
|
+
...source.event({ ...content, extracted }),
|
|
272
167
|
});
|
|
273
168
|
|
|
274
|
-
return {
|
|
169
|
+
return {
|
|
170
|
+
sourceId: packet.sourceId,
|
|
171
|
+
packetPath: packet.packetPath,
|
|
172
|
+
sourcePagePath,
|
|
173
|
+
extracted,
|
|
174
|
+
};
|
|
275
175
|
}
|
|
276
176
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
177
|
+
async function preserveFileOriginal(
|
|
178
|
+
pi: ExtensionAPI,
|
|
179
|
+
packetPath: string,
|
|
180
|
+
filePath: string,
|
|
181
|
+
fileName: string,
|
|
182
|
+
fallbackContent: string,
|
|
183
|
+
signal?: AbortSignal,
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
try {
|
|
186
|
+
await exec(pi, "cp", [filePath, join(packetPath, "original", fileName)], { signal });
|
|
187
|
+
} catch {
|
|
188
|
+
// If cp fails, preserve whatever text content was available.
|
|
189
|
+
writeFileSync(join(packetPath, "original", fileName), fallbackContent, "utf-8");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
286
192
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
193
|
+
async function preserveUrlOriginal(
|
|
194
|
+
pi: ExtensionAPI,
|
|
195
|
+
packetPath: string,
|
|
196
|
+
url: string,
|
|
197
|
+
signal?: AbortSignal,
|
|
198
|
+
): Promise<void> {
|
|
199
|
+
const originalPath = join(packetPath, "original", originalFileNameForUrl(url));
|
|
200
|
+
try {
|
|
201
|
+
await exec(pi, "curl", ["-sL", "--max-time", "30", "-o", originalPath, url], {
|
|
202
|
+
signal,
|
|
203
|
+
timeout: 35_000,
|
|
204
|
+
});
|
|
205
|
+
} catch {
|
|
206
|
+
// Preserve best-effort extraction behavior even when the original artifact cannot be saved.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
296
209
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
210
|
+
function originalFileNameForUrl(url: string): string {
|
|
211
|
+
try {
|
|
212
|
+
const parsed = new URL(url);
|
|
213
|
+
const ext = extname(parsed.pathname).toLowerCase();
|
|
214
|
+
if (URL_ORIGINAL_EXTENSIONS.has(ext)) return `source${ext}`;
|
|
215
|
+
} catch {
|
|
216
|
+
const path = url.split(/[?#]/, 1)[0] ?? "";
|
|
217
|
+
const ext = extname(path).toLowerCase();
|
|
218
|
+
if (URL_ORIGINAL_EXTENSIONS.has(ext)) return `source${ext}`;
|
|
219
|
+
}
|
|
301
220
|
|
|
302
|
-
|
|
303
|
-
|
|
221
|
+
return "source.html";
|
|
222
|
+
}
|
|
304
223
|
|
|
305
|
-
|
|
224
|
+
function contentExtractionFailureMessage(source: string): string {
|
|
225
|
+
return `_Content could not be extracted from ${source}_\n`;
|
|
306
226
|
}
|
|
307
227
|
|
|
308
228
|
/** Build a skeleton source page from manifest and extracted text. */
|
|
@@ -362,13 +282,3 @@ status: skeleton
|
|
|
362
282
|
- **Manifest:** [raw/sources/${id}/manifest.json](../raw/sources/${id}/manifest.json)
|
|
363
283
|
`;
|
|
364
284
|
}
|
|
365
|
-
|
|
366
|
-
function guessFormat(filePath: string): string {
|
|
367
|
-
const lower = filePath.toLowerCase();
|
|
368
|
-
if (lower.endsWith(".pdf")) return "pdf";
|
|
369
|
-
if (lower.endsWith(".md")) return "markdown";
|
|
370
|
-
if (lower.endsWith(".txt")) return "text";
|
|
371
|
-
if (lower.endsWith(".html") || lower.endsWith(".htm")) return "html";
|
|
372
|
-
if (lower.endsWith(".docx")) return "docx";
|
|
373
|
-
return "file";
|
|
374
|
-
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zosmaai/pi-llm-wiki",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "LLM Wiki for Pi — self-maintaining knowledge base following Karpathy's pattern. Obsidian-friendly, auto-updating, personal & company wiki.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/test/llm-wiki.test.ts
CHANGED
|
@@ -183,13 +183,27 @@ describe("package structure", () => {
|
|
|
183
183
|
|
|
184
184
|
it("should keep MarkItDown timeout configurable and avoid PDF byte fallbacks", () => {
|
|
185
185
|
const sourcePacketPath = join(rootDir, "extensions", "llm-wiki", "lib", "source-packet.ts");
|
|
186
|
+
const sourceExtractorsPath = join(
|
|
187
|
+
rootDir,
|
|
188
|
+
"extensions",
|
|
189
|
+
"llm-wiki",
|
|
190
|
+
"lib",
|
|
191
|
+
"source-extractors.ts",
|
|
192
|
+
);
|
|
186
193
|
expect(existsSync(sourcePacketPath)).toBe(true);
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
expect(
|
|
192
|
-
expect(
|
|
194
|
+
expect(existsSync(sourceExtractorsPath)).toBe(true);
|
|
195
|
+
|
|
196
|
+
const sourcePacket = readFile(sourcePacketPath);
|
|
197
|
+
const sourceExtractors = readFile(sourceExtractorsPath);
|
|
198
|
+
expect(sourcePacket).toContain("captureSource");
|
|
199
|
+
expect(sourcePacket).toContain("fileExtractorFor");
|
|
200
|
+
expect(sourcePacket).toContain("extractUrlContent");
|
|
201
|
+
expect(sourceExtractors).toContain("WIKI_MARKITDOWN_TIMEOUT_MS");
|
|
202
|
+
expect(sourceExtractors).toContain("DEFAULT_MARKITDOWN_TIMEOUT_MS = 180_000");
|
|
203
|
+
expect(sourceExtractors).toContain("URL_EXTRACTORS");
|
|
204
|
+
expect(sourceExtractors).toContain("matches: isPdfUrl");
|
|
205
|
+
expect(sourceExtractors).toContain("looksLikePdf(curlExtracted)");
|
|
206
|
+
expect(sourceExtractors).toContain("pdfExtractionFailureMessage");
|
|
193
207
|
});
|
|
194
208
|
|
|
195
209
|
it("should have a comprehensive README with install instructions", () => {
|
|
@@ -388,6 +402,112 @@ describe("source packet capture", () => {
|
|
|
388
402
|
// Original file should be preserved
|
|
389
403
|
expect(existsSync(join(result.packetPath, "original", "notes.md"))).toBe(true);
|
|
390
404
|
});
|
|
405
|
+
|
|
406
|
+
it("should convert XML files to readable markdown in extracted.md", async () => {
|
|
407
|
+
const paths = makePaths();
|
|
408
|
+
const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
409
|
+
<document>
|
|
410
|
+
<title>Project Report</title>
|
|
411
|
+
<section>
|
|
412
|
+
<heading>Findings</heading>
|
|
413
|
+
<p>The analysis revealed several key insights.</p>
|
|
414
|
+
<list>
|
|
415
|
+
<item>First finding</item>
|
|
416
|
+
<item>Second finding</item>
|
|
417
|
+
</list>
|
|
418
|
+
</section>
|
|
419
|
+
</document>`;
|
|
420
|
+
const xmlPath = join(tempDir, "report.xml");
|
|
421
|
+
writeFileSync(xmlPath, xmlContent, "utf-8");
|
|
422
|
+
|
|
423
|
+
const pi = mockPi();
|
|
424
|
+
const result = await captureFile(pi as never, paths, xmlPath);
|
|
425
|
+
|
|
426
|
+
const extracted = readFile(join(result.packetPath, "extracted.md"));
|
|
427
|
+
// Should have extracted title
|
|
428
|
+
expect(extracted).toContain("Project Report");
|
|
429
|
+
// Should have extracted text content
|
|
430
|
+
expect(extracted).toContain("The analysis revealed several key insights.");
|
|
431
|
+
expect(extracted).toContain("First finding");
|
|
432
|
+
expect(extracted).toContain("Second finding");
|
|
433
|
+
// Should NOT contain raw XML tags
|
|
434
|
+
expect(extracted).not.toContain("<?xml");
|
|
435
|
+
expect(extracted).not.toContain("<document>");
|
|
436
|
+
expect(extracted).not.toContain("</document>");
|
|
437
|
+
|
|
438
|
+
// Original file should be preserved
|
|
439
|
+
expect(existsSync(join(result.packetPath, "original", "report.xml"))).toBe(true);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("should fall back to raw XML content when tag stripping produces nothing", async () => {
|
|
443
|
+
const paths = makePaths();
|
|
444
|
+
const xmlContent = `<?xml version="1.0"?><data><![CDATA[Hello]]></data>`;
|
|
445
|
+
const xmlPath = join(tempDir, "minimal.xml");
|
|
446
|
+
writeFileSync(xmlPath, xmlContent, "utf-8");
|
|
447
|
+
|
|
448
|
+
const pi = mockPi();
|
|
449
|
+
const result = await captureFile(pi as never, paths, xmlPath);
|
|
450
|
+
|
|
451
|
+
const extracted = readFile(join(result.packetPath, "extracted.md"));
|
|
452
|
+
// Should have the text content at minimum
|
|
453
|
+
expect(extracted).toContain("Hello");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("should convert JSON files to readable markdown in extracted.md", async () => {
|
|
457
|
+
const paths = makePaths();
|
|
458
|
+
const jsonContent = JSON.stringify(
|
|
459
|
+
{
|
|
460
|
+
title: "Project Roadmap",
|
|
461
|
+
scope: "Improve the client portal and project record.",
|
|
462
|
+
assumptions: ["Routes already exist", "Use generated API types"],
|
|
463
|
+
tasks: [
|
|
464
|
+
{
|
|
465
|
+
id: "client-portal",
|
|
466
|
+
title: "Client portal hardening",
|
|
467
|
+
acceptance: ["Shows open actions", "Build passes"],
|
|
468
|
+
},
|
|
469
|
+
],
|
|
470
|
+
},
|
|
471
|
+
null,
|
|
472
|
+
2,
|
|
473
|
+
);
|
|
474
|
+
const jsonPath = join(tempDir, "roadmap.json");
|
|
475
|
+
writeFileSync(jsonPath, jsonContent, "utf-8");
|
|
476
|
+
|
|
477
|
+
const pi = mockPi();
|
|
478
|
+
const result = await captureFile(pi as never, paths, jsonPath);
|
|
479
|
+
|
|
480
|
+
const extracted = readFile(join(result.packetPath, "extracted.md"));
|
|
481
|
+
expect(extracted).toContain("# Project Roadmap");
|
|
482
|
+
expect(extracted).toContain("**Scope:** Improve the client portal and project record.");
|
|
483
|
+
expect(extracted).toMatch(/^## Assumptions$/m);
|
|
484
|
+
expect(extracted).not.toMatch(/^### Assumptions$/m);
|
|
485
|
+
expect(extracted).toContain("- Routes already exist");
|
|
486
|
+
expect(extracted).toMatch(/^## Tasks$/m);
|
|
487
|
+
expect(extracted).not.toMatch(/^### Tasks$/m);
|
|
488
|
+
expect(extracted).toMatch(/^### Client portal hardening$/m);
|
|
489
|
+
expect(extracted).toContain("Shows open actions");
|
|
490
|
+
expect(extracted).not.toContain('"tasks"');
|
|
491
|
+
expect(extracted).not.toContain("{");
|
|
492
|
+
|
|
493
|
+
expect(existsSync(join(result.packetPath, "original", "roadmap.json"))).toBe(true);
|
|
494
|
+
|
|
495
|
+
const manifest = JSON.parse(readFile(join(result.packetPath, "manifest.json")));
|
|
496
|
+
expect(manifest.format).toBe("json");
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("should fall back to raw JSON content when parsing fails", async () => {
|
|
500
|
+
const paths = makePaths();
|
|
501
|
+
const jsonContent = `{ "title": "Broken", `;
|
|
502
|
+
const jsonPath = join(tempDir, "broken.json");
|
|
503
|
+
writeFileSync(jsonPath, jsonContent, "utf-8");
|
|
504
|
+
|
|
505
|
+
const pi = mockPi();
|
|
506
|
+
const result = await captureFile(pi as never, paths, jsonPath);
|
|
507
|
+
|
|
508
|
+
const extracted = readFile(join(result.packetPath, "extracted.md"));
|
|
509
|
+
expect(extracted).toBe(jsonContent);
|
|
510
|
+
});
|
|
391
511
|
});
|
|
392
512
|
|
|
393
513
|
describe("wiki directory structure", () => {
|