@msareen/knowledge-hub-builder 0.1.4 → 0.1.7

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/AGENTS.md CHANGED
@@ -135,7 +135,7 @@ From outside, pass `--hub <dir>` or set `$KHB_HUB`.
135
135
  | Command | Purpose |
136
136
  |---|---|
137
137
  | `khb lint` | validate structure against `skills/lint/SKILL.md` |
138
- | `khb upgrade` | refresh this hub's package-owned contract docs |
138
+ | `khb upgrade` | refresh this hub's package-owned contract docs — runs by itself whenever `khb.json`'s stamped version differs from the installed khb, so these docs always match the CLI |
139
139
  | `khb visualize` | regenerate `visualizer/graph.html` |
140
140
  | `khb new-bundle <name>` | scaffold + register a bundle |
141
141
  | `khb ingest <bundle>` | acquire + extract every source in `sources.yaml` → `raw/`; maintains `log.md`. Name the bundle — with none it lists the hub's bundles and stops, unless there is nothing to choose between (no bundles, or only `default`), where it uses `default` |
@@ -158,6 +158,10 @@ The single exception is a hub with nothing to choose between: with no bundles at
158
158
  unnamed ingest lands in `default`, created on the spot, and where `default` is the only
159
159
  bundle it lands there as it stands.
160
160
 
161
+ When a run finishes, **offer the catalog pass** for the bundle just ingested — report what
162
+ landed and how many rows are uncurated, then ask before curating anything. Neither stopping
163
+ silently at the summary nor cataloging unasked is right.
164
+
161
165
  **Catalog** (`skills/catalog/SKILL.md`) is the judgement half, one bundle at a time: read
162
166
  each `raw/` file, split it into concepts, give each OKF frontmatter, link them, register
163
167
  them in `index.md`. When the runtime supports parallel agents, fan them out over the raw
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # knowledge-hub-builder
2
2
 
3
3
  <p align="center">
4
- <img src="images/demo.gif" alt="KHB creates a hub, ingests sources, routes a question to a concept, and validates the result" width="544">
4
+ <img src="images/demo.gif" alt="khb init in the shell, then an agent session: the ingest skill asks which bundle owns the material, runs khb ingest as a tool call, offers to catalog, writes the concept docs, then answers a question from them" width="600">
5
5
  </p>
6
6
 
7
7
  **KHB (Knowledge Hub Builder)** is a local, markdown-based knowledge base maintained with
@@ -69,20 +69,25 @@ after ingestion.
69
69
  `khb ingest <bundle>` reads `sources.yaml` and writes extracted markdown under
70
70
  `bundles/<bundle>/raw/`.
71
71
 
72
- Supported without additional system tools:
72
+ Supported out of the box, with no system tools and no further installation:
73
73
 
74
74
  - text, markdown, CSV, JSON, and YAML
75
75
  - PDF and DOCX
76
76
  - ODT, XLSX, and PPTX
77
+ - OCR for images and scanned PDFs, applied automatically when a PDF has no text layer
77
78
 
78
- Optional local tools add:
79
+ OCR ships with KHB: `@hyzyla/pdfium`, `sharp`, and `tesseract.js` are ordinary dependencies,
80
+ so installing KHB pulls them down whether or not you ever ingest a scan. That costs roughly
81
+ 75 MB of WASM plus `sharp`'s native binaries — the price of an ingest that never stalls
82
+ waiting for a setup step.
79
83
 
80
- - OCR for images and scanned PDFs: `@hyzyla/pdfium`, `sharp`, and `tesseract.js`
81
- - audio and video transcription: a `whisper` or `faster-whisper` executable on `PATH`
84
+ One extractor is genuinely optional:
82
85
 
83
- If an optional extractor is missing, KHB leaves a pending row in `log.md` and prints the
84
- required setup. OCR packages must be installed where the KHB package resolves modules; the
85
- CLI prints that location.
86
+ - audio and video transcription needs a `whisper` or `faster-whisper` executable on `PATH`
87
+ (`pip install -U openai-whisper`)
88
+
89
+ Without it, KHB leaves a pending row in `log.md` and prints the required setup rather than
90
+ failing the run.
86
91
 
87
92
  Every raw markdown file carries provenance:
88
93
 
@@ -208,6 +213,12 @@ files under `skills/` when developing KHB.
208
213
  changing `bundles/` or `outer.index.md`. `khb export` includes the same compatibility
209
214
  layout in a standalone bundle export.
210
215
 
216
+ The refresh is also automatic: every command that works on a hub first compares the `khb`
217
+ version stamped in `khb.json` against the installed package, and upgrades the hub in place
218
+ if they differ, printing one line to stderr. So updating the package is enough — the hub's
219
+ contract docs can never be a version behind the CLI acting on them. Set
220
+ `KHB_NO_AUTO_UPGRADE=1` to suppress the check and leave the hub as it is.
221
+
211
222
  ## Commands
212
223
 
213
224
  Commands can be run directly or requested through the matching agent skill.
@@ -215,7 +226,7 @@ Commands can be run directly or requested through the matching agent skill.
215
226
  | Command | Purpose |
216
227
  |---|---|
217
228
  | `khb init [dir]` | Create a hub |
218
- | `khb upgrade` | Refresh package-owned contracts and skills |
229
+ | `khb upgrade` | Refresh package-owned contracts and skills (also runs automatically on version drift) |
219
230
  | `khb new-bundle <name> ["scope"]` | Create and register a bundle |
220
231
  | `khb ingest [bundle] [--force]` | Acquire and extract declared sources |
221
232
  | `khb lint` | Validate routing, bundle structure, and OKF metadata |
@@ -229,14 +240,15 @@ Additional ingest flags:
229
240
 
230
241
  Run against a hub outside the current directory with `--hub <dir>` or `$KHB_HUB`.
231
242
 
232
- To update the installed package and then refresh a hub:
243
+ To update the installed package:
233
244
 
234
245
  ```bash
235
246
  bun update -g @msareen/knowledge-hub-builder
236
- cd ~/my-knowledge
237
- khb upgrade
238
247
  ```
239
248
 
249
+ Each hub refreshes itself the next time you run any command in it. `khb upgrade` does the
250
+ same thing on demand, with a fuller report.
251
+
240
252
  ## Hub Layout
241
253
 
242
254
  ```text
package/SPEC.md CHANGED
@@ -92,6 +92,14 @@ agent is opened on the hub folder and must find its rules there, without knowing
92
92
  `khb` is installed. They are package-owned: `khb upgrade` overwrites them in place and
93
93
  leaves `bundles/` and `outer.index.md` alone.
94
94
 
95
+ Copies drift, so the copy is kept honest by the CLI rather than by the user's memory.
96
+ `khb.json` stamps the version that wrote the hub's copies; before running any command that
97
+ touches a hub, `khb` compares that stamp to the installed package version and, if they
98
+ differ, performs the upgrade in place and says so on stderr. Only `init` (no hub yet) and
99
+ `upgrade` (which is the operation) skip the check, and `KHB_NO_AUTO_UPGRADE=1` disables it.
100
+ The invariant it buys: **a hub's contract docs always state the same version as the `khb`
101
+ acting on them** — an agent can never read a protocol the CLI no longer implements.
102
+
95
103
  ### 2b. The package — `@msareen/knowledge-hub-builder`, installed once
96
104
 
97
105
  > **None of the following is in your hub.** This section describes the *installed tool*,
@@ -105,7 +113,7 @@ leaves `bundles/` and `outer.index.md` alone.
105
113
  @msareen/knowledge-hub-builder/ # installed once, globally — NOT part of a hub
106
114
  ├── package.json # bin: khb → scripts/cli.ts
107
115
  ├── scripts/
108
- │ ├── cli.ts # subcommand dispatch; owns the global --hub flag
116
+ │ ├── cli.ts # subcommand dispatch; --hub flag; the version drift check
109
117
  │ ├── init.ts # khb init / khb upgrade
110
118
  │ ├── new-bundle.ts # scaffold from .bundle_template, register in outer.index.md
111
119
  │ ├── export.ts # bundle + common patterns → standalone shareable folder
@@ -116,6 +124,7 @@ leaves `bundles/` and `outer.index.md` alone.
116
124
  │ ├── extract.ts # every local extractor + the content-hash cache
117
125
  │ ├── ledger.ts # log.md read/write
118
126
  │ ├── paths.ts # package-side paths — importing it never needs a hub
127
+ │ ├── upgrade.ts # the refresh itself: `khb upgrade` and the drift check
119
128
  │ └── util.ts # hub resolution + shared helpers
120
129
  ├── .bundle_template/ # copied by `khb new-bundle`
121
130
  ├── templates/hub/ # copied by `khb init`
@@ -273,11 +282,15 @@ puts them on the CLI side of the §Division-of-labor line.
273
282
  | DOCX | `mammoth`, `pandoc` if present | bundled | high |
274
283
  | ODT, PPTX | `fflate` + XML | bundled | high |
275
284
  | XLSX | `fflate` → one markdown table per sheet | bundled | high |
276
- | scanned PDF | `pdfium` + `tesseract.js` (WASM) | opt-in, ~75 MB | low |
277
- | Images (png/jpg/webp/tif) | `tesseract.js` | opt-in, ~75 MB | low |
285
+ | scanned PDF | `pdfium` + `tesseract.js` (WASM) | bundled, ~75 MB | low |
286
+ | Images (png/jpg/webp/tif) | `tesseract.js` | bundled, ~75 MB | low |
278
287
  | Audio, video | `whisper` / `faster-whisper` | opt-in, pip | low |
279
288
 
280
- Missing optional deps degrade to a ledger row with an empty `raw` and a printed install
289
+ The OCR stack is bundled rather than opt-in: an ingest that stops to ask for an install is
290
+ worse than an install that carries WASM nobody uses. Transcription stays opt-in because it is
291
+ a Python executable, not something a JS package manager can pull down.
292
+
293
+ A missing dep degrades to a ledger row with an empty `raw` and a printed install
281
294
  hint — never to a failed run. `quality: low` output is a standing invitation for the catalog
282
295
  pass to re-read the original: a vision read of a chart or a scanned table recovers what OCR
283
296
  drops, and rewrites the raw file with `extract_tool: claude-vision`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msareen/knowledge-hub-builder",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
4
4
  "description": "Knowledge Hub Builder — a bundle-of-bundles knowledge base you build with an agent",
5
5
  "keywords": ["knowledge-base", "okf", "agent", "wiki", "bundles", "claude", "codex", "rag"],
6
6
  "license": "MIT",
package/scripts/cli.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  // khb — the CLI. Subcommands are loaded lazily: `init` must run before a hub exists,
3
3
  // so nothing that resolves a hub may be imported at module scope.
4
- import { version } from "./lib/paths";
4
+ import { version, findHub, markerIn, MARKER } from "./lib/paths";
5
5
 
6
6
  const COMMANDS: Record<string, { load: () => Promise<unknown>; help: string }> = {
7
7
  init: { load: () => import("./init"), help: "khb init [dir] create a hub here (or in dir)" },
@@ -59,6 +59,28 @@ if (!entry) {
59
59
  process.exit(1);
60
60
  }
61
61
 
62
+ // Version drift: a hub carries package-owned copies of the agent contract, and a hub
63
+ // stamped at an older version than the installed khb is stating an older contract than
64
+ // the one the CLI now implements. Rather than let the two disagree, refresh the hub in
65
+ // place before running the command — `khb upgrade` touches nothing the user wrote.
66
+ // `init` has no hub yet, `upgrade` does this itself, and $KHB_NO_AUTO_UPGRADE opts out.
67
+ if (cmd !== "init" && cmd !== "upgrade" && !process.env.KHB_NO_AUTO_UPGRADE) {
68
+ const hub = findHub();
69
+ if (hub) {
70
+ const { hubVersion, upgradeHub } = await import("./lib/upgrade");
71
+ // A marker under a pre-rename name is drift too, even at a matching version.
72
+ if (hubVersion(hub) !== version() || markerIn(hub) !== MARKER) {
73
+ const { from, to, pruned, renamed } = upgradeHub(hub);
74
+ // stderr, so a command's own output stays pipeable.
75
+ console.error(
76
+ `khb: hub was built by ${from ?? "an unknown version"}, khb is ${to} — refreshed its contract docs.`,
77
+ );
78
+ if (renamed) console.error(`khb: renamed ${renamed} -> khb.json`);
79
+ if (pruned.length) console.error(`khb: removed (no longer part of the contract): ${pruned.join(", ")}`);
80
+ }
81
+ }
82
+ }
83
+
62
84
  // Subcommand modules parse process.argv.slice(2) themselves — reshape it so they see
63
85
  // their own arguments and not the subcommand name.
64
86
  process.argv = [process.argv[0], process.argv[1], ...argv];
package/scripts/init.ts CHANGED
@@ -5,63 +5,23 @@
5
5
  // copied INTO the hub — an agent opened on the hub folder must be able to read them
6
6
  // without knowing where khb is installed. Those copies are package-owned: `upgrade`
7
7
  // overwrites them.
8
- import { cpSync, mkdirSync, writeFileSync, existsSync, statSync, rmSync } from "node:fs";
9
- import { join, resolve, basename, dirname } from "node:path";
10
- import { PKG, HUB_TEMPLATE, MANAGED, RETIRED, MARKER, markerIn, version } from "./lib/paths";
8
+ //
9
+ // The mechanism itself lives in lib/upgrade.ts, because cli.ts also runs it on version
10
+ // drift before any hub command.
11
+ import { cpSync, mkdirSync, existsSync } from "node:fs";
12
+ import { join, resolve, basename } from "node:path";
13
+ import { HUB_TEMPLATE, MARKER, markerIn } from "./lib/paths";
14
+ import { upgradeHub, syncManaged, stamp } from "./lib/upgrade";
11
15
 
12
16
  const upgrading = process.env.KHB_SUBCOMMAND === "upgrade";
13
17
  const [dirArg] = process.argv.slice(2);
14
18
 
15
- /** Copy every package-owned contract file into the hub, replacing what is there. */
16
- function syncManaged(hub: string): string[] {
17
- const done: string[] = [];
18
- for (const f of MANAGED) {
19
- const src = join(PKG, f);
20
- if (!existsSync(src)) continue;
21
- const dest = join(hub, f);
22
- mkdirSync(dirname(dest), { recursive: true });
23
- cpSync(src, dest, { recursive: true, force: true });
24
- done.push(statSync(src).isDirectory() ? `${f}/` : f);
25
- }
26
- return done;
27
- }
28
-
29
- /** Drop package-owned files that later versions stopped shipping. */
30
- function pruneRetired(hub: string): string[] {
31
- const gone: string[] = [];
32
- for (const f of RETIRED) {
33
- const p = join(hub, f);
34
- if (!existsSync(p)) continue;
35
- rmSync(p, { recursive: true, force: true });
36
- gone.push(f);
37
- }
38
- return gone;
39
- }
40
-
41
- function stamp(hub: string, created?: string) {
42
- writeFileSync(
43
- join(hub, MARKER),
44
- JSON.stringify(
45
- { khb: version(), created: created ?? new Date().toISOString(), upgraded: new Date().toISOString() },
46
- null,
47
- 2,
48
- ) + "\n",
49
- );
50
- }
51
-
52
19
  if (upgrading) {
53
20
  const { HUB } = await import("./lib/util"); // resolves the hub, or exits with guidance
54
- // The hub may still carry a marker name from an older version; stamp() writes MARKER,
55
- // so drop the old file rather than leaving the hub with two.
56
- const found = markerIn(HUB)!;
57
- const before = JSON.parse(await Bun.file(join(HUB, found)).text());
58
- if (found !== MARKER) rmSync(join(HUB, found));
59
- const synced = syncManaged(HUB);
60
- const pruned = pruneRetired(HUB);
61
- stamp(HUB, before.created);
62
- console.log(`Upgraded ${HUB}: ${before.khb ?? before.bkr ?? "?"} -> ${version()}`);
21
+ const { from, to, synced, pruned, renamed } = upgradeHub(HUB);
22
+ console.log(`Upgraded ${HUB}: ${from ?? "?"} -> ${to}`);
63
23
  console.log(` refreshed: ${synced.join(", ")}`);
64
- if (found !== MARKER) console.log(` renamed: ${found} -> ${MARKER}`);
24
+ if (renamed) console.log(` renamed: ${renamed} -> ${MARKER}`);
65
25
  if (pruned.length) console.log(` removed (no longer part of the contract): ${pruned.join(", ")}`);
66
26
  console.log(`Your bundles/ and outer.index.md were not touched. Next: khb lint`);
67
27
  } else {
@@ -1,7 +1,7 @@
1
1
  // Package-side paths. Importing this must never require a hub to exist — `khb init`
2
2
  // runs before there is one. Hub-side paths live in util.ts.
3
3
  import { fileURLToPath } from "node:url";
4
- import { join } from "node:path";
4
+ import { join, dirname, resolve } from "node:path";
5
5
  import { readFileSync, existsSync } from "node:fs";
6
6
 
7
7
  /** Root of the installed @msareen/knowledge-hub-builder package (NOT the user's hub). */
@@ -29,6 +29,26 @@ export const LEGACY_MARKERS = ["bkr.json"];
29
29
  export const markerIn = (dir: string): string | undefined =>
30
30
  [MARKER, ...LEGACY_MARKERS].find((m) => existsSync(join(dir, m)));
31
31
 
32
+ /**
33
+ * The hub this invocation acts on, or undefined if there is none. Precedence:
34
+ * $KHB_HUB (set from --hub by cli.ts) > nearest ancestor of cwd holding a marker.
35
+ * Soft by design — util.ts turns "none" into an error with guidance, while the version
36
+ * drift check in cli.ts must stay silent for commands that run outside a hub.
37
+ */
38
+ export function findHub(): string | undefined {
39
+ const explicit = process.env.KHB_HUB;
40
+ if (explicit) {
41
+ const dir = resolve(explicit);
42
+ return markerIn(dir) ? dir : undefined;
43
+ }
44
+ for (let dir = process.cwd(); ; ) {
45
+ if (markerIn(dir)) return dir;
46
+ const up = dirname(dir);
47
+ if (up === dir) return undefined;
48
+ dir = up;
49
+ }
50
+ }
51
+
32
52
  /**
33
53
  * Package-owned files copied into every hub by `khb init` and refreshed by
34
54
  * `khb upgrade`. These are the agent contract — the hub needs its own copies so an
@@ -0,0 +1,93 @@
1
+ // The upgrade mechanism: refresh a hub's package-owned contract docs to match the
2
+ // installed khb. Lives here rather than in init.ts because two callers need it — the
3
+ // explicit `khb upgrade`, and the drift check cli.ts runs before every hub command.
4
+ //
5
+ // Nothing here may import util.ts: the drift check runs before a hub is resolved, and
6
+ // util.ts resolves one or exits.
7
+ import { cpSync, mkdirSync, writeFileSync, readFileSync, existsSync, statSync, rmSync } from "node:fs";
8
+ import { join, dirname } from "node:path";
9
+ import { PKG, MANAGED, RETIRED, MARKER, markerIn, version } from "./paths";
10
+
11
+ export type UpgradeResult = {
12
+ /** Version recorded in the hub's marker before the upgrade, if it recorded one. */
13
+ from?: string;
14
+ to: string;
15
+ synced: string[];
16
+ pruned: string[];
17
+ /** Set when a legacy marker name was renamed to khb.json. */
18
+ renamed?: string;
19
+ };
20
+
21
+ /** Read the khb version a hub was last stamped with. Undefined if it records none. */
22
+ export function hubVersion(hub: string): string | undefined {
23
+ const found = markerIn(hub);
24
+ if (!found) return undefined;
25
+ try {
26
+ const marker = JSON.parse(readFileSync(join(hub, found), "utf8"));
27
+ return marker.khb ?? marker.bkr; // bkr: the pre-rename field name
28
+ } catch {
29
+ return undefined; // unreadable marker — treat as drifted, upgrade will restamp it
30
+ }
31
+ }
32
+
33
+ /** Copy every package-owned contract file into the hub, replacing what is there. */
34
+ export function syncManaged(hub: string): string[] {
35
+ const done: string[] = [];
36
+ for (const f of MANAGED) {
37
+ const src = join(PKG, f);
38
+ if (!existsSync(src)) continue;
39
+ const dest = join(hub, f);
40
+ mkdirSync(dirname(dest), { recursive: true });
41
+ cpSync(src, dest, { recursive: true, force: true });
42
+ done.push(statSync(src).isDirectory() ? `${f}/` : f);
43
+ }
44
+ return done;
45
+ }
46
+
47
+ /** Drop package-owned files that later versions stopped shipping. */
48
+ function pruneRetired(hub: string): string[] {
49
+ const gone: string[] = [];
50
+ for (const f of RETIRED) {
51
+ const p = join(hub, f);
52
+ if (!existsSync(p)) continue;
53
+ rmSync(p, { recursive: true, force: true });
54
+ gone.push(f);
55
+ }
56
+ return gone;
57
+ }
58
+
59
+ /** Write khb.json with the installed version, preserving the hub's creation date. */
60
+ export function stamp(hub: string, created?: string) {
61
+ writeFileSync(
62
+ join(hub, MARKER),
63
+ JSON.stringify(
64
+ { khb: version(), created: created ?? new Date().toISOString(), upgraded: new Date().toISOString() },
65
+ null,
66
+ 2,
67
+ ) + "\n",
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Bring `hub`'s package-owned files up to the installed version. Touches only what the
73
+ * package owns: bundles/, outer.index.md and anything else the user wrote are untouched.
74
+ */
75
+ export function upgradeHub(hub: string): UpgradeResult {
76
+ // The hub may still carry a marker name from an older version; stamp() writes MARKER,
77
+ // so drop the old file rather than leaving the hub with two.
78
+ const found = markerIn(hub)!;
79
+ let created: string | undefined;
80
+ let from: string | undefined;
81
+ try {
82
+ const before = JSON.parse(readFileSync(join(hub, found), "utf8"));
83
+ created = before.created;
84
+ from = before.khb ?? before.bkr;
85
+ } catch {
86
+ /* unreadable marker: rewritten below with today's date */
87
+ }
88
+ if (found !== MARKER) rmSync(join(hub, found));
89
+ const synced = syncManaged(hub);
90
+ const pruned = pruneRetired(hub);
91
+ stamp(hub, created);
92
+ return { from, to: version(), synced, pruned, renamed: found === MARKER ? undefined : found };
93
+ }
@@ -1,7 +1,7 @@
1
1
  import { readdirSync, readFileSync, existsSync, statSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
- import { join, basename, dirname, resolve } from "node:path";
4
- import { MARKER, markerIn } from "./paths";
3
+ import { join, basename, resolve } from "node:path";
4
+ import { MARKER, markerIn, findHub } from "./paths";
5
5
 
6
6
  /**
7
7
  * Find the hub root — the folder holding khb.json, outer.index.md and bundles/.
@@ -10,20 +10,12 @@ import { MARKER, markerIn } from "./paths";
10
10
  * ancestor of cwd containing the marker.
11
11
  */
12
12
  function resolveHub(): string {
13
+ const found = findHub();
14
+ if (found) return found;
13
15
  const explicit = process.env.KHB_HUB;
14
16
  if (explicit) {
15
- const dir = resolve(explicit);
16
- if (!markerIn(dir)) {
17
- console.error(`Not a KHB hub (no ${MARKER}): ${dir}`);
18
- process.exit(1);
19
- }
20
- return dir;
21
- }
22
- for (let dir = process.cwd(); ; ) {
23
- if (markerIn(dir)) return dir;
24
- const up = dirname(dir);
25
- if (up === dir) break;
26
- dir = up;
17
+ console.error(`Not a KHB hub (no ${MARKER}): ${resolve(explicit)}`);
18
+ process.exit(1);
27
19
  }
28
20
  console.error(`No KHB hub found in ${process.cwd()} or any parent directory.`);
29
21
  console.error(`Create one: khb init <dir>`);
@@ -141,16 +141,22 @@ curation, not transcription.
141
141
  Extracted text is cached hub-wide by content hash at `inbox/extracted/<sha256>.md`, so the
142
142
  same file appearing in two bundles converts once.
143
143
 
144
- OCR and transcription need optional dependencies. When they are missing khb says so once and
145
- records the affected files as pending rather than failing the run:
144
+ **OCR needs no setup.** `@hyzyla/pdfium`, `sharp` and `tesseract.js` are dependencies of khb
145
+ itself, so a scanned PDF or a photographed page is read on the first run, in any hub, without
146
+ asking the user to install anything.
147
+
148
+ Transcription is the one route that can be absent: it wants a `whisper` or `faster-whisper`
149
+ executable on `PATH`.
146
150
 
147
151
  ```
148
- bun add @hyzyla/pdfium sharp tesseract.js # OCR — ~75 MB WASM, no system binary
149
152
  pip install -U openai-whisper # transcription (faster-whisper also works)
150
153
  ```
151
154
 
152
- Install them where `khb` resolves modules from for a global install that is the khb
153
- package directory, not your hub. khb prints the exact `cd && bun add …` to use.
155
+ When any extractor is unavailable khb says so once and records the affected files as pending
156
+ rather than failing the run a `log.md` row with an empty `raw`, waiting for the dependency.
157
+ If khb ever prints a `bun add` hint for the OCR packages, its own install tree is incomplete;
158
+ install them where `khb` resolves modules from — for a global install that is the khb package
159
+ directory, not your hub — and khb prints the exact `cd … && bun add …` to use.
154
160
 
155
161
  ## 4. Sources khb cannot reach
156
162
 
@@ -229,11 +235,30 @@ Still on you, not khb: a source **modified in place** keeps its `curated` value,
229
235
  concept derived from it does not re-enter the backlog even though its material changed.
230
236
  Watch for `raw/` files whose content shifted and re-catalog them deliberately.
231
237
 
232
- ## Hand off
238
+ ## Hand off — offer the catalog pass
233
239
 
234
240
  Ingest is done when the summary shows nothing unexpectedly pending. Report to the user what
235
- landed, what didn't and why, and how many rows are uncurated — then continue with the
236
- [catalog skill](../catalog/SKILL.md) to turn `raw/` into concept docs.
241
+ landed, what didn't and why, and how many `log.md` rows are now uncurated.
242
+
243
+ Then **offer to catalog, and wait for the answer.** Raw text is not yet knowledge — a bundle
244
+ left at the end of ingest has a backlog and nothing citable — so never stop silently on the
245
+ summary, and never start cataloging unasked either. Name the bundle and the size of the
246
+ backlog in the offer, so the answer is informed:
247
+
248
+ > Ingest landed 94 files in `real-estate/raw/`; 94 rows are uncurated. Shall I catalog them
249
+ > into concept docs now?
250
+
251
+ Take the answer at face value:
252
+
253
+ - **yes** → continue with the [catalog skill](../catalog/SKILL.md), on that bundle, reading
254
+ the backlog from `log.md`.
255
+ - **no, or not now** → stop. The ledger is the durable backlog, so nothing is lost; say that
256
+ the uncurated rows are waiting whenever they want to pick it up.
257
+ - **only part of it** — one folder, one document, the low-quality files first → catalog that
258
+ subset and leave the rest of the rows uncurated.
259
+
260
+ Offer once, for the bundle you just ingested. Do not offer to catalog a bundle this run did
261
+ not touch, and do not roll a "yes" onward into a second bundle's backlog.
237
262
 
238
263
  ## Hygiene
239
264