@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.
- package/.github/workflows/ci.yml +7 -6
- package/.github/workflows/release.yml +3 -3
- package/AGENTS.md +57 -0
- package/CHANGELOG.md +30 -0
- package/CONTRIBUTING.md +43 -0
- package/LICENSE +21 -0
- package/README.md +42 -342
- package/biome.json +3 -0
- package/docs/api.md +105 -0
- package/docs/architecture.md +65 -0
- package/docs/commands.md +51 -0
- package/docs/configuration.md +38 -0
- package/docs/obsidian.md +21 -0
- package/extensions/llm-wiki/index.ts +53 -0
- package/extensions/llm-wiki/lib/guardrails.ts +69 -0
- package/extensions/llm-wiki/lib/metadata.ts +218 -0
- package/extensions/llm-wiki/lib/source-packet.ts +339 -0
- package/extensions/llm-wiki/lib/tools.ts +932 -0
- package/extensions/llm-wiki/lib/utils.ts +222 -0
- package/package.json +7 -2
- package/prompts/wiki-digest.md +5 -1
- package/prompts/wiki-discover.md +5 -1
- package/prompts/wiki-ingest.md +5 -1
- package/prompts/wiki-init.md +5 -1
- package/prompts/wiki-lint.md +5 -1
- package/prompts/wiki-query.md +5 -1
- package/prompts/wiki-run.md +5 -1
- package/prompts/wiki-status.md +1 -1
- package/scripts/release.js +72 -0
- package/skills/llm-wiki/SKILL.md +95 -369
- package/skills/llm-wiki/templates/pages/analysis.md +35 -0
- package/test/llm-wiki.test.ts +51 -9
- package/extensions/llm-wiki-tools.ts +0 -705
|
@@ -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
|
+
}
|