@zosmaai/pi-llm-wiki 0.8.0 → 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
@@ -120,6 +120,7 @@ The result is a wiki that **compounds** as you capture sources, ask questions, a
120
120
  | `/wiki-status` | Show a concise operational summary |
121
121
  | `/wiki-digest [--period daily\|weekly]` | Generate a digest of recent activity |
122
122
  | `/wiki-retro` | Save atomic insights from completed tasks |
123
+ | `/wiki-req <concept>` | Decompose a concept into atomic, traceable requirement pages |
123
124
 
124
125
  ---
125
126
 
@@ -398,9 +399,39 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, test patterns, and
398
399
 
399
400
  ## Contributors
400
401
 
401
- <a href="https://github.com/zosmaai/pi-llm-wiki/graphs/contributors">
402
- <img src="https://contrib.rocks/image?repo=zosmaai/pi-llm-wiki" alt="Contributors" />
403
- </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>
404
435
 
405
436
  ---
406
437
 
package/docs/api.md CHANGED
@@ -1,105 +1,353 @@
1
1
  # API Reference
2
2
 
3
- ## Extension Tools
3
+ All 13 tools registered by the extension. Parameters marked `?` are optional.
4
4
 
5
- ### wiki_bootstrap
5
+ ---
6
6
 
7
- Initialize a new LLM Wiki vault.
7
+ ## wiki_bootstrap
8
+
9
+ Initialize a new LLM Wiki vault with the 4-layer architecture. Creates config, templates, schema,
10
+ and metadata scaffolding.
11
+
12
+ **Parameters**
13
+
14
+ | Name | Type | Required | Description |
15
+ |------|------|----------|-------------|
16
+ | `topic` | `string` | ✅ | Main topic of the wiki |
17
+ | `mode` | `string` | — | `"personal"` or `"company"` (default: `"personal"`) |
18
+ | `root` | `string` | — | Root directory to bootstrap in (default: current working directory) |
19
+
20
+ **Returns**
8
21
 
9
22
  ```
10
- wiki_bootstrap(topic: string, mode?: "personal" | "company", root?: string)
23
+ details: { root: string, mode: string, topic: string }
11
24
  ```
12
25
 
13
- ### wiki_capture_source
26
+ Confirmation text includes the vault path, directory layout, and a prompt to capture the first source.
27
+
28
+ ---
29
+
30
+ ## wiki_capture_source
14
31
 
15
- Capture a URL, file, or text into an immutable source packet.
32
+ Capture a URL, local file, or pasted text into an immutable source packet and skeleton source page.
33
+ Provide exactly one of `url`, `file_path`, or `text`.
34
+
35
+ **Parameters**
36
+
37
+ | Name | Type | Required | Description |
38
+ |------|------|----------|-------------|
39
+ | `url` | `string` | — | URL to fetch and capture |
40
+ | `file_path` | `string` | — | Absolute or relative path to a local file (PDF, md, txt, html, XML, JSON) |
41
+ | `text` | `string` | — | Pasted text content to capture directly |
42
+ | `title` | `string` | — | Title override (used for `text` captures; inferred from URL/file otherwise) |
43
+
44
+ **Returns**
16
45
 
17
46
  ```
18
- wiki_capture_source(
19
- inputType: "url" | "file" | "text",
20
- value: string,
21
- title?: string,
22
- kind?: string,
23
- tags?: string[],
24
- createSourcePage?: boolean
25
- )
47
+ details: {
48
+ sourceId: string, // e.g. "SRC-2026-06-03-001"
49
+ packetPath: string, // path to raw/sources/SRC-.../
50
+ sourcePagePath: string, // path to wiki/sources/SRC-....md (skeleton)
51
+ extractedPreview: string // first 300 chars of extracted content
52
+ }
26
53
  ```
27
54
 
28
- ### wiki_ingest
55
+ Errors with `isError: true` if no vault exists or no source input is provided.
56
+
57
+ ---
58
+
59
+ ## wiki_ingest
60
+
61
+ Return a batch of uningested source packets for the LLM to synthesize. Does not write anything
62
+ itself — the model reads the returned extracted content, fills in the skeleton source page,
63
+ and creates entity/concept pages.
64
+
65
+ **Parameters**
66
+
67
+ | Name | Type | Required | Description |
68
+ |------|------|----------|-------------|
69
+ | `source_id` | `string` | — | Process a specific source ID only; leave empty to get the next unprocessed batch |
70
+ | `batch_size` | `number` | — | Max sources to return (default: `3`, max: `5`) |
71
+
72
+ **Returns**
73
+
74
+ ```
75
+ details: {
76
+ batch: string[], // source IDs in this batch, e.g. ["SRC-2026-06-03-001"]
77
+ remaining: number // sources still waiting after this batch
78
+ }
79
+ ```
80
+
81
+ Each batch entry includes the source title, char count, and the path to read (`raw/sources/{id}/extracted.md`).
82
+ Returns a "all sources ingested" message with `{ ingested, total }` when nothing is pending.
83
+
84
+ ---
85
+
86
+ ## wiki_ensure_page
87
+
88
+ Resolve or safely create a canonical wiki page. Returns immediately if the page already exists
89
+ (no overwrite). Uses a built-in template when `content` is not provided.
90
+
91
+ **Parameters**
92
+
93
+ | Name | Type | Required | Description |
94
+ |------|------|----------|-------------|
95
+ | `type` | `string` | ✅ | Page type: `"entity"`, `"concept"`, `"synthesis"`, `"analysis"`, or `"requirement"` |
96
+ | `title` | `string` | ✅ | Human-readable page title; auto-slugified to a kebab-case filename |
97
+ | `content` | `string` | — | Full markdown content for the page; if omitted, the type-appropriate template is used |
98
+
99
+ **Returns**
100
+
101
+ ```
102
+ details: { path: string, created: boolean }
103
+ ```
104
+
105
+ `created: false` means the page already existed and was not modified.
106
+
107
+ ---
108
+
109
+ ## wiki_recall
110
+
111
+ Search both the personal (`~/.llm-wiki/`) and project (`.llm-wiki/`) vaults for pages relevant to
112
+ a query. Uses chunk-level scoring, weighted field matching, and pseudo-relevance feedback. Also
113
+ called automatically before every agent turn.
114
+
115
+ **Parameters**
116
+
117
+ | Name | Type | Required | Description |
118
+ |------|------|----------|-------------|
119
+ | `query` | `string` | ✅ | Search query — use the user's full request or key terms |
120
+ | `max_results` | `number` | — | Maximum pages to return (default: `5`, max: `10`) |
121
+
122
+ **Returns**
123
+
124
+ ```
125
+ details: {
126
+ query: string,
127
+ matches: Array<{
128
+ id: string, // folder-qualified page ID, e.g. "concepts/rag"
129
+ title: string,
130
+ type: string, // "source" | "entity" | "concept" | "synthesis" | "analysis"
131
+ preview: string, // best-matching chunk or page intro (~200 chars)
132
+ path: string, // absolute filesystem path to the .md file
133
+ score: number, // relevance score (higher = better)
134
+ vaultLabel?: string // "📓 personal" when result is from the personal vault
135
+ }>
136
+ }
137
+ ```
138
+
139
+ Returns empty `matches: []` with a hint to use `wiki_retro` when the wiki has no matching pages.
140
+
141
+ ---
142
+
143
+ ## wiki_search
29
144
 
30
- Get a batch of sources needing synthesis.
145
+ Exact keyword search across the generated registry. Faster and simpler than `wiki_recall` — no
146
+ scoring, no PRF, no vault layering. Use for lookups when you already know what you're looking for.
147
+
148
+ **Parameters**
149
+
150
+ | Name | Type | Required | Description |
151
+ |------|------|----------|-------------|
152
+ | `query` | `string` | ✅ | Search term matched against page IDs, titles, and types |
153
+ | `type` | `string` | — | Filter results to a specific page type (e.g. `"concept"`, `"entity"`) |
154
+
155
+ **Returns**
31
156
 
32
157
  ```
33
- wiki_ingest(batch_size?: number)
158
+ details: {
159
+ query: string,
160
+ matches: Array<{ id: string, title: string, type: string }>
161
+ }
34
162
  ```
35
163
 
36
- ### wiki_ensure_page
164
+ ---
165
+
166
+ ## wiki_retro
167
+
168
+ Save an atomic insight from a completed task as a single lightweight markdown file in
169
+ `wiki/sources/`. Does not create a full source packet. Rebuilds metadata immediately so the
170
+ insight is searchable in the same session.
171
+
172
+ **Parameters**
37
173
 
38
- Create or resolve a canonical page.
174
+ | Name | Type | Required | Description |
175
+ |------|------|----------|-------------|
176
+ | `slug` | `string` | ✅ | Unique kebab-case identifier (e.g. `"jwt-revocation-pattern"`). Used as the filename and for lookups. |
177
+ | `title` | `string` | ✅ | Short descriptive title, 60 chars max. Noun phrase, not a sentence. |
178
+ | `body` | `string` | ✅ | Markdown content explaining what was learned. Include `[[wikilinks]]` to related pages. |
179
+ | `category` | `string` | — | Optional grouping label (e.g. `"frontend"`, `"architecture"`, `"devops"`, `"bugfix"`) |
180
+
181
+ **Returns**
39
182
 
40
183
  ```
41
- wiki_ensure_page(
42
- type: "concept" | "entity" | "synthesis" | "analysis",
43
- title: string,
44
- aliases?: string[],
45
- tags?: string[],
46
- summary?: string,
47
- createIfMissing?: boolean
48
- )
184
+ details: { slug: string, title: string, category: string | null }
49
185
  ```
50
186
 
51
- ### wiki_search
187
+ ---
188
+
189
+ ## wiki_observe
190
+
191
+ Record a timestamped, relevance-rated observation during a session. Saved to `wiki/sources/` with
192
+ `status: observation`. Immediately searchable via `wiki_recall`. Intended for mid-session capture;
193
+ use `wiki_retro` for end-of-task summaries.
194
+
195
+ **Parameters**
196
+
197
+ | Name | Type | Required | Description |
198
+ |------|------|----------|-------------|
199
+ | `title` | `string` | ✅ | Short descriptive title, ≤80 chars. Noun phrase, not a sentence. |
200
+ | `content` | `string` | ✅ | Plain prose: what happened, was decided, or was learned. Preserve specifics (file paths, function names, error messages, numbers). |
201
+ | `relevance` | `"low" \| "medium" \| "high" \| "critical"` | ✅ | Retention priority. `low` = routine; `medium` = task context; `high` = non-trivial decisions; `critical` = persistent identity/preference or completed work that must not be redone. |
202
+ | `tags` | `string` | — | Space-separated tags for categorisation (e.g. `"auth backend migration"`) |
203
+ | `source_context` | `string` | — | What was being worked on (e.g. `"Adding authentication module"`) |
52
204
 
53
- Search the wiki registry.
205
+ **Returns**
54
206
 
55
207
  ```
56
- wiki_search(query: string, type?: string, limit?: number)
208
+ details: { slug: string, title: string, relevance: string, tags: string | null }
57
209
  ```
58
210
 
59
- ### wiki_lint
211
+ The slug is auto-generated as `obs-YYYY-MM-DD-{title-slug}`.
60
212
 
61
- Health check the wiki.
213
+ ---
214
+
215
+ ## wiki_lint
216
+
217
+ Deterministic health check of the wiki. Scans for orphan pages (no inbound links), missing pages
218
+ (linked but not created), and contradiction markers. Optionally auto-creates stub pages for
219
+ knowledge gaps cited in two or more pages.
220
+
221
+ **Parameters**
222
+
223
+ | Name | Type | Required | Description |
224
+ |------|------|----------|-------------|
225
+ | `auto_fix` | `boolean` | — | When `true`, auto-creates stub concept pages for gaps mentioned in ≥2 pages (default: `false`) |
226
+
227
+ **Returns**
62
228
 
63
229
  ```
64
- wiki_lint(mode?: string, writeReport?: boolean, limit?: number)
230
+ details: {
231
+ pages: number,
232
+ orphans: number,
233
+ missingPages: number,
234
+ contradictions: number,
235
+ reportPath: string, // path to the generated lint report .md file
236
+ gaps: number // knowledge gaps tracked in .discoveries/gaps.json
237
+ }
65
238
  ```
66
239
 
67
- ### wiki_status
240
+ The lint report is written to `.llm-wiki/outputs/lint-YYYY-MM-DD.md`.
241
+ Contradictions are flagged by the presence of `⚠️ **Contradiction` markers in page content and
242
+ always require human review.
243
+
244
+ ---
245
+
246
+ ## wiki_status
247
+
248
+ Report wiki health and statistics from the generated registry. Reads pre-built metadata — does not
249
+ scan files directly.
250
+
251
+ **Parameters**
252
+
253
+ None.
68
254
 
69
- Show wiki statistics.
255
+ **Returns**
70
256
 
71
257
  ```
72
- wiki_status()
258
+ details: {
259
+ topic: string,
260
+ mode: string, // "personal" or "company"
261
+ totalPages: number,
262
+ byType: Record<string, number>, // e.g. { concept: 4, entity: 2, source: 7 }
263
+ orphans: number,
264
+ gaps: number,
265
+ health: "✅ Good" | "⚠️ Warning" | "🔴 Empty"
266
+ }
73
267
  ```
74
268
 
75
- ### wiki_rebuild_meta
269
+ Health is `"⚠️ Warning"` when orphan count exceeds 5, `"🔴 Empty"` when the registry has no pages.
76
270
 
77
- Force metadata rebuild.
271
+ ---
272
+
273
+ ## wiki_rebuild_meta
274
+
275
+ Force a full synchronous rebuild of all generated metadata: `registry.json`, `backlinks.json`,
276
+ `index.md`, `log.md`. Use when metadata appears out of sync with actual wiki files.
277
+
278
+ **Parameters**
279
+
280
+ None.
281
+
282
+ **Returns**
78
283
 
79
284
  ```
80
- wiki_rebuild_meta()
285
+ details: { pageCount: number }
81
286
  ```
82
287
 
83
- ### wiki_log_event
288
+ ---
84
289
 
85
- Record a structured event.
290
+ ## wiki_log_event
291
+
292
+ Append a structured event to `meta/events.jsonl` and regenerate `meta/log.md`. Every event is
293
+ timestamped automatically.
294
+
295
+ **Parameters**
296
+
297
+ | Name | Type | Required | Description |
298
+ |------|------|----------|-------------|
299
+ | `kind` | `string` | ✅ | Event kind label (e.g. `"ingest"`, `"query"`, `"decision"`, `"integrate"`) |
300
+ | `details` | `object` | — | Arbitrary additional fields to store alongside the event |
301
+
302
+ **Returns**
86
303
 
87
304
  ```
88
- wiki_log_event(
89
- kind: string,
90
- title: string,
91
- summary?: string,
92
- sourceIds?: string[],
93
- pagePaths?: string[],
94
- notes?: string[],
95
- actor?: "agent" | "user" | "extension"
96
- )
305
+ details: { kind: string }
97
306
  ```
98
307
 
99
- ### wiki_watch
308
+ ---
309
+
310
+ ## wiki_watch
100
311
 
101
- Schedule auto-updates.
312
+ Output the shell command needed to schedule automatic wiki updates (discover → ingest → lint) via
313
+ pi's `schedule_prompt` cron system. Does not schedule anything directly — it returns the command
314
+ for the user to run.
315
+
316
+ **Parameters**
317
+
318
+ | Name | Type | Required | Description |
319
+ |------|------|----------|-------------|
320
+ | `interval` | `string` | ✅ | `"daily"` (8:00 AM), `"weekly"` (Monday 9:00 AM), `"hourly"`, or `"stop"` (prints removal instructions) |
321
+
322
+ **Returns**
102
323
 
103
324
  ```
104
- wiki_watch(schedule: string, command: string)
325
+ details: {
326
+ interval: string,
327
+ cronSchedule: string, // e.g. "0 0 8 * * *"
328
+ label: string // e.g. "Daily at 8:00 AM"
329
+ }
330
+ ```
331
+
332
+ When `interval` is `"stop"`, returns `details: { action: "stop_instructions" }` with instructions
333
+ for removing existing jobs via `schedule_prompt action=remove`.
334
+
335
+ ---
336
+
337
+ ## Error Shape
338
+
339
+ All tools return `isError: true` in their result when a hard error occurs (no vault found, missing
340
+ required input). The `text` content will contain a human-readable explanation. Check for `isError`
341
+ before using `details`.
342
+
343
+ ```ts
344
+ {
345
+ content: [{ type: "text", text: string }],
346
+ details: { error: string },
347
+ isError: true
348
+ }
105
349
  ```
350
+
351
+ The most common error is **"No wiki found — run wiki_bootstrap first"**, returned by every tool
352
+ except `wiki_bootstrap` itself when `.llm-wiki/config.json` does not exist in the resolved vault
353
+ root.
@@ -1,14 +1,23 @@
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
 
6
+ export type ExtractionStatus = "success" | "failed" | "unsupported";
7
+
4
8
  export interface ExtractedContent {
5
9
  extracted: string;
6
10
  title?: string;
11
+ extractor?: string;
12
+ extraction_status?: ExtractionStatus;
13
+ content_type?: string;
7
14
  }
8
15
 
9
16
  export interface FileExtractor {
10
17
  format: string;
11
18
  shouldReadText: boolean;
19
+ extractorName?: string;
20
+ content_type?: string;
12
21
  matches(filePath: string): boolean;
13
22
  extract(args: FileExtractArgs): Promise<string> | string;
14
23
  }
@@ -31,6 +40,67 @@ interface UrlExtractArgs {
31
40
  signal?: AbortSignal;
32
41
  }
33
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
+
34
104
  const DEFAULT_MARKITDOWN_TIMEOUT_MS = 180_000;
35
105
  const DEFAULT_CURL_TIMEOUT_SECONDS = 30;
36
106
 
@@ -38,25 +108,38 @@ const FILE_EXTRACTORS: FileExtractor[] = [
38
108
  {
39
109
  format: "pdf",
40
110
  shouldReadText: false,
111
+ extractorName: "markitdown",
112
+ content_type: "application/pdf",
41
113
  matches: hasExtension(".pdf"),
42
114
  extract: ({ pi, filePath, signal }) => extractPdf(pi, filePath, signal),
43
115
  },
44
- textFileExtractor("markdown", [".md"]),
45
- textFileExtractor("text", [".txt"]),
46
- textFileExtractor("html", [".html", ".htm"]),
116
+ textFileExtractor("markdown", [".md"], "text/markdown"),
117
+ textFileExtractor("text", [".txt"], "text/plain"),
118
+ textFileExtractor("html", [".html", ".htm"], "text/html"),
47
119
  {
48
120
  format: "xml",
49
121
  shouldReadText: true,
122
+ extractorName: "xmlToMarkdown",
123
+ content_type: "application/xml",
50
124
  matches: hasExtension(".xml"),
51
125
  extract: ({ content }) => xmlToMarkdown(content),
52
126
  },
53
127
  {
54
128
  format: "json",
55
129
  shouldReadText: true,
130
+ extractorName: "jsonToMarkdown",
131
+ content_type: "application/json",
56
132
  matches: hasExtension(".json"),
57
133
  extract: ({ content }) => jsonToMarkdown(content),
58
134
  },
59
- textFileExtractor("docx", [".docx"]),
135
+ {
136
+ format: "docx",
137
+ shouldReadText: false,
138
+ extractorName: "markitdown",
139
+ content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
140
+ matches: hasExtension(".docx"),
141
+ extract: ({ pi, filePath, signal }) => extractDocx(pi, filePath, signal),
142
+ },
60
143
  textFileExtractor("file", []),
61
144
  ];
62
145
 
@@ -91,10 +174,16 @@ export function pdfExtractionFailureMessage(source: string): string {
91
174
  return `_PDF content could not be converted to markdown from ${source}. Try increasing WIKI_MARKITDOWN_TIMEOUT_MS._\n`;
92
175
  }
93
176
 
94
- function textFileExtractor(format: string, extensions: string[]): FileExtractor {
177
+ function textFileExtractor(
178
+ format: string,
179
+ extensions: string[],
180
+ contentType?: string,
181
+ ): FileExtractor {
95
182
  return {
96
183
  format,
97
184
  shouldReadText: true,
185
+ extractorName: "passthrough",
186
+ content_type: contentType,
98
187
  matches: extensions.length ? hasAnyExtension(extensions) : () => true,
99
188
  extract: ({ content }) => content,
100
189
  };
@@ -113,13 +202,33 @@ async function extractPdf(pi: ExtensionAPI, source: string, signal?: AbortSignal
113
202
  return extracted || pdfExtractionFailureMessage(source);
114
203
  }
115
204
 
205
+ export function docxExtractionFailureMessage(source: string): string {
206
+ return `_DOCX content could not be converted to markdown from ${source}. Ensure uvx and markitdown are installed._\n`;
207
+ }
208
+
209
+ async function extractDocx(
210
+ pi: ExtensionAPI,
211
+ source: string,
212
+ signal?: AbortSignal,
213
+ ): Promise<string> {
214
+ const extracted = await extractWithMarkItDown(pi, source, signal);
215
+ return extracted || docxExtractionFailureMessage(source);
216
+ }
217
+
116
218
  async function extractPdfUrl(
117
219
  pi: ExtensionAPI,
118
220
  url: string,
119
221
  signal?: AbortSignal,
120
222
  ): Promise<ExtractedContent> {
121
223
  const extracted = await extractPdf(pi, url, signal);
122
- return { extracted, title: titleFromMarkdown(extracted) };
224
+ const failed = extracted.includes("could not be converted");
225
+ return {
226
+ extracted,
227
+ title: titleFromMarkdown(extracted),
228
+ extractor: "markitdown",
229
+ extraction_status: failed ? "failed" : "success",
230
+ content_type: "application/pdf",
231
+ };
123
232
  }
124
233
 
125
234
  async function extractTextUrl(
@@ -129,13 +238,31 @@ async function extractTextUrl(
129
238
  ): Promise<ExtractedContent> {
130
239
  const markitdownExtracted = await extractWithMarkItDown(pi, url, signal);
131
240
  if (markitdownExtracted) {
132
- return { extracted: markitdownExtracted, title: titleFromMarkdown(markitdownExtracted) };
241
+ return {
242
+ extracted: markitdownExtracted,
243
+ title: titleFromMarkdown(markitdownExtracted),
244
+ extractor: "markitdown",
245
+ extraction_status: "success",
246
+ };
133
247
  }
134
248
 
135
249
  const curlExtracted = await fetchTextUrl(pi, url, signal);
136
- if (!curlExtracted) return { extracted: "" };
137
- if (looksLikePdf(curlExtracted)) return { extracted: pdfExtractionFailureMessage(url) };
138
- return { extracted: curlExtracted, title: titleFromHtml(curlExtracted) };
250
+ if (!curlExtracted) return { extracted: "", extractor: "none", extraction_status: "failed" };
251
+ if (looksLikePdf(curlExtracted)) {
252
+ return {
253
+ extracted: pdfExtractionFailureMessage(url),
254
+ extractor: "curl",
255
+ extraction_status: "failed",
256
+ content_type: "application/pdf",
257
+ };
258
+ }
259
+ const normalized = htmlToMarkdown(curlExtracted);
260
+ return {
261
+ extracted: normalized,
262
+ title: titleFromMarkdown(normalized) ?? titleFromHtml(curlExtracted),
263
+ extractor: "htmlToMarkdown",
264
+ extraction_status: "success",
265
+ };
139
266
  }
140
267
 
141
268
  async function extractWithMarkItDown(
@@ -149,7 +276,7 @@ async function extractWithMarkItDown(
149
276
  const mdResult = await exec(
150
277
  pi,
151
278
  "sh",
152
- ["-c", `uvx --from 'markitdown[pdf]' markitdown "${source}" 2>/dev/null || echo ""`],
279
+ ["-c", `uvx --from 'markitdown[docx,pdf]' markitdown "${source}" 2>/dev/null || echo ""`],
153
280
  { signal, timeout: markitdownTimeoutMs() },
154
281
  );
155
282
  return mdResult.stdout.trim() ? mdResult.stdout : "";
@@ -216,6 +343,23 @@ function titleFromHtml(html: string): string | undefined {
216
343
  return html.match(/<title>([^<]*)<\/title>/i)?.[1]?.trim();
217
344
  }
218
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
+
219
363
  /** Basic XML to markdown conversion: strip tags while preserving text structure. */
220
364
  function xmlToMarkdown(xml: string): string {
221
365
  let title = "";
@@ -234,14 +378,7 @@ function xmlToMarkdown(xml: string): string {
234
378
  }
235
379
  text = text.replace(/</g, "");
236
380
 
237
- text = text.replace(/&(?:amp|lt|gt|quot|#\d+);/gi, (entity) => {
238
- const map: Record<string, string> = { "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"' };
239
- const lower = entity.toLowerCase();
240
- if (map[lower]) return map[lower];
241
- if (lower.startsWith("&#")) return String.fromCodePoint(Number.parseInt(entity.slice(2, -1)));
242
- return entity;
243
- });
244
-
381
+ text = decodeHtmlEntities(text);
245
382
  text = text.replace(/\n{3,}/g, "\n\n").trim();
246
383
  if (!text) return xml;
247
384
 
@@ -251,6 +388,41 @@ function xmlToMarkdown(xml: string): string {
251
388
  return lines.join("\n\n");
252
389
  }
253
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
+
254
426
  function jsonToMarkdown(json: string): string {
255
427
  let value: unknown;
256
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
  /**
@@ -106,9 +112,28 @@ function fileCaptureSource(
106
112
  fallbackText: "",
107
113
  preserveOriginal: (packetPath) =>
108
114
  preserveFileOriginal(pi, packetPath, filePath, fileName, content, signal),
109
- extract: async () => ({
110
- extracted: await extractor.extract({ pi, filePath, content, signal }),
111
- }),
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
+
128
+ const extractedStr = await extractor.extract({ pi, filePath, content, signal });
129
+ const failed = extractedStr.includes("could not be converted");
130
+ return {
131
+ extracted: extractedStr,
132
+ extractor: extractor.extractorName ?? "passthrough",
133
+ extraction_status: (failed ? "failed" : "success") as "failed" | "success",
134
+ ...(extractor.content_type ? { content_type: extractor.content_type } : {}),
135
+ };
136
+ },
112
137
  manifest: () => ({
113
138
  title: fileName,
114
139
  file_path: filePath,
@@ -152,6 +177,9 @@ function finalizeCapture(
152
177
  captured: fmtDate(),
153
178
  packet_version: "1.0",
154
179
  ...source.manifest({ ...content, extracted }),
180
+ extractor: content.extractor ?? "passthrough",
181
+ extraction_status: content.extraction_status ?? "success",
182
+ ...(content.content_type ? { content_type: content.content_type } : {}),
155
183
  };
156
184
 
157
185
  writeFileSync(join(packet.packetPath, "extracted.md"), extracted, "utf-8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zosmaai/pi-llm-wiki",
3
- "version": "0.8.0",
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",