@msareen/knowledge-hub-builder 0.1.3

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.
Files changed (57) hide show
  1. package/.agents/skills/catalog/SKILL.md +7 -0
  2. package/.agents/skills/export/SKILL.md +7 -0
  3. package/.agents/skills/ingest/SKILL.md +7 -0
  4. package/.agents/skills/lint/SKILL.md +7 -0
  5. package/.agents/skills/new-bundle/SKILL.md +7 -0
  6. package/.agents/skills/query/SKILL.md +7 -0
  7. package/.agents/skills/visualize/SKILL.md +7 -0
  8. package/.bundle_template/index.md +9 -0
  9. package/.bundle_template/log.md +10 -0
  10. package/.bundle_template/raw/.gitkeep +15 -0
  11. package/.bundle_template/refs.md +6 -0
  12. package/.bundle_template/sources.yaml +13 -0
  13. package/.claude/skills/catalog/SKILL.md +7 -0
  14. package/.claude/skills/export/SKILL.md +7 -0
  15. package/.claude/skills/ingest/SKILL.md +7 -0
  16. package/.claude/skills/lint/SKILL.md +7 -0
  17. package/.claude/skills/new-bundle/SKILL.md +7 -0
  18. package/.claude/skills/query/SKILL.md +7 -0
  19. package/.claude/skills/visualize/SKILL.md +7 -0
  20. package/AGENTS.md +167 -0
  21. package/CLAUDE.md +13 -0
  22. package/README.md +289 -0
  23. package/SPEC.md +354 -0
  24. package/document/faq.md +156 -0
  25. package/package.json +52 -0
  26. package/scripts/cli.ts +66 -0
  27. package/scripts/export.ts +42 -0
  28. package/scripts/ingest/acquire.ts +189 -0
  29. package/scripts/ingest/exts.ts +29 -0
  30. package/scripts/ingest/files.ts +29 -0
  31. package/scripts/ingest/folder.ts +44 -0
  32. package/scripts/ingest/index.ts +125 -0
  33. package/scripts/ingest/protect.ts +42 -0
  34. package/scripts/ingest/web.ts +54 -0
  35. package/scripts/init.ts +93 -0
  36. package/scripts/lib/args.ts +8 -0
  37. package/scripts/lib/extract.ts +384 -0
  38. package/scripts/lib/graph-page.ts +477 -0
  39. package/scripts/lib/graph.ts +117 -0
  40. package/scripts/lib/ledger.ts +136 -0
  41. package/scripts/lib/log.ts +53 -0
  42. package/scripts/lib/paths.ts +55 -0
  43. package/scripts/lib/scaffold.ts +56 -0
  44. package/scripts/lib/util.ts +154 -0
  45. package/scripts/lint.ts +165 -0
  46. package/scripts/new-bundle.ts +17 -0
  47. package/scripts/visualize.ts +104 -0
  48. package/skills/catalog/SKILL.md +164 -0
  49. package/skills/export/SKILL.md +31 -0
  50. package/skills/ingest/SKILL.md +229 -0
  51. package/skills/lint/SKILL.md +69 -0
  52. package/skills/new-bundle/SKILL.md +25 -0
  53. package/skills/query/SKILL.md +114 -0
  54. package/skills/visualize/SKILL.md +33 -0
  55. package/templates/hub/gitattributes +12 -0
  56. package/templates/hub/gitignore +12 -0
  57. package/templates/hub/outer.index.md +13 -0
@@ -0,0 +1,384 @@
1
+ // Content-addressed extraction cache: inbox/extracted/<sha256>.md
2
+ //
3
+ // Binary formats have to be converted before anything can read them. That conversion is
4
+ // expensive and deterministic per content hash, so it is cached hub-wide rather than
5
+ // per-bundle. Nothing here ever writes into a bundle's `raw/` — `raw/` stays derived and
6
+ // rebuildable (templates/hub/gitignore:2); callers copy out of the cache.
7
+ //
8
+ // Extraction is built in. khb is tooling, so it carries the libraries: unpdf (pdf.js),
9
+ // mammoth and fflate are pure JS with no native build and no PATH assumptions, which is
10
+ // what lets `khb ingest` work on a bare machine. External CLIs are a bonus, not a
11
+ // requirement: if `pdftotext` or `pandoc` happen to be installed they get a second shot at
12
+ // anything the library couldn't read, because poppler still wins on gnarly layouts.
13
+ //
14
+ // Every route out of here is LOCAL and deterministic — pure-JS libraries, tesseract WASM,
15
+ // a whisper binary. None of it contacts a model. That is the AGENTS.md division of labor:
16
+ // khb converts bytes to text as cheaply as possible, and the agent's judgement is spent on
17
+ // curation, not on transcription.
18
+ //
19
+ // The two lossy routes (OCR, ASR) are marked `quality: low` rather than hidden. A pixel or
20
+ // audio source that extracted badly is not a dead end — the original file is still on disk
21
+ // and named in the provenance header, so curation can escalate to a vision read of the
22
+ // source instead of trusting garbled text.
23
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, readdirSync } from "node:fs";
24
+ import { INBOX, join, basename } from "./util";
25
+ import { note } from "./log";
26
+
27
+ export const EXTRACTED = join(INBOX, "extracted");
28
+
29
+ /** Below this many characters per page, a PDF is a picture of a document, not a document. */
30
+ const SCANNED_CHARS_PER_PAGE = 20;
31
+
32
+ /** `high` = real text out of a born-digital file. `low` = OCR/ASR guessed at it. */
33
+ export type Quality = "high" | "low";
34
+
35
+ export type Extraction =
36
+ | { status: "ok"; path: string; tool: string; quality: Quality }
37
+ | { status: "needs-ocr"; pages: number } // renders fine, has no text layer — OCR is the only route
38
+ | { status: "unsupported" } // no extractor for this format
39
+ | { status: "failed"; reason: string }; // tried, got nothing usable
40
+
41
+ export function extractedPath(hash: string): string {
42
+ return join(EXTRACTED, `${hash}.md`);
43
+ }
44
+
45
+ /** Cached text minus the provenance header — what a `raw/` copy actually wants. */
46
+ export function extractedBody(path: string): string {
47
+ const text = readFileSync(path, "utf8");
48
+ const m = text.match(/^---\n[\s\S]*?\n---\n\n?/);
49
+ return m ? text.slice(m[0].length) : text;
50
+ }
51
+
52
+ /**
53
+ * How a cache entry was produced. Read back rather than re-derived, so a `raw/` copy made
54
+ * from cache carries the same `extract_tool`/`quality` as the run that filled it.
55
+ */
56
+ export function extractedMeta(path: string): { tool: string; quality: Quality } {
57
+ const head = readFileSync(path, "utf8").slice(0, 2048);
58
+ const tool = head.match(/^tool: (.*)$/m)?.[1]?.trim() || "unknown";
59
+ const quality = head.match(/^quality: (.*)$/m)?.[1]?.trim() === "low" ? "low" : "high";
60
+ return { tool, quality };
61
+ }
62
+
63
+ type LibResult = { text: string; pages?: number };
64
+
65
+ const unxml = (s: string) =>
66
+ s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
67
+
68
+ /** Strip tags from an OOXML/ODF fragment, keeping paragraph breaks. */
69
+ const stripXml = (xml: string, breakOn: RegExp) =>
70
+ unxml(xml.replace(breakOn, "\n").replace(/<[^>]+>/g, ""));
71
+
72
+ /** "BD" → 55. Preserves gaps so an empty cell doesn't shift the rest of the row left. */
73
+ function colIndex(ref: string): number {
74
+ const letters = ref.match(/^[A-Z]+/)?.[0] ?? "A";
75
+ let n = 0;
76
+ for (const ch of letters) n = n * 26 + (ch.charCodeAt(0) - 64);
77
+ return n - 1;
78
+ }
79
+
80
+ /** Built-in, pure-JS extractors. Loaded lazily so `khb init` never pays for them. */
81
+ const LIBRARY: Record<string, (file: string) => Promise<LibResult>> = {
82
+ ".pdf": async (file) => {
83
+ const { extractText, getDocumentProxy } = await import("unpdf");
84
+ const doc = await getDocumentProxy(new Uint8Array(readFileSync(file)));
85
+ const { totalPages, text } = await extractText(doc, { mergePages: true });
86
+ return { text, pages: totalPages };
87
+ },
88
+ ".docx": async (file) => {
89
+ const mammoth = (await import("mammoth")).default;
90
+ // Markdown keeps headings and lists, which are exactly the structure curation reads.
91
+ const { value } = await mammoth.convertToMarkdown({ path: file });
92
+ // mammoth escapes markdown punctuation defensively ("Non\-Disclosure", "2026\-02\-01").
93
+ // That noise ends up in model snippets and in raw/, so undo it.
94
+ return { text: value.replace(/\\([-_*#+.!\[\]()`])/g, "$1") };
95
+ },
96
+ ".odt": async (file) => {
97
+ // ODT is a zip of XML, same shape as DOCX — cheap to support once fflate is here.
98
+ const { unzipSync, strFromU8 } = await import("fflate");
99
+ const zip = unzipSync(new Uint8Array(readFileSync(file)));
100
+ const xml = strFromU8(zip["content.xml"] ?? new Uint8Array());
101
+ return { text: stripXml(xml, /<text:(?:h|p)\b[^>]*>/g) };
102
+ },
103
+ // XLSX and PPTX are the same zip+XML shape as ODT, so they cost one parser each and no
104
+ // new dependency. Spreadsheets in particular are worth having: a budget or a tracker is
105
+ // knowledge, and it reaching curation as a blank row was the single biggest gap.
106
+ ".xlsx": async (file) => {
107
+ const { unzipSync, strFromU8 } = await import("fflate");
108
+ const zip = unzipSync(new Uint8Array(readFileSync(file)));
109
+ const at = (n: string) => (zip[n] ? strFromU8(zip[n]) : "");
110
+
111
+ // Cell values are indices into one shared string table; resolve it before the sheets.
112
+ const shared = [...at("xl/sharedStrings.xml").matchAll(/<si>([\s\S]*?)<\/si>/g)].map((m) =>
113
+ [...m[1].matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((t) => unxml(t[1])).join(""),
114
+ );
115
+ const names = [...at("xl/workbook.xml").matchAll(/<sheet[^>]*name="([^"]*)"/g)].map((m) => unxml(m[1]));
116
+ const sheets = Object.keys(zip)
117
+ .filter((n) => /^xl\/worksheets\/sheet\d+\.xml$/.test(n))
118
+ .sort((a, b) => Number(a.match(/\d+/)![0]) - Number(b.match(/\d+/)![0]));
119
+
120
+ const out: string[] = [];
121
+ sheets.forEach((sheetFile, i) => {
122
+ const rows: string[][] = [];
123
+ for (const rm of at(sheetFile).matchAll(/<row[^>]*>([\s\S]*?)<\/row>/g)) {
124
+ const cells: string[] = [];
125
+ for (const cm of rm[1].matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
126
+ const ref = cm[1].match(/r="([A-Z]+\d+)"/)?.[1];
127
+ const type = cm[1].match(/t="([^"]*)"/)?.[1];
128
+ const v = cm[2].match(/<v>([\s\S]*?)<\/v>/)?.[1];
129
+ const value =
130
+ type === "s" ? (shared[Number(v)] ?? "")
131
+ : type === "inlineStr" ? [...cm[2].matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((t) => unxml(t[1])).join("")
132
+ : unxml(v ?? "");
133
+ if (ref) cells[colIndex(ref)] = value;
134
+ }
135
+ // A sheet is mostly empty cells; drop rows that carry nothing at all.
136
+ const filled = [...cells].map((c) => (c ?? "").replaceAll("|", "\\|").trim());
137
+ if (filled.some(Boolean)) rows.push(filled);
138
+ }
139
+ if (!rows.length) return;
140
+ // Pad every row to the widest one: a sparse sheet whose row 1 is narrower than row 5
141
+ // otherwise renders as a ragged table, which no markdown reader will parse.
142
+ const width = Math.max(...rows.map((r) => r.length));
143
+ const line = (r: string[]) => `| ${Array.from({ length: width }, (_, j) => r[j] ?? "").join(" | ")} |`;
144
+ // A header separator after row 1 makes the sheet render as a table wherever raw/ is
145
+ // read, and costs nothing when row 1 isn't really a header.
146
+ const body = [line(rows[0]), `|${"---|".repeat(width)}`, ...rows.slice(1).map(line)];
147
+ out.push(`## ${names[i] ?? basename(sheetFile)}\n\n${body.join("\n")}`);
148
+ });
149
+ return { text: out.join("\n\n") };
150
+ },
151
+ ".pptx": async (file) => {
152
+ const { unzipSync, strFromU8 } = await import("fflate");
153
+ const zip = unzipSync(new Uint8Array(readFileSync(file)));
154
+ const slides = Object.keys(zip)
155
+ .filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n))
156
+ .sort((a, b) => Number(a.match(/\d+/)![0]) - Number(b.match(/\d+/)![0]));
157
+ const out = slides.map((s, i) => {
158
+ const body = [...strFromU8(zip[s]).matchAll(/<a:t>([\s\S]*?)<\/a:t>/g)].map((m) => unxml(m[1])).join("\n");
159
+ return body.trim() ? `## Slide ${i + 1}\n\n${body}` : "";
160
+ });
161
+ return { text: out.filter(Boolean).join("\n\n") };
162
+ },
163
+ };
164
+
165
+ /** Optional second attempt: better fidelity, but only if the user happens to have them. */
166
+ const CLI: Record<string, (file: string) => string[]> = {
167
+ ".pdf": (f) => ["pdftotext", "-layout", f, "-"],
168
+ ".docx": (f) => ["pandoc", f, "-t", "gfm"],
169
+ ".odt": (f) => ["pandoc", f, "-t", "gfm"],
170
+ ".pptx": (f) => ["pandoc", f, "-t", "gfm"],
171
+ };
172
+
173
+ async function runCli(argv: string[]): Promise<string> {
174
+ try {
175
+ const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "ignore" });
176
+ const text = await new Response(proc.stdout).text();
177
+ return (await proc.exited) === 0 ? text : "";
178
+ } catch {
179
+ return ""; // not installed — expected, and not worth a warning
180
+ }
181
+ }
182
+
183
+ function writeCache(dest: string, path: string, tool: string, quality: Quality, text: string): string {
184
+ mkdirSync(EXTRACTED, { recursive: true });
185
+ const fm =
186
+ `---\nsource: ${path.replaceAll("\\", "/")}\nextracted: ${new Date().toISOString()}\n` +
187
+ `tool: ${tool}\nquality: ${quality}\n---\n\n`;
188
+ writeFileSync(dest, fm + text);
189
+ return dest;
190
+ }
191
+
192
+ function cacheHit(dest: string): Extraction {
193
+ return { status: "ok", path: dest, ...extractedMeta(dest) };
194
+ }
195
+
196
+ /**
197
+ * Fill the cache for this file's extracted text and say what happened.
198
+ * Never throws — a run over thousands of files degrades per file instead of aborting.
199
+ */
200
+ export async function extractCached(path: string, hash: string, ext: string): Promise<Extraction> {
201
+ const dest = extractedPath(hash);
202
+ if (existsSync(dest)) return cacheHit(dest);
203
+ if (!LIBRARY[ext]) return { status: "unsupported" };
204
+
205
+ let pages: number | undefined;
206
+ let text = "";
207
+ let tool = ext.slice(1);
208
+ try {
209
+ const r = await LIBRARY[ext](path);
210
+ text = r.text;
211
+ pages = r.pages;
212
+ } catch {
213
+ text = ""; // corrupt, encrypted, or a format surprise — the CLI may still cope
214
+ }
215
+
216
+ if (!text.trim() && CLI[ext]) {
217
+ const bin = CLI[ext](path)[0];
218
+ note(`built-in reader recovered nothing — retrying with ${bin} if installed …`);
219
+ text = await runCli(CLI[ext](path));
220
+ if (text.trim()) tool = bin;
221
+ }
222
+
223
+ // Pages but (near-)no characters is the signature of a scan. Check before declaring
224
+ // success: a scan stamped with a page number yields a few characters, not zero, and
225
+ // calling that "extracted" would hide a document that OCR could actually read.
226
+ const trimmed = text.trim();
227
+ if (pages && trimmed.length / pages < SCANNED_CHARS_PER_PAGE) return { status: "needs-ocr", pages };
228
+ if (trimmed) return { status: "ok", path: writeCache(dest, path, tool, "high", trimmed), tool, quality: "high" };
229
+ return { status: "failed", reason: "no text recovered" };
230
+ }
231
+
232
+ /**
233
+ * Lazily loaded OCR stack, shared by the PDF and bare-image paths. The install hint is
234
+ * printed once per process, not once per file — a corpus of scans would otherwise bury its
235
+ * own summary under hundreds of identical warnings.
236
+ */
237
+ let ocrWarned = false;
238
+ async function ocrDeps() {
239
+ try {
240
+ const { createWorker } = await import("tesseract.js");
241
+ return { createWorker };
242
+ } catch {
243
+ if (!ocrWarned) {
244
+ // Resolution is relative to the khb package, not the hub — say where, because for a
245
+ // global install those are different directories and `bun add` in the hub is a no-op.
246
+ const { PKG } = await import("./paths");
247
+ console.warn(` OCR unavailable. Install it where khb resolves modules from:`);
248
+ console.warn(` cd ${PKG} && bun add @hyzyla/pdfium sharp tesseract.js`);
249
+ ocrWarned = true;
250
+ }
251
+ return null;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * OCR a scanned PDF: render each page, then read the pixels. Runs automatically during
257
+ * ingest when a PDF turns out to have no text layer — a scan is not a failure, it just
258
+ * needs a different reader — but the deps are optional (~75 MB of WASM plus a one-time
259
+ * language-data download), so a missing stack degrades to a pending ledger row.
260
+ *
261
+ * bun add @hyzyla/pdfium sharp tesseract.js
262
+ */
263
+ export async function ocrCached(path: string, hash: string, dpi = 216): Promise<Extraction> {
264
+ const dest = extractedPath(hash);
265
+ if (existsSync(dest)) return cacheHit(dest);
266
+
267
+ const deps = await ocrDeps();
268
+ if (!deps) return { status: "failed", reason: "OCR dependencies not installed" };
269
+ let PDFiumLibrary, sharp;
270
+ try {
271
+ ({ PDFiumLibrary } = await import("@hyzyla/pdfium"));
272
+ sharp = (await import("sharp")).default;
273
+ } catch {
274
+ return { status: "failed", reason: "OCR dependencies not installed" };
275
+ }
276
+
277
+ const lib = await PDFiumLibrary.init();
278
+ const worker = await deps.createWorker("eng");
279
+ const tool = `tesseract.js @ ${dpi}dpi`;
280
+ try {
281
+ const doc = await lib.loadDocument(readFileSync(path));
282
+ // Materialize the page list for the denominator: this loop is the longest thing khb
283
+ // does, and "page 7/94" is the difference between waiting and killing the process.
284
+ const all = [...doc.pages()];
285
+ const pages: string[] = [];
286
+ for (const [i, page] of all.entries()) {
287
+ const img = await page.render({
288
+ scale: dpi / 72,
289
+ render: (o: { data: Buffer; width: number; height: number }) =>
290
+ sharp(o.data, { raw: { width: o.width, height: o.height, channels: 4 } }).png().toBuffer(),
291
+ });
292
+ const { data } = await worker.recognize(Buffer.from(img.data));
293
+ const text = data.text.trim();
294
+ note(` page ${i + 1}/${all.length} — ${text.length} chars`);
295
+ pages.push(text);
296
+ }
297
+ doc.destroy();
298
+ const text = pages.filter(Boolean).join("\n\n---\n\n");
299
+ if (!text) return { status: "failed", reason: "OCR produced no text" };
300
+ return { status: "ok", path: writeCache(dest, path, tool, "low", text), tool, quality: "low" };
301
+ } catch (e) {
302
+ return { status: "failed", reason: `OCR failed: ${e}` };
303
+ } finally {
304
+ await worker.terminate();
305
+ lib.destroy();
306
+ }
307
+ }
308
+
309
+ /**
310
+ * OCR a bare image (screenshot, photographed page, scanned receipt). Same tesseract pass
311
+ * as a scanned PDF minus the render step, since the pixels are already the file.
312
+ *
313
+ * This deliberately does NOT try to understand a diagram or a chart — OCR reads glyphs.
314
+ * The `quality: low` marker plus the source path in the header is how curation knows to
315
+ * escalate to a vision read when the text comes back thin or nonsensical.
316
+ */
317
+ export async function ocrImageCached(path: string, hash: string): Promise<Extraction> {
318
+ const dest = extractedPath(hash);
319
+ if (existsSync(dest)) return cacheHit(dest);
320
+
321
+ const deps = await ocrDeps();
322
+ if (!deps) return { status: "failed", reason: "OCR dependencies not installed" };
323
+
324
+ const worker = await deps.createWorker("eng");
325
+ try {
326
+ const { data } = await worker.recognize(readFileSync(path));
327
+ const text = data.text.trim();
328
+ if (!text) return { status: "failed", reason: "no text in image — vision read may still help" };
329
+ return { status: "ok", path: writeCache(dest, path, "tesseract.js", "low", text), tool: "tesseract.js", quality: "low" };
330
+ } catch (e) {
331
+ return { status: "failed", reason: `OCR failed: ${e}` };
332
+ } finally {
333
+ await worker.terminate();
334
+ }
335
+ }
336
+
337
+ /** Probe once per process: spawning `--help` per file would cost more than it saves. */
338
+ let whisper: string | undefined;
339
+ async function whisperBin(): Promise<string> {
340
+ if (whisper !== undefined) return whisper;
341
+ whisper = (await runCli(["whisper", "--help"])) ? "whisper"
342
+ : (await runCli(["faster-whisper", "--help"])) ? "faster-whisper"
343
+ : "";
344
+ if (!whisper) console.warn(` whisper not on PATH — transcription skipped. Install: pip install -U openai-whisper`);
345
+ return whisper;
346
+ }
347
+
348
+ /**
349
+ * Transcribe audio or video with a local whisper binary. Video needs no demux step —
350
+ * whisper reads the audio track directly.
351
+ *
352
+ * Minutes of CPU per file, so this is the one extractor worth interrupting: `khb ingest
353
+ * --skip-audio` leaves the rows pending and everything else proceeds. Still local and
354
+ * still deterministic-enough to belong in khb rather than in an agent pass.
355
+ */
356
+ export async function transcribeCached(path: string, hash: string, model = "base"): Promise<Extraction> {
357
+ const dest = extractedPath(hash);
358
+ if (existsSync(dest)) return cacheHit(dest);
359
+
360
+ const bin = await whisperBin();
361
+ if (!bin) return { status: "failed", reason: "whisper not installed" };
362
+
363
+ // whisper writes <name>.txt into --output_dir rather than to stdout; give it a scratch
364
+ // directory of its own so a stray sibling .txt never gets mistaken for the transcript.
365
+ const out = join(INBOX, "tmp", hash.slice(0, 12));
366
+ mkdirSync(out, { recursive: true });
367
+ const tool = `${bin} (${model})`;
368
+ try {
369
+ const proc = Bun.spawn([bin, path, "--model", model, "--output_format", "txt", "--output_dir", out], {
370
+ stdout: "ignore",
371
+ stderr: "ignore",
372
+ });
373
+ if ((await proc.exited) !== 0) return { status: "failed", reason: `${bin} exited non-zero` };
374
+ const txt = readdirSync(out).find((f) => f.endsWith(".txt"));
375
+ if (!txt) return { status: "failed", reason: `${bin} produced no transcript` };
376
+ const text = readFileSync(join(out, txt), "utf8").trim();
377
+ if (!text) return { status: "failed", reason: "empty transcript" };
378
+ return { status: "ok", path: writeCache(dest, path, tool, "low", text), tool, quality: "low" };
379
+ } catch (e) {
380
+ return { status: "failed", reason: `transcription failed: ${e}` };
381
+ } finally {
382
+ rmSync(out, { recursive: true, force: true });
383
+ }
384
+ }