@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.
- package/.github/codeql/codeql-config.yml +12 -0
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/codeql.yml +1 -1
- package/.github/workflows/release.yml +8 -0
- package/README.md +24 -0
- package/docs/configuration.md +6 -0
- package/extensions/llm-wiki/lib/source-packet.ts +37 -2
- package/package.json +1 -1
- package/test/llm-wiki.test.ts +158 -0
|
@@ -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
|
package/.github/workflows/ci.yml
CHANGED
|
@@ -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
|
+
[](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
|
package/docs/configuration.md
CHANGED
|
@@ -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.
|
|
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",
|
package/test/llm-wiki.test.ts
CHANGED
|
@@ -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
|
|