@zosmaai/pi-llm-wiki 0.8.1 → 0.8.2

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/README.md CHANGED
@@ -399,9 +399,39 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, test patterns, and
399
399
 
400
400
  ## Contributors
401
401
 
402
- <a href="https://github.com/zosmaai/pi-llm-wiki/graphs/contributors">
403
- <img src="https://contrib.rocks/image?repo=zosmaai/pi-llm-wiki" alt="Contributors" />
404
- </a>
402
+ Thanks to everyone who has contributed! This list is regenerated automatically by [`.github/workflows/contributors.yml`](.github/workflows/contributors.yml) — see [#60](https://github.com/zosmaai/pi-llm-wiki/issues/60) for the rationale.
403
+
404
+ <!-- readme: contributors -start -->
405
+ <table>
406
+ <tbody>
407
+ <tr>
408
+ <td align="center">
409
+ <a href="https://github.com/arjun-zosma">
410
+ <img src="https://avatars.githubusercontent.com/u/25246034?v=4" width="64;" alt="arjun-zosma"/>
411
+ <br />
412
+ <sub><b>Arjun Nayak</b></sub>
413
+ </a>
414
+ </td>
415
+ <td align="center">
416
+ <a href="https://github.com/jfraser">
417
+ <img src="https://avatars.githubusercontent.com/u/165964?v=4" width="64;" alt="jfraser"/>
418
+ <br />
419
+ <sub><b>James Fraser</b></sub>
420
+ </a>
421
+ </td>
422
+ <td align="center">
423
+ <a href="https://github.com/Shanvit7">
424
+ <img src="https://avatars.githubusercontent.com/u/64424817?v=4" width="64;" alt="Shanvit7"/>
425
+ <br />
426
+ <sub><b>Shanvit S Shetty</b></sub>
427
+ </a>
428
+ </td>
429
+ </tr>
430
+ <tbody>
431
+ </table>
432
+ <!-- readme: contributors -end -->
433
+
434
+ <sub>Full history: [contributors graph](https://github.com/zosmaai/pi-llm-wiki/graphs/contributors).</sub>
405
435
 
406
436
  ---
407
437
 
@@ -1,4 +1,6 @@
1
+ import { open } from "node:fs/promises";
1
2
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import { NodeHtmlMarkdown } from "node-html-markdown";
2
4
  import { exec } from "./utils.js";
3
5
 
4
6
  export type ExtractionStatus = "success" | "failed" | "unsupported";
@@ -38,6 +40,67 @@ interface UrlExtractArgs {
38
40
  signal?: AbortSignal;
39
41
  }
40
42
 
43
+ // ---------------------------------------------------------------------------
44
+ // Binary magic byte detection
45
+ // ---------------------------------------------------------------------------
46
+
47
+ const BINARY_SIGNATURES: Array<{ bytes: number[]; format: string }> = [
48
+ // Archives & documents
49
+ { bytes: [0x50, 0x4b, 0x03, 0x04], format: "zip" }, // ZIP / DOCX / XLSX / PPTX / JAR
50
+ { bytes: [0x25, 0x50, 0x44, 0x46], format: "pdf" }, // %PDF
51
+ { bytes: [0x37, 0x7a, 0xbc, 0xaf], format: "7z" }, // 7-Zip
52
+ { bytes: [0x1f, 0x8b], format: "gzip" }, // gzip / .tar.gz
53
+ // Images
54
+ { bytes: [0x89, 0x50, 0x4e, 0x47], format: "png" }, // PNG
55
+ { bytes: [0xff, 0xd8, 0xff], format: "jpeg" }, // JPEG
56
+ { bytes: [0x47, 0x49, 0x46, 0x38], format: "gif" }, // GIF8
57
+ { bytes: [0x42, 0x4d], format: "bmp" }, // BMP
58
+ { bytes: [0x49, 0x49, 0x2a, 0x00], format: "tiff" }, // TIFF (little-endian)
59
+ { bytes: [0x4d, 0x4d, 0x00, 0x2a], format: "tiff" }, // TIFF (big-endian)
60
+ { bytes: [0x52, 0x49, 0x46, 0x46], format: "riff" }, // RIFF (WAV / AVI / WebP)
61
+ // Executables & binaries
62
+ { bytes: [0x4d, 0x5a], format: "exe" }, // Windows PE (EXE / DLL)
63
+ { bytes: [0xcf, 0xfa, 0xed, 0xfe], format: "macho" }, // Mach-O 64-bit LE
64
+ { bytes: [0xce, 0xfa, 0xed, 0xfe], format: "macho" }, // Mach-O 32-bit LE
65
+ { bytes: [0xfe, 0xed, 0xfa, 0xcf], format: "macho" }, // Mach-O 64-bit BE
66
+ { bytes: [0xfe, 0xed, 0xfa, 0xce], format: "macho" }, // Mach-O 32-bit BE
67
+ { bytes: [0xca, 0xfe, 0xba, 0xbe], format: "class" }, // Java .class / Mach-O FAT
68
+ { bytes: [0x7f, 0x45, 0x4c, 0x46], format: "elf" }, // ELF binary
69
+ { bytes: [0x00, 0x61, 0x73, 0x6d], format: "wasm" }, // WebAssembly
70
+ // Data & media
71
+ { bytes: [0x53, 0x51, 0x4c, 0x69], format: "sqlite" }, // SQLite
72
+ { bytes: [0x49, 0x44, 0x33], format: "mp3" }, // MP3 (ID3 tag)
73
+ ];
74
+
75
+ /**
76
+ * Reads the first 8 bytes of `filePath` and checks them against known binary
77
+ * magic byte signatures. Returns the detected format name or `null` for text.
78
+ */
79
+ export async function detectBinaryMagicBytes(filePath: string): Promise<string | null> {
80
+ let handle: import("node:fs/promises").FileHandle | undefined;
81
+ try {
82
+ handle = await open(filePath, "r");
83
+ const buf = Buffer.alloc(8);
84
+ const { bytesRead } = await handle.read(buf, 0, 8, 0);
85
+ const header = buf.subarray(0, bytesRead);
86
+
87
+ for (const { bytes, format } of BINARY_SIGNATURES) {
88
+ if (bytes.every((b, i) => header[i] === b)) return format;
89
+ }
90
+ return null;
91
+ } catch {
92
+ return null; // Unreadable file — let the extractor deal with it
93
+ } finally {
94
+ await handle?.close();
95
+ }
96
+ }
97
+
98
+ export function binaryExtractionFailureMessage(format: string): string {
99
+ return `_Binary file could not be converted to markdown (detected format: ${format}).\nCapture a text-based version or a URL pointing to readable content instead._\n`;
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+
41
104
  const DEFAULT_MARKITDOWN_TIMEOUT_MS = 180_000;
42
105
  const DEFAULT_CURL_TIMEOUT_SECONDS = 30;
43
106
 
@@ -193,10 +256,11 @@ async function extractTextUrl(
193
256
  content_type: "application/pdf",
194
257
  };
195
258
  }
259
+ const normalized = htmlToMarkdown(curlExtracted);
196
260
  return {
197
- extracted: curlExtracted,
198
- title: titleFromHtml(curlExtracted),
199
- extractor: "curl",
261
+ extracted: normalized,
262
+ title: titleFromMarkdown(normalized) ?? titleFromHtml(curlExtracted),
263
+ extractor: "htmlToMarkdown",
200
264
  extraction_status: "success",
201
265
  };
202
266
  }
@@ -279,6 +343,23 @@ function titleFromHtml(html: string): string | undefined {
279
343
  return html.match(/<title>([^<]*)<\/title>/i)?.[1]?.trim();
280
344
  }
281
345
 
346
+ /** Decode common HTML/XML entities. Shared by xmlToMarkdown and htmlToMarkdown. */
347
+ function decodeHtmlEntities(text: string): string {
348
+ return text.replace(/&(?:amp|lt|gt|quot|apos|#\d+);/gi, (entity) => {
349
+ const map: Record<string, string> = {
350
+ "&amp;": "&",
351
+ "&lt;": "<",
352
+ "&gt;": ">",
353
+ "&quot;": '"',
354
+ "&apos;": "'",
355
+ };
356
+ const lower = entity.toLowerCase();
357
+ if (map[lower]) return map[lower];
358
+ if (lower.startsWith("&#")) return String.fromCodePoint(Number.parseInt(entity.slice(2, -1)));
359
+ return entity;
360
+ });
361
+ }
362
+
282
363
  /** Basic XML to markdown conversion: strip tags while preserving text structure. */
283
364
  function xmlToMarkdown(xml: string): string {
284
365
  let title = "";
@@ -297,14 +378,7 @@ function xmlToMarkdown(xml: string): string {
297
378
  }
298
379
  text = text.replace(/</g, "");
299
380
 
300
- text = text.replace(/&(?:amp|lt|gt|quot|#\d+);/gi, (entity) => {
301
- const map: Record<string, string> = { "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"' };
302
- const lower = entity.toLowerCase();
303
- if (map[lower]) return map[lower];
304
- if (lower.startsWith("&#")) return String.fromCodePoint(Number.parseInt(entity.slice(2, -1)));
305
- return entity;
306
- });
307
-
381
+ text = decodeHtmlEntities(text);
308
382
  text = text.replace(/\n{3,}/g, "\n\n").trim();
309
383
  if (!text) return xml;
310
384
 
@@ -314,6 +388,41 @@ function xmlToMarkdown(xml: string): string {
314
388
  return lines.join("\n\n");
315
389
  }
316
390
 
391
+ /**
392
+ * Lightweight HTML-to-markdown normalizer for the curl fallback path.
393
+ *
394
+ * Pre-strips page chrome (nav, header, footer, script, style) that
395
+ * node-html-markdown does not remove, then delegates full conversion —
396
+ * bold, italic, code blocks, tables, ordered lists, image alt text — to
397
+ * node-html-markdown. Prepends the <title> as a # heading when the body
398
+ * has no <h1> of its own.
399
+ *
400
+ * Falls back to the original HTML if conversion yields an empty string.
401
+ */
402
+ export function htmlToMarkdown(input: string): string {
403
+ // 1. Extract <title> from original before stripping head
404
+ const title = input.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() ?? "";
405
+
406
+ // 2. Strip <head> and noise blocks that node-html-markdown won't remove
407
+ let html = input.replace(/<head[\s\S]*?<\/head>/gi, "");
408
+ let previousHtml = "";
409
+ while (previousHtml !== html) {
410
+ previousHtml = html;
411
+ html = html.replace(/<(script|style|nav|header|footer|noscript)[\s\S]*?<\/\1>/gi, "");
412
+ }
413
+
414
+ // 3. Delegate to node-html-markdown for full semantic conversion
415
+ const converted = NodeHtmlMarkdown.translate(html).trim();
416
+ if (!converted) return input;
417
+
418
+ // 4. Prepend <title> as # heading only if body has no <h1> of its own
419
+ const hasBodyH1 = /<h1[^>]*>[\s\S]*?<\/h1>/i.test(html);
420
+ const lines: string[] = [];
421
+ if (title && !hasBodyH1) lines.push(`# ${title}\n`);
422
+ lines.push(converted);
423
+ return lines.join("\n");
424
+ }
425
+
317
426
  function jsonToMarkdown(json: string): string {
318
427
  let value: unknown;
319
428
  try {
@@ -2,7 +2,13 @@ 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
+ import {
6
+ type ExtractedContent,
7
+ binaryExtractionFailureMessage,
8
+ detectBinaryMagicBytes,
9
+ extractUrlContent,
10
+ fileExtractorFor,
11
+ } from "./source-extractors.js";
6
12
  import { type VaultPaths, exec, fmtDate, nextSourceId, readText, writeJson } from "./utils.js";
7
13
 
8
14
  /**
@@ -107,6 +113,18 @@ function fileCaptureSource(
107
113
  preserveOriginal: (packetPath) =>
108
114
  preserveFileOriginal(pi, packetPath, filePath, fileName, content, signal),
109
115
  extract: async () => {
116
+ // Guard: if we hit the generic catch-all extractor, check for binary magic bytes first
117
+ if (extractor.format === "file") {
118
+ const binaryFormat = await detectBinaryMagicBytes(filePath);
119
+ if (binaryFormat) {
120
+ return {
121
+ extracted: binaryExtractionFailureMessage(binaryFormat),
122
+ extractor: "magicBytes",
123
+ extraction_status: "unsupported" as const,
124
+ };
125
+ }
126
+ }
127
+
110
128
  const extractedStr = await extractor.extract({ pi, filePath, content, signal });
111
129
  const failed = extractedStr.includes("could not be converted");
112
130
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zosmaai/pi-llm-wiki",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Self-maintaining LLM Wiki for Pi — Karpathy-pattern knowledge base with immutable source capture, automated ingestion, search, linting, and Obsidian-compatible vault. auto-updating personal & company wiki.",
5
5
  "keywords": [
6
6
  "pi",
@@ -76,7 +76,8 @@
76
76
  "node": ">=18"
77
77
  },
78
78
  "dependencies": {
79
- "@modelcontextprotocol/server": "^2.0.0-alpha.2"
79
+ "@modelcontextprotocol/server": "^2.0.0-alpha.2",
80
+ "node-html-markdown": "^2.0.0"
80
81
  },
81
82
  "devDependencies": {
82
83
  "@biomejs/biome": "^1.9.4",