@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 +34 -3
- package/docs/api.md +301 -53
- package/extensions/llm-wiki/lib/source-extractors.ts +191 -19
- package/extensions/llm-wiki/lib/source-packet.ts +32 -4
- package/package.json +3 -2
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
3
|
+
All 13 tools registered by the extension. Parameters marked `?` are optional.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
---
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
23
|
+
details: { root: string, mode: string, topic: string }
|
|
11
24
|
```
|
|
12
25
|
|
|
13
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
+
details: {
|
|
159
|
+
query: string,
|
|
160
|
+
matches: Array<{ id: string, title: string, type: string }>
|
|
161
|
+
}
|
|
34
162
|
```
|
|
35
163
|
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
+
**Returns**
|
|
54
206
|
|
|
55
207
|
```
|
|
56
|
-
|
|
208
|
+
details: { slug: string, title: string, relevance: string, tags: string | null }
|
|
57
209
|
```
|
|
58
210
|
|
|
59
|
-
|
|
211
|
+
The slug is auto-generated as `obs-YYYY-MM-DD-{title-slug}`.
|
|
60
212
|
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
+
**Returns**
|
|
70
256
|
|
|
71
257
|
```
|
|
72
|
-
|
|
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
|
-
|
|
269
|
+
Health is `"⚠️ Warning"` when orphan count exceeds 5, `"🔴 Empty"` when the registry has no pages.
|
|
76
270
|
|
|
77
|
-
|
|
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
|
-
|
|
285
|
+
details: { pageCount: number }
|
|
81
286
|
```
|
|
82
287
|
|
|
83
|
-
|
|
288
|
+
---
|
|
84
289
|
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## wiki_watch
|
|
100
311
|
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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))
|
|
138
|
-
|
|
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
|
+
"&": "&",
|
|
351
|
+
"<": "<",
|
|
352
|
+
">": ">",
|
|
353
|
+
""": '"',
|
|
354
|
+
"'": "'",
|
|
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
|
|
238
|
-
const map: Record<string, string> = { "&": "&", "<": "<", ">": ">", """: '"' };
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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",
|