@zosmaai/pi-llm-wiki 0.2.1 → 0.3.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,12 @@
1
+ name: "CodeQL Config"
2
+
3
+ queries:
4
+ - uses: security-and-quality
5
+
6
+ # Suppress false positives for temporary file creation.
7
+ # This is a single-user CLI tool — TOCTOU race conditions on temp directories
8
+ # are not a valid threat model. The alerts only fire because tests pass
9
+ # os.tmpdir() paths into production capture functions.
10
+ query-filters:
11
+ - exclude:
12
+ id: js/insecure-temporary-file
@@ -15,7 +15,7 @@ jobs:
15
15
  runs-on: ubuntu-latest
16
16
  strategy:
17
17
  matrix:
18
- node-version: [20, 22, 23]
18
+ node-version: [20, 22, 23, 24, 25]
19
19
 
20
20
  steps:
21
21
  - uses: actions/checkout@v4
@@ -32,7 +32,7 @@ jobs:
32
32
  with:
33
33
  languages: ${{ matrix.language }}
34
34
  build-mode: ${{ matrix.build-mode }}
35
- queries: security-and-quality
35
+ config-file: ./.github/codeql/codeql-config.yml
36
36
 
37
37
  - uses: github/codeql-action/analyze@v3
38
38
  with:
@@ -43,6 +43,14 @@ jobs:
43
43
  node -e "const p=require('./package.json'); p.version='${{ steps.version.outputs.VERSION }}'; require('fs').writeFileSync('./package.json', JSON.stringify(p, null, 2)+'\n')"
44
44
  npx biome check --fix package.json 2>&1
45
45
 
46
+ - name: Commit version bump back to main
47
+ run: |
48
+ git config user.name "github-actions[bot]"
49
+ git config user.email "github-actions[bot]@users.noreply.github.com"
50
+ git add package.json
51
+ git commit -m "chore(release): v${{ steps.version.outputs.VERSION }}"
52
+ git push origin HEAD:main
53
+
46
54
  - name: Generate Release Notes
47
55
  id: notes
48
56
  run: |
package/README.md CHANGED
@@ -42,6 +42,14 @@ Drop sources into `raw/`, then:
42
42
  | `wiki_log_event` | Record custom event |
43
43
  | `wiki_watch` | Schedule auto-updates |
44
44
 
45
+ ## Features
46
+
47
+ - **Configurable PDF extraction** — MarkItDown timeout adjustable via `WIKI_MARKITDOWN_TIMEOUT_MS` env var
48
+ - **Smart content detection** — PDF bytes sniffed even from non-`.pdf` URLs, never written as markdown
49
+ - **Original artifacts preserved** — URL captures save the fetched payload under `original/source.*`
50
+ - **Clickable source links** — Captured URLs render as clickable Markdown links in source pages
51
+ - **Reliable prompt forwarding** — Slash commands properly forward user arguments to the model
52
+
45
53
  ## Architecture
46
54
 
47
55
  Four layers with clear ownership:
@@ -67,6 +75,22 @@ Read [docs/architecture.md](docs/architecture.md) for details.
67
75
 
68
76
  See [CONTRIBUTING.md](CONTRIBUTING.md).
69
77
 
78
+ ## Star History
79
+
80
+ [![Star History Chart](https://api.star-history.com/svg?repos=zosmaai/pi-llm-wiki&type=Date)](https://star-history.com/#zosmaai/pi-llm-wiki&Date)
81
+
82
+ ## Contributors
83
+
84
+ <a href="https://github.com/zosmaai/pi-llm-wiki/graphs/contributors">
85
+ <img src="https://contrib.rocks/image?repo=zosmaai/pi-llm-wiki" alt="Contributors" />
86
+ </a>
87
+
88
+ ---
89
+
90
+ <div align="center">
91
+ <sub>Built with ❤️ by <a href="https://github.com/zosmaai">zosmaai</a></sub>
92
+ </div>
93
+
70
94
  ## License
71
95
 
72
96
  MIT
@@ -23,6 +23,12 @@ Wiki configuration lives in `.wiki/config.json`.
23
23
  | `auto_fix_lint` | false | Auto-fix lint issues |
24
24
  | `batch_ingest_size` | 3 | Sources processed per ingest batch |
25
25
 
26
+ ## Environment Variables
27
+
28
+ | Variable | Default | Description |
29
+ | ----------------------------- | ------- | ----------------------------------------------- |
30
+ | `WIKI_MARKITDOWN_TIMEOUT_MS` | 180000 | Timeout (ms) for MarkItDown PDF/text extraction |
31
+
26
32
  ## Page Frontmatter
27
33
 
28
34
  ```yaml
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
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
5
  import { type VaultPaths, exec, fmtDate, nextSourceId, readText, writeJson } from "./utils.js";
@@ -52,6 +52,39 @@ function pdfExtractionFailureMessage(source: string): string {
52
52
  return `_PDF content could not be converted to markdown from ${source}. Try increasing WIKI_MARKITDOWN_TIMEOUT_MS._\n`;
53
53
  }
54
54
 
55
+ const URL_ORIGINAL_EXTENSIONS = new Set([".html", ".htm", ".md", ".pdf", ".txt", ".xml", ".json"]);
56
+
57
+ function originalFileNameForUrl(url: string): string {
58
+ try {
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(
72
+ pi: ExtensionAPI,
73
+ packetPath: string,
74
+ url: string,
75
+ signal?: AbortSignal,
76
+ ): Promise<void> {
77
+ const originalPath = join(packetPath, "original", originalFileNameForUrl(url));
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
+ }
86
+ }
87
+
55
88
  /** Capture a URL into a source packet. */
56
89
  export async function captureUrl(
57
90
  pi: ExtensionAPI,
@@ -65,6 +98,8 @@ export async function captureUrl(
65
98
  mkdirSync(join(packetPath, "original"), { recursive: true });
66
99
  mkdirSync(join(packetPath, "attachments"), { recursive: true });
67
100
 
101
+ await preserveUrlOriginal(pi, packetPath, url, signal);
102
+
68
103
  // Try to fetch and extract content
69
104
  let extracted = "";
70
105
  let title = url;
@@ -171,12 +206,20 @@ export async function captureFile(
171
206
  mkdirSync(join(packetPath, "original"), { recursive: true });
172
207
  mkdirSync(join(packetPath, "attachments"), { recursive: true });
173
208
 
174
- const isPdf = filePath.toLowerCase().endsWith(".pdf");
209
+ const lowerPath = filePath.toLowerCase();
210
+ const isPdf = lowerPath.endsWith(".pdf");
211
+ const isXml = lowerPath.endsWith(".xml");
175
212
  const content = isPdf ? "" : readText(filePath);
176
213
  const fileName = filePath.split("/").pop() || "unknown";
177
214
 
178
- // Try MarkItDown for PDFs.
179
215
  let extracted = content;
216
+
217
+ // Convert XML to markdown
218
+ if (isXml && content) {
219
+ extracted = xmlToMarkdown(content);
220
+ }
221
+
222
+ // Try MarkItDown for PDFs.
180
223
  if (isPdf) {
181
224
  const markitdown = await exec(
182
225
  pi,
@@ -274,7 +317,7 @@ export function captureText(paths: VaultPaths, text: string, title?: string): Ca
274
317
  function buildSourcePageSkeleton(manifest: Record<string, unknown>, extracted: string): string {
275
318
  const id = String(manifest.id);
276
319
  const title = String(manifest.title || id);
277
- const url = manifest.url ? `\n> _Original: ${manifest.url}_` : "";
320
+ const url = manifest.url ? `\n> _Original: [${manifest.url}](${manifest.url})_` : "";
278
321
  const format = String(manifest.format || "unknown");
279
322
  const captured = String(manifest.captured || fmtDate());
280
323
 
@@ -328,12 +371,58 @@ status: skeleton
328
371
  `;
329
372
  }
330
373
 
374
+ /** Basic XML to markdown conversion: strip tags while preserving text structure. */
375
+ function xmlToMarkdown(xml: string): string {
376
+ // Extract title from first <title> or root element
377
+ let title = "";
378
+ const titleMatch = xml.match(/<title[^>]*>([^<]*)<\/title>/i);
379
+ if (titleMatch) title = titleMatch[1].trim();
380
+
381
+ // Strip XML declaration and doctype
382
+ let text = xml.replace(/<\?xml[^>]*\?>\s*/gi, "");
383
+ text = text.replace(/<!DOCTYPE[^>]*>\s*/gi, "");
384
+
385
+ // Replace block-level tags with newlines
386
+ text = text.replace(/<\/(p|div|section|article|li|h\d|tr|blockquote|pre)>/gi, "\n");
387
+ text = text.replace(/<br\s*\/?>/gi, "\n");
388
+
389
+ // Strip remaining tags — match < followed by tag name characters to >
390
+ // Using a loop to handle malformed/broken tags that lack a closing >
391
+ let prev = "";
392
+ while (prev !== text) {
393
+ prev = text;
394
+ text = text.replace(/<[a-zA-Z\/!?][^>]*>/g, "");
395
+ }
396
+ // Remove any stray < that didn't form a complete tag
397
+ text = text.replace(/</g, "");
398
+
399
+ // Decode XML entities in a single pass to avoid double-unescaping
400
+ text = text.replace(/&(?:amp|lt|gt|quot|#\d+);/gi, (entity) => {
401
+ const map: Record<string, string> = { "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"' };
402
+ const lower = entity.toLowerCase();
403
+ if (map[lower]) return map[lower];
404
+ if (lower.startsWith("&#")) return String.fromCodePoint(Number.parseInt(entity.slice(2, -1)));
405
+ return entity;
406
+ });
407
+
408
+ // Clean up excessive blank lines
409
+ text = text.replace(/\n{3,}/g, "\n\n").trim();
410
+
411
+ if (!text) return xml; // fallback: return raw if stripping produced nothing
412
+
413
+ const lines = [];
414
+ if (title) lines.push(`# ${title}\n`);
415
+ lines.push(text);
416
+ return lines.join("\n\n");
417
+ }
418
+
331
419
  function guessFormat(filePath: string): string {
332
420
  const lower = filePath.toLowerCase();
333
421
  if (lower.endsWith(".pdf")) return "pdf";
334
422
  if (lower.endsWith(".md")) return "markdown";
335
423
  if (lower.endsWith(".txt")) return "text";
336
424
  if (lower.endsWith(".html") || lower.endsWith(".htm")) return "html";
425
+ if (lower.endsWith(".xml")) return "xml";
337
426
  if (lower.endsWith(".docx")) return "docx";
338
427
  return "file";
339
428
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zosmaai/pi-llm-wiki",
3
- "version": "0.2.1",
3
+ "version": "0.3.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",
@@ -3,6 +3,8 @@ import { tmpdir } from "node:os";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { captureFile, captureText, captureUrl } from "../extensions/llm-wiki/lib/source-packet.js";
7
+ import { ensureVaultStructure, getVaultPaths } from "../extensions/llm-wiki/lib/utils.js";
6
8
 
7
9
  // ─── Helpers ────────────────────────────────────────────
8
10
 
@@ -232,6 +234,212 @@ describe("skill frontmatter validation", () => {
232
234
 
233
235
  // ─── Wiki Directory Structure Tests ─────────────────────
234
236
 
237
+ describe("source packet capture", () => {
238
+ const html = "<html><head><title>Example Page</title></head><body>Hello</body></html>";
239
+ const pdfBytes = "%PDF-1.7\n1 0 obj\n<</Type/Catalog>>\nendobj\n";
240
+
241
+ beforeEach(() => {
242
+ tempDir = join(tmpdir(), `pi-llm-wiki-capture-${Date.now()}`);
243
+ mkdirSync(tempDir, { recursive: true });
244
+ });
245
+
246
+ afterEach(() => {
247
+ rmSync(tempDir, { recursive: true, force: true });
248
+ });
249
+
250
+ /** Build a mock pi.exec that optionally handles curl -o file writes. */
251
+ function mockPi(stdout?: string, writeOriginal = true) {
252
+ return {
253
+ exec: async (command: string, args: string[]) => {
254
+ if (command === "sh") return { stdout: "no\n", stderr: "", code: 0 };
255
+ if (command === "curl" && args.includes("-o")) {
256
+ if (writeOriginal) {
257
+ const outputPath = args[args.indexOf("-o") + 1];
258
+ writeFileSync(outputPath, stdout ?? html, "utf-8");
259
+ }
260
+ return { stdout: "", stderr: "", code: 0 };
261
+ }
262
+ if (command === "curl") return { stdout: stdout ?? html, stderr: "", code: 0 };
263
+ throw new Error(`Unexpected command: ${command}`);
264
+ },
265
+ };
266
+ }
267
+
268
+ function makePaths() {
269
+ const p = getVaultPaths(join(tempDir, `wiki-${Math.random().toString(36).slice(2)}`));
270
+ ensureVaultStructure(p);
271
+ return p;
272
+ }
273
+
274
+ it("should preserve the original artifact for URL captures and render clickable links", async () => {
275
+ const paths = makePaths();
276
+ const pi = mockPi();
277
+
278
+ const url = "https://example.com/article";
279
+ const result = await captureUrl(pi as never, paths, url);
280
+
281
+ // Original artifact preserved
282
+ expect(existsSync(join(result.packetPath, "original", "source.html"))).toBe(true);
283
+ expect(readFile(join(result.packetPath, "original", "source.html"))).toBe(html);
284
+ expect(readFile(join(result.packetPath, "extracted.md"))).toBe(html);
285
+
286
+ // Clickable URL in source page
287
+ const sourcePage = readFile(result.sourcePagePath);
288
+ expect(sourcePage).toContain(`> _Original: [${url}](${url})_`);
289
+ });
290
+
291
+ it("should write PDF extraction failure message for .pdf URLs when MarkItDown is unavailable", async () => {
292
+ const paths = makePaths();
293
+ const pi = mockPi();
294
+
295
+ const url = "https://example.com/report.pdf";
296
+ const result = await captureUrl(pi as never, paths, url);
297
+
298
+ // Should NOT have written raw original for .pdf (no uvx)
299
+ expect(existsSync(join(result.packetPath, "original", "source.pdf"))).toBe(true);
300
+
301
+ // extracted.md should contain a failure message, not raw bytes
302
+ const extracted = readFile(join(result.packetPath, "extracted.md"));
303
+ expect(extracted).not.toContain("%PDF-");
304
+ expect(extracted).toContain("PDF content could not be converted");
305
+ expect(extracted).toContain(url);
306
+ });
307
+
308
+ it("should sniff %PDF- bytes from non-.pdf URLs and write a failure message instead", async () => {
309
+ const paths = makePaths();
310
+ // curl returns PDF bytes from a non-.pdf URL
311
+ const pi = mockPi(pdfBytes);
312
+
313
+ const url = "https://example.com/download?format=pdf";
314
+ const result = await captureUrl(pi as never, paths, url);
315
+
316
+ const extracted = readFile(join(result.packetPath, "extracted.md"));
317
+ expect(extracted).not.toContain("%PDF-");
318
+ expect(extracted).toContain("PDF content could not be converted");
319
+ expect(extracted).toContain(url);
320
+ });
321
+
322
+ it("should name original artifacts based on URL extension", async () => {
323
+ const cases: Array<{ url: string; expected: string }> = [
324
+ { url: "https://example.com/article.html", expected: "source.html" },
325
+ { url: "https://example.com/doc.pdf", expected: "source.pdf" },
326
+ { url: "https://example.com/notes.md", expected: "source.md" },
327
+ { url: "https://example.com/data.xml", expected: "source.xml" },
328
+ { url: "https://example.com/readme.txt", expected: "source.txt" },
329
+ { url: "https://example.com/page", expected: "source.html" },
330
+ { url: "https://example.com/page?format=pdf", expected: "source.html" },
331
+ ];
332
+
333
+ for (const { url, expected } of cases) {
334
+ const paths = makePaths();
335
+ const pi = mockPi();
336
+ const result = await captureUrl(pi as never, paths, url);
337
+ const originalFile = join(result.packetPath, "original", expected);
338
+ expect(existsSync(originalFile)).toBe(true);
339
+ }
340
+ });
341
+
342
+ it("should render source page without an Original: line for text captures", async () => {
343
+ const paths = makePaths();
344
+ const result = captureText(paths, "Some text content", "My Note");
345
+
346
+ const sourcePage = readFile(result.sourcePagePath);
347
+ expect(sourcePage).toContain("# My Note");
348
+ expect(sourcePage).not.toContain("Original:");
349
+ expect(sourcePage).toContain("_Auto-preview: Some text content_");
350
+ });
351
+
352
+ it("should truncate auto-preview to 500 characters", async () => {
353
+ const paths = makePaths();
354
+ const longText = "A".repeat(1000);
355
+ const result = captureText(paths, longText, "Long Note");
356
+
357
+ const sourcePage = readFile(result.sourcePagePath);
358
+ // Preview should be 500 chars + "..."
359
+ expect(sourcePage).toContain(`_Auto-preview: ${"A".repeat(500)}..._`);
360
+ });
361
+
362
+ it("should handle local PDF file capture failure message when MarkItDown is unavailable", async () => {
363
+ const paths = makePaths();
364
+ // Create a temporary PDF file
365
+ const pdfPath = join(tempDir, "test.pdf");
366
+ writeFileSync(pdfPath, pdfBytes, "utf-8");
367
+
368
+ const pi = mockPi();
369
+ const result = await captureFile(pi as never, paths, pdfPath);
370
+
371
+ const extracted = readFile(join(result.packetPath, "extracted.md"));
372
+ expect(extracted).not.toContain("%PDF-");
373
+ expect(extracted).toContain("PDF content could not be converted");
374
+ });
375
+
376
+ it("should copy local non-PDF file content to extracted.md", async () => {
377
+ const paths = makePaths();
378
+ const mdPath = join(tempDir, "notes.md");
379
+ writeFileSync(mdPath, "# My Notes\n\nHello world.", "utf-8");
380
+
381
+ const pi = mockPi();
382
+ const result = await captureFile(pi as never, paths, mdPath);
383
+
384
+ const extracted = readFile(join(result.packetPath, "extracted.md"));
385
+ expect(extracted).toContain("# My Notes");
386
+ expect(extracted).toContain("Hello world.");
387
+
388
+ // Original file should be preserved
389
+ expect(existsSync(join(result.packetPath, "original", "notes.md"))).toBe(true);
390
+ });
391
+
392
+ it("should convert XML files to readable markdown in extracted.md", async () => {
393
+ const paths = makePaths();
394
+ const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
395
+ <document>
396
+ <title>Project Report</title>
397
+ <section>
398
+ <heading>Findings</heading>
399
+ <p>The analysis revealed several key insights.</p>
400
+ <list>
401
+ <item>First finding</item>
402
+ <item>Second finding</item>
403
+ </list>
404
+ </section>
405
+ </document>`;
406
+ const xmlPath = join(tempDir, "report.xml");
407
+ writeFileSync(xmlPath, xmlContent, "utf-8");
408
+
409
+ const pi = mockPi();
410
+ const result = await captureFile(pi as never, paths, xmlPath);
411
+
412
+ const extracted = readFile(join(result.packetPath, "extracted.md"));
413
+ // Should have extracted title
414
+ expect(extracted).toContain("Project Report");
415
+ // Should have extracted text content
416
+ expect(extracted).toContain("The analysis revealed several key insights.");
417
+ expect(extracted).toContain("First finding");
418
+ expect(extracted).toContain("Second finding");
419
+ // Should NOT contain raw XML tags
420
+ expect(extracted).not.toContain("<?xml");
421
+ expect(extracted).not.toContain("<document>");
422
+ expect(extracted).not.toContain("</document>");
423
+
424
+ // Original file should be preserved
425
+ expect(existsSync(join(result.packetPath, "original", "report.xml"))).toBe(true);
426
+ });
427
+
428
+ it("should fall back to raw XML content when tag stripping produces nothing", async () => {
429
+ const paths = makePaths();
430
+ const xmlContent = `<?xml version="1.0"?><data><![CDATA[Hello]]></data>`;
431
+ const xmlPath = join(tempDir, "minimal.xml");
432
+ writeFileSync(xmlPath, xmlContent, "utf-8");
433
+
434
+ const pi = mockPi();
435
+ const result = await captureFile(pi as never, paths, xmlPath);
436
+
437
+ const extracted = readFile(join(result.packetPath, "extracted.md"));
438
+ // Should have the text content at minimum
439
+ expect(extracted).toContain("Hello");
440
+ });
441
+ });
442
+
235
443
  describe("wiki directory structure", () => {
236
444
  let wikiDir: string;
237
445