@zosmaai/pi-llm-wiki 0.1.6 → 0.2.1

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,339 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+ import { appendEvent } from "./metadata.js";
5
+ import { type VaultPaths, exec, fmtDate, nextSourceId, readText, writeJson } from "./utils.js";
6
+
7
+ /**
8
+ * Source packet capture and management.
9
+ *
10
+ * Each source is stored as a structured packet:
11
+ * raw/sources/SRC-YYYY-MM-DD-NNN/
12
+ * manifest.json — capture metadata
13
+ * original/ — original artifact (if file/URL)
14
+ * extracted.md — normalized markdown text
15
+ * attachments/ — downloaded images, PDFs, etc.
16
+ */
17
+
18
+ export interface CaptureResult {
19
+ sourceId: string;
20
+ packetPath: string;
21
+ sourcePagePath: string;
22
+ extracted: string;
23
+ }
24
+
25
+ const DEFAULT_MARKITDOWN_TIMEOUT_MS = 180_000;
26
+ const DEFAULT_CURL_TIMEOUT_SECONDS = 30;
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-");
49
+ }
50
+
51
+ function pdfExtractionFailureMessage(source: string): string {
52
+ return `_PDF content could not be converted to markdown from ${source}. Try increasing WIKI_MARKITDOWN_TIMEOUT_MS._\n`;
53
+ }
54
+
55
+ /** Capture a URL into a source packet. */
56
+ export async function captureUrl(
57
+ pi: ExtensionAPI,
58
+ paths: VaultPaths,
59
+ url: string,
60
+ signal?: AbortSignal,
61
+ ): Promise<CaptureResult> {
62
+ const sourceId = nextSourceId(paths);
63
+ const packetPath = join(paths.rawSources, sourceId);
64
+ mkdirSync(packetPath, { recursive: true });
65
+ mkdirSync(join(packetPath, "original"), { recursive: true });
66
+ mkdirSync(join(packetPath, "attachments"), { recursive: true });
67
+
68
+ // Try to fetch and extract content
69
+ let extracted = "";
70
+ let title = url;
71
+ const isPdf = isPdfUrl(url);
72
+
73
+ // Try MarkItDown first.
74
+ const markitdown = await exec(
75
+ pi,
76
+ "sh",
77
+ ["-c", `which uvx >/dev/null 2>&1 && echo "yes" || echo "no"`],
78
+ { signal },
79
+ );
80
+
81
+ if (markitdown.stdout.trim() === "yes") {
82
+ try {
83
+ const mdResult = await exec(
84
+ pi,
85
+ "sh",
86
+ ["-c", `uvx --from 'markitdown[pdf]' markitdown "${url}" 2>/dev/null || echo ""`],
87
+ { signal, timeout: markitdownTimeoutMs() },
88
+ );
89
+ if (mdResult.stdout.trim()) {
90
+ extracted = mdResult.stdout;
91
+ // Try to extract title from first h1
92
+ const h1Match = extracted.match(/^#\s+(.+)$/m);
93
+ if (h1Match) title = h1Match[1].trim();
94
+ }
95
+ } catch {
96
+ // markitdown failed, fall through
97
+ }
98
+ }
99
+
100
+ // Fallback: try fetch_content equivalent via curl for text/html sources.
101
+ // Do not write binary PDF bytes into extracted.md when PDF conversion fails.
102
+ if (!extracted) {
103
+ if (isPdf) {
104
+ extracted = pdfExtractionFailureMessage(url);
105
+ } else {
106
+ try {
107
+ const curlResult = await exec(
108
+ pi,
109
+ "curl",
110
+ ["-sL", "--max-time", String(DEFAULT_CURL_TIMEOUT_SECONDS), url],
111
+ {
112
+ signal,
113
+ timeout: (DEFAULT_CURL_TIMEOUT_SECONDS + 5) * 1_000,
114
+ },
115
+ );
116
+ if (curlResult.stdout) {
117
+ if (looksLikePdf(curlResult.stdout)) {
118
+ extracted = pdfExtractionFailureMessage(url);
119
+ } else {
120
+ extracted = curlResult.stdout;
121
+ // Try to extract title from HTML
122
+ const titleMatch = extracted.match(/<title>([^<]*)<\/title>/i);
123
+ if (titleMatch) title = titleMatch[1].trim();
124
+ }
125
+ }
126
+ } catch {
127
+ // curl failed too
128
+ }
129
+ }
130
+ }
131
+
132
+ // Write extracted text
133
+ writeFileSync(
134
+ join(packetPath, "extracted.md"),
135
+ extracted || `_Content could not be extracted from ${url}_\n`,
136
+ "utf-8",
137
+ );
138
+
139
+ // Write manifest
140
+ const manifest = {
141
+ id: sourceId,
142
+ title,
143
+ url,
144
+ captured: fmtDate(),
145
+ format: "web",
146
+ packet_version: "1.0",
147
+ };
148
+ writeJson(join(packetPath, "manifest.json"), manifest);
149
+
150
+ // Create skeleton source page in wiki
151
+ const sourcePagePath = join(paths.wiki, "sources", `${sourceId}.md`);
152
+ const sourcePageContent = buildSourcePageSkeleton(manifest, extracted);
153
+ writeFileSync(sourcePagePath, sourcePageContent, "utf-8");
154
+
155
+ // Log event
156
+ appendEvent(paths, { kind: "capture", source_id: sourceId, url, format: "web" });
157
+
158
+ return { sourceId, packetPath, sourcePagePath, extracted };
159
+ }
160
+
161
+ /** Capture a local file into a source packet. */
162
+ export async function captureFile(
163
+ pi: ExtensionAPI,
164
+ paths: VaultPaths,
165
+ filePath: string,
166
+ signal?: AbortSignal,
167
+ ): Promise<CaptureResult> {
168
+ const sourceId = nextSourceId(paths);
169
+ const packetPath = join(paths.rawSources, sourceId);
170
+ mkdirSync(packetPath, { recursive: true });
171
+ mkdirSync(join(packetPath, "original"), { recursive: true });
172
+ mkdirSync(join(packetPath, "attachments"), { recursive: true });
173
+
174
+ const isPdf = filePath.toLowerCase().endsWith(".pdf");
175
+ const content = isPdf ? "" : readText(filePath);
176
+ const fileName = filePath.split("/").pop() || "unknown";
177
+
178
+ // Try MarkItDown for PDFs.
179
+ let extracted = content;
180
+ if (isPdf) {
181
+ const markitdown = await exec(
182
+ pi,
183
+ "sh",
184
+ ["-c", `which uvx >/dev/null 2>&1 && echo "yes" || echo "no"`],
185
+ { signal },
186
+ );
187
+
188
+ if (markitdown.stdout.trim() === "yes") {
189
+ try {
190
+ const mdResult = await exec(
191
+ pi,
192
+ "sh",
193
+ ["-c", `uvx --from 'markitdown[pdf]' markitdown "${filePath}" 2>/dev/null || echo ""`],
194
+ { signal, timeout: markitdownTimeoutMs() },
195
+ );
196
+ if (mdResult.stdout.trim()) extracted = mdResult.stdout;
197
+ } catch {
198
+ extracted = pdfExtractionFailureMessage(filePath);
199
+ }
200
+ }
201
+ if (!extracted) extracted = pdfExtractionFailureMessage(filePath);
202
+ }
203
+
204
+ // Copy original to packet
205
+ try {
206
+ await exec(pi, "cp", [filePath, join(packetPath, "original", fileName)], { signal });
207
+ } catch {
208
+ // If cp fails, just write the content
209
+ writeFileSync(join(packetPath, "original", fileName), content, "utf-8");
210
+ }
211
+
212
+ // Write extracted text
213
+ writeFileSync(join(packetPath, "extracted.md"), extracted, "utf-8");
214
+
215
+ // Write manifest
216
+ const manifest = {
217
+ id: sourceId,
218
+ title: fileName,
219
+ file_path: filePath,
220
+ captured: fmtDate(),
221
+ format: guessFormat(filePath),
222
+ packet_version: "1.0",
223
+ };
224
+ writeJson(join(packetPath, "manifest.json"), manifest);
225
+
226
+ // Create skeleton source page
227
+ const sourcePagePath = join(paths.wiki, "sources", `${sourceId}.md`);
228
+ const sourcePageContent = buildSourcePageSkeleton(manifest, extracted);
229
+ writeFileSync(sourcePagePath, sourcePageContent, "utf-8");
230
+
231
+ // Log event
232
+ appendEvent(paths, {
233
+ kind: "capture",
234
+ source_id: sourceId,
235
+ file_path: filePath,
236
+ format: manifest.format,
237
+ });
238
+
239
+ return { sourceId, packetPath, sourcePagePath, extracted };
240
+ }
241
+
242
+ /** Capture pasted text into a source packet. */
243
+ export function captureText(paths: VaultPaths, text: string, title?: string): CaptureResult {
244
+ const sourceId = nextSourceId(paths);
245
+ const packetPath = join(paths.rawSources, sourceId);
246
+ mkdirSync(packetPath, { recursive: true });
247
+ mkdirSync(join(packetPath, "attachments"), { recursive: true });
248
+
249
+ // Write extracted text
250
+ writeFileSync(join(packetPath, "extracted.md"), text, "utf-8");
251
+
252
+ // Write manifest
253
+ const manifest = {
254
+ id: sourceId,
255
+ title: title || `Pasted text — ${fmtDate()}`,
256
+ captured: fmtDate(),
257
+ format: "text",
258
+ packet_version: "1.0",
259
+ };
260
+ writeJson(join(packetPath, "manifest.json"), manifest);
261
+
262
+ // Create skeleton source page
263
+ const sourcePagePath = join(paths.wiki, "sources", `${sourceId}.md`);
264
+ const sourcePageContent = buildSourcePageSkeleton(manifest, text);
265
+ writeFileSync(sourcePagePath, sourcePageContent, "utf-8");
266
+
267
+ // Log event
268
+ appendEvent(paths, { kind: "capture", source_id: sourceId, format: "text" });
269
+
270
+ return { sourceId, packetPath, sourcePagePath, extracted: text };
271
+ }
272
+
273
+ /** Build a skeleton source page from manifest and extracted text. */
274
+ function buildSourcePageSkeleton(manifest: Record<string, unknown>, extracted: string): string {
275
+ const id = String(manifest.id);
276
+ const title = String(manifest.title || id);
277
+ const url = manifest.url ? `\n> _Original: ${manifest.url}_` : "";
278
+ const format = String(manifest.format || "unknown");
279
+ const captured = String(manifest.captured || fmtDate());
280
+
281
+ // Generate a brief auto-summary (first 500 chars)
282
+ const preview = extracted
283
+ .replace(/[#*_`]/g, "")
284
+ .replace(/\s+/g, " ")
285
+ .trim()
286
+ .slice(0, 500);
287
+
288
+ return `---
289
+ type: source
290
+ format: ${format}
291
+ source_id: ${id}
292
+ raw_path: raw/sources/${id}/extracted.md
293
+ captured: ${captured}
294
+ status: skeleton
295
+ ---
296
+
297
+ # ${title}${url}
298
+
299
+ ## Summary
300
+
301
+ [LLM: Replace with 2-3 paragraph summary of key content]
302
+
303
+ > _Auto-preview: ${preview}${extracted.length > 500 ? "..." : ""}_
304
+
305
+ ## Key Takeaways
306
+
307
+ - [LLM: Most important point]
308
+ - [LLM: Second important point]
309
+ - [LLM: Third important point]
310
+
311
+ ## Entities Mentioned
312
+
313
+ - [[entity-name]]
314
+
315
+ ## Concepts Mentioned
316
+
317
+ - [[concept-name]]
318
+
319
+ ## Notable Quotes
320
+
321
+ > [LLM: Important quote] — attribution
322
+
323
+ ## Source Packet
324
+
325
+ - **ID:** \`[[sources/${id}]]\`
326
+ - **Extracted:** [raw/sources/${id}/extracted.md](../raw/sources/${id}/extracted.md)
327
+ - **Manifest:** [raw/sources/${id}/manifest.json](../raw/sources/${id}/manifest.json)
328
+ `;
329
+ }
330
+
331
+ function guessFormat(filePath: string): string {
332
+ const lower = filePath.toLowerCase();
333
+ if (lower.endsWith(".pdf")) return "pdf";
334
+ if (lower.endsWith(".md")) return "markdown";
335
+ if (lower.endsWith(".txt")) return "text";
336
+ if (lower.endsWith(".html") || lower.endsWith(".htm")) return "html";
337
+ if (lower.endsWith(".docx")) return "docx";
338
+ return "file";
339
+ }