@zosmaai/pi-llm-wiki 0.2.1 → 0.2.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.
@@ -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;
@@ -274,7 +309,7 @@ export function captureText(paths: VaultPaths, text: string, title?: string): Ca
274
309
  function buildSourcePageSkeleton(manifest: Record<string, unknown>, extracted: string): string {
275
310
  const id = String(manifest.id);
276
311
  const title = String(manifest.title || id);
277
- const url = manifest.url ? `\n> _Original: ${manifest.url}_` : "";
312
+ const url = manifest.url ? `\n> _Original: [${manifest.url}](${manifest.url})_` : "";
278
313
  const format = String(manifest.format || "unknown");
279
314
  const captured = String(manifest.captured || fmtDate());
280
315
 
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.2.2",
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,162 @@ 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
+
235
393
  describe("wiki directory structure", () => {
236
394
  let wikiDir: string;
237
395