@msareen/knowledge-hub-builder 0.1.5 → 0.1.8

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.
@@ -4,6 +4,7 @@
4
4
  sources: []
5
5
  # - type: folder
6
6
  # path: /abs/path/to/some-folder
7
+ # exclude: [] # optional — skip these paths/globs before ingesting
7
8
  # - type: web
8
9
  # urls: []
9
10
  # - type: confluence
package/AGENTS.md CHANGED
@@ -141,6 +141,22 @@ From outside, pass `--hub <dir>` or set `$KHB_HUB`.
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` |
142
142
  | `khb export <bundle> [dest]` | standalone copy: bundle + common patterns, shareable alone |
143
143
 
144
+ These five work **outside** any hub, against a per-machine shortcut list at
145
+ `~/.khb/hubs-config.json` (`%USERPROFILE%\.khb` on Windows). They hold no knowledge — only
146
+ paths — and fill themselves in as hubs are used, so they need no maintenance.
147
+
148
+ | Command | Purpose |
149
+ |---|---|
150
+ | `khb list` | every hub on this machine, with its description and path |
151
+ | `khb go [name\|N]` | open a hub — prints its path, then launches your agent there. Bare `khb` is this: one hub asks, several offer a list, none walks the user through creating the first. `--path` prints only the path |
152
+ | `khb agent [name]` | which agent `khb go` launches — `claude`, `codex`, a custom `--command`, or `none` |
153
+ | `khb update [new-path]` | repair the hub: `--path`/`-p` repoints the list and rewrites every old-path reference after a move (needs no arguments — the move is detected and announced by any khb command run there); `--schema`/`-s` backfills a bundle's `sources.yaml` to the current schema (e.g. a newly added `exclude:` field). No flag runs both. `--dry-run` first. Unrelated to `khb upgrade`, which only touches package-owned contract docs, never bundle content |
154
+ | `khb forget <name>` | drop a shortcut; the hub folder is untouched |
155
+
156
+ A hub's `name` and `description` in that list come from its own `khb.json`, so they travel
157
+ with it. Set them at creation with `khb init --name --description`, or edit the marker's
158
+ `name`/`description` keys — those two are yours, unlike the rest of the file.
159
+
144
160
  There is no `khb catalog` command — cataloging is entirely a judgement pass.
145
161
 
146
162
  ## Ingest, then catalog — two steps, in that order
@@ -158,6 +174,10 @@ The single exception is a hub with nothing to choose between: with no bundles at
158
174
  unnamed ingest lands in `default`, created on the spot, and where `default` is the only
159
175
  bundle it lands there as it stands.
160
176
 
177
+ When a run finishes, **offer the catalog pass** for the bundle just ingested — report what
178
+ landed and how many rows are uncurated, then ask before curating anything. Neither stopping
179
+ silently at the summary nor cataloging unasked is right.
180
+
161
181
  **Catalog** (`skills/catalog/SKILL.md`) is the judgement half, one bundle at a time: read
162
182
  each `raw/` file, split it into concepts, give each OKF frontmatter, link them, register
163
183
  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
@@ -27,8 +27,17 @@ KHB requires [Bun](https://bun.sh).
27
27
 
28
28
  ```bash
29
29
  bun install -g @msareen/knowledge-hub-builder
30
+ khb
31
+ ```
32
+
33
+ On a machine with no hubs yet, `khb` walks you through the first one — where it lives, what
34
+ to call it, which agent opens it, and a first bundle. Every question has a default, so
35
+ holding Enter produces a working hub.
36
+
37
+ To do the same by hand:
30
38
 
31
- khb init ~/my-knowledge
39
+ ```bash
40
+ khb init ~/my-knowledge --description "Personal knowledge"
32
41
  cd ~/my-knowledge
33
42
 
34
43
  khb new-bundle personal "My accounts, plans, records, and reference material"
@@ -40,6 +49,7 @@ Add sources to `bundles/personal/sources.yaml`:
40
49
  sources:
41
50
  - type: folder
42
51
  path: /absolute/path/to/documents
52
+ exclude: [drafts/] # optional — skip paths/globs before ingesting
43
53
  - type: files
44
54
  paths:
45
55
  - /absolute/path/to/one.pdf
@@ -62,6 +72,9 @@ or ask the agent:
62
72
  The agent discovers the KHB workflow skills from the hub and performs the catalog pass
63
73
  after ingestion.
64
74
 
75
+ Later, from any terminal, `khb` on its own takes you back to the hub and starts your agent
76
+ there — see [Moving between hubs](#moving-between-hubs).
77
+
65
78
  ## How It Works
66
79
 
67
80
  ### 1. Ingest
@@ -69,20 +82,25 @@ after ingestion.
69
82
  `khb ingest <bundle>` reads `sources.yaml` and writes extracted markdown under
70
83
  `bundles/<bundle>/raw/`.
71
84
 
72
- Supported without additional system tools:
85
+ Supported out of the box, with no system tools and no further installation:
73
86
 
74
87
  - text, markdown, CSV, JSON, and YAML
75
88
  - PDF and DOCX
76
89
  - ODT, XLSX, and PPTX
90
+ - OCR for images and scanned PDFs, applied automatically when a PDF has no text layer
91
+
92
+ OCR ships with KHB: `@hyzyla/pdfium`, `sharp`, and `tesseract.js` are ordinary dependencies,
93
+ so installing KHB pulls them down whether or not you ever ingest a scan. That costs roughly
94
+ 75 MB of WASM plus `sharp`'s native binaries — the price of an ingest that never stalls
95
+ waiting for a setup step.
77
96
 
78
- Optional local tools add:
97
+ One extractor is genuinely optional:
79
98
 
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`
99
+ - audio and video transcription needs a `whisper` or `faster-whisper` executable on `PATH`
100
+ (`pip install -U openai-whisper`)
82
101
 
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.
102
+ Without it, KHB leaves a pending row in `log.md` and prints the required setup rather than
103
+ failing the run.
86
104
 
87
105
  Every raw markdown file carries provenance:
88
106
 
@@ -220,7 +238,7 @@ Commands can be run directly or requested through the matching agent skill.
220
238
 
221
239
  | Command | Purpose |
222
240
  |---|---|
223
- | `khb init [dir]` | Create a hub |
241
+ | `khb init [dir] [--name N] [--description "…"]` | Create a hub |
224
242
  | `khb upgrade` | Refresh package-owned contracts and skills (also runs automatically on version drift) |
225
243
  | `khb new-bundle <name> ["scope"]` | Create and register a bundle |
226
244
  | `khb ingest [bundle] [--force]` | Acquire and extract declared sources |
@@ -235,6 +253,84 @@ Additional ingest flags:
235
253
 
236
254
  Run against a hub outside the current directory with `--hub <dir>` or `$KHB_HUB`.
237
255
 
256
+ ### Moving between hubs
257
+
258
+ These four run **outside** any hub, from any terminal:
259
+
260
+ | Command | Purpose |
261
+ |---|---|
262
+ | `khb` | Open a hub. One registered hub asks; several show a list and take a pick; none walks you through creating the first |
263
+ | `khb list [--json]` | Every hub on this machine, with its description and path |
264
+ | `khb go <name\|N> [--path]` | Open one by name or list position. `--path` prints just the path, for `cd "$(khb go --path work)"` |
265
+ | `khb agent [name] [--command X] [--args "…"]` | Which agent `khb go` launches — `claude`, `codex`, anything on your PATH, or `none` to just print the path |
266
+ | `khb update [new-path] [--path\|-p] [--schema\|-s] [--from <old>] [--dry-run]` | Repair the hub: path references after a move, and/or backfill `sources.yaml` to the current schema. No flag runs both |
267
+ | `khb forget <name>` | Drop a hub from the list. The folder is untouched |
268
+
269
+ `khb go` prints the hub's path and then starts your agent there, so a bare `khb` from a
270
+ cold terminal ends with an agent open on the right folder. No program can change its
271
+ parent shell's directory, which is why the `cd` line is printed rather than performed.
272
+
273
+ The list lives in `~/.khb/hubs-config.json` (`%USERPROFILE%\.khb` on Windows). It holds
274
+ paths and one launch command — no knowledge — and fills itself in: any khb command run
275
+ inside a hub registers it, so hubs you already had show up without a migration step.
276
+ Delete the file and the next command in each hub puts it back.
277
+
278
+ A hub's name and description come from its own `khb.json`, so they travel with the hub
279
+ rather than living in one machine's list. Set them with `khb init --name --description`,
280
+ or edit those two keys in the marker later.
281
+
282
+ ### `khb update` — repairing a hub
283
+
284
+ Two independent repairs, run together or apart:
285
+
286
+ ```bash
287
+ khb update --dry-run # what would change, both halves
288
+ khb update # apply both
289
+ khb update --path # only repoint paths after a move
290
+ khb update --schema # only backfill sources.yaml to the current schema
291
+ ```
292
+
293
+ **`--path`/`-p`** — after you move the folder, this repoints the shortcut list (a moved hub
294
+ shows as `MISSING` in `khb list` until you do) and rewrites the absolute paths recorded
295
+ *inside* the hub that named the old location: `sources.yaml` entries, `source:` headers in
296
+ `raw/`, `log.md` rows, `resource:` front matter. It needs no old path: the hub records where
297
+ it lives in its own `khb.json` (`path`), so any khb command run in a moved hub notices and
298
+ says so —
299
+
300
+ ```
301
+ khb: this hub was at D:\kb\old and is now at D:\kb\new.
302
+ khb: repair them: khb update --path (--dry-run to preview)
303
+ ```
304
+
305
+ — and `--path` reads the old location straight out of the marker. That record travels with
306
+ the folder, so it works after `~/.khb` is deleted, on a second machine, or on a hub a
307
+ colleague handed you. Move a hub twice before repairing it and both former homes are
308
+ rewritten in one pass. For a hub last touched by a khb too old to have recorded a location,
309
+ the old path is worked out from the registry entry that now points at nothing, matched on
310
+ the identity stamp in `khb.json`. If it can't prove which entry that is, it asks; `--from
311
+ <old-path>` tells it outright.
312
+
313
+ The rewrite is a literal substitution with three guards: every spelling of the path moves
314
+ (native, forward-slashed, and JSON-escaped as `raw/` headers store it), each rewritten to
315
+ the same spelling of the new path; matches must end at a path boundary, so moving `…/old`
316
+ never touches a sibling `…/older`; and the new path is matched too and rewritten to itself,
317
+ which is what makes overlapping moves safe — lifting a hub out of its parent, or pushing it
318
+ down into a subdirectory of where it stood — and makes a second run a no-op.
319
+ `.git/`, `node_modules/` and the `inbox/` cache are skipped, as are binaries.
320
+
321
+ **`--schema`/`-s`** — a bundle's `sources.yaml` can predate a field khb's since learned about
322
+ (e.g. `exclude:`), with no way to discover it short of reading the docs. This backfills
323
+ missing optional fields with their default, per bundle, preserving your comments and
324
+ formatting. `khb upgrade` mentions when either half of `update` has something pending, as a
325
+ printed hint — it never runs `update` for you.
326
+
327
+ Both `khb update` and `khb ingest` state their whole plan before writing anything, then
328
+ report as they go — a position per file for work slow enough to matter, a live counter for
329
+ work that isn't. The counter goes to stderr, so redirected stdout stays clean.
330
+
331
+ Not to be confused with `khb upgrade`, which refreshes a hub's package-owned contract docs
332
+ and has nothing to do with moving anything.
333
+
238
334
  To update the installed package:
239
335
 
240
336
  ```bash
package/SPEC.md CHANGED
@@ -115,6 +115,8 @@ acting on them** — an agent can never read a protocol the CLI no longer implem
115
115
  ├── scripts/
116
116
  │ ├── cli.ts # subcommand dispatch; --hub flag; the version drift check
117
117
  │ ├── init.ts # khb init / khb upgrade
118
+ │ ├── hubs.ts # khb list / go / agent / update / forget — the only
119
+ │ │ # commands that run outside a hub
118
120
  │ ├── new-bundle.ts # scaffold from .bundle_template, register in outer.index.md
119
121
  │ ├── export.ts # bundle + common patterns → standalone shareable folder
120
122
  │ ├── lint.ts # enforce skills/lint/SKILL.md across the hub
@@ -124,6 +126,9 @@ acting on them** — an agent can never read a protocol the CLI no longer implem
124
126
  │ ├── extract.ts # every local extractor + the content-hash cache
125
127
  │ ├── ledger.ts # log.md read/write
126
128
  │ ├── paths.ts # package-side paths — importing it never needs a hub
129
+ │ ├── registry.ts # ~/.khb/hubs-config.json: where this machine's hubs are
130
+ │ ├── relocate.ts # khb update --path's path rewriter — pure text, no judgement
131
+ │ ├── schema.ts # khb update --schema: sources.yaml field diff/apply
127
132
  │ ├── upgrade.ts # the refresh itself: `khb upgrade` and the drift check
128
133
  │ └── util.ts # hub resolution + shared helpers
129
134
  ├── .bundle_template/ # copied by `khb new-bundle`
@@ -143,6 +148,210 @@ Rule 3 is the normal path: `cd` anywhere inside the hub and run `khb lint`. One
143
148
  consequence worth stating — a hub is identified by its marker file, not its name, so
144
149
  hubs may be renamed or moved freely, and nested hubs resolve to the innermost one.
145
150
 
151
+ ### 2d. The machine registry — `~/.khb/hubs-config.json`
152
+
153
+ Rules 1–3 all assume you already know where the hub is. From a cold terminal in an
154
+ unrelated folder, you don't — and a person with a personal hub, a work hub and a client
155
+ hub has three paths to remember. So khb keeps **one file per machine** recording where the
156
+ hubs on it are:
157
+
158
+ ```
159
+ ~/.khb/ # %USERPROFILE%\.khb on Windows; $KHB_HOME overrides
160
+ └── hubs-config.json
161
+ ```
162
+
163
+ ```json
164
+ {
165
+ "version": 1,
166
+ "defaultAgent": "claude",
167
+ "agents": { "claude": { "command": "claude", "args": [] },
168
+ "codex": { "command": "codex", "args": [] } },
169
+ "hubs": [
170
+ { "name": "my-knowledge", "description": "Personal — work + home",
171
+ "path": "D:\\code\\my-knowledge", "added": "…", "lastUsed": "…" }
172
+ ]
173
+ }
174
+ ```
175
+
176
+ Three properties define it:
177
+
178
+ - **It is a shortcut list, never knowledge.** It holds paths and one launch command.
179
+ Delete it and nothing is lost — the next command run inside each hub puts it back.
180
+ - **It fills itself in.** Every khb command that resolves a hub registers it, so hubs made
181
+ before the registry existed appear the first time anything is run in them. There is no
182
+ migration and no `register` command to remember. `khb forget <name>` drops a shortcut and
183
+ never touches the folder.
184
+ - **The hub is the authority on its own identity.** `name` and `description` are read out
185
+ of the hub's `khb.json` (`khb init --name --description`, or edit the file), so a hub
186
+ moved to another machine or cloned by a colleague describes itself the same way there.
187
+ Only when the marker says nothing does khb fall back to the folder name and a summary of
188
+ the bundles inside. `khb upgrade` merges rather than replaces the marker, so keys khb
189
+ does not own survive an upgrade.
190
+
191
+ The commands over it are `khb list`, `khb go`, `khb agent`, `khb update` and `khb forget` —
192
+ the only ones that run **outside** a hub, and therefore the only ones that skip hub
193
+ resolution and the version drift check. A bare `khb` is `khb go`: one hub asks to open it,
194
+ several show the list and take a pick, none prints the help.
195
+
196
+ `khb go` ends by launching your configured agent with the hub as its working directory.
197
+ No process can change its parent shell's directory, so `khb go` prints the `cd` line for
198
+ the human and passes the path to the agent as cwd — `khb go --path <name>` prints only the
199
+ path, for `cd "$(khb go --path work)"`. `khb agent none` turns the launch off entirely.
200
+
201
+ Every path stored in or compared against the registry is canonicalized first (`realpath`,
202
+ case-folded on Windows). One directory has several true names — `C:\Users\MANASV~1\…` and
203
+ `C:\Users\Manasvi Sareen\…` are the same folder, as is anything reached through a symlink —
204
+ and without this a single hub lists twice and `khb update --path` cannot tell it has already
205
+ been repaired.
206
+
207
+ ### 2e. `khb update` — repairing a hub
208
+
209
+ Two independent repairs, selectable together or apart:
210
+
211
+ ```
212
+ khb update [new-path] [--path|-p] [--schema|-s] [--from <old-path>] [--dry-run]
213
+ ```
214
+
215
+ No flag runs both. `--path`/`-p` repairs a moved hub; `--schema`/`-s` backfills a bundle's
216
+ `sources.yaml` to the current field set (§ scripts/lib/schema.ts). They are unrelated repairs
217
+ that happen to share a plan-then-write-then-report shape, so one command with two switches
218
+ beat two commands that would each need the same hub-resolution and dry-run plumbing.
219
+
220
+ #### `--path` — a hub that moved
221
+
222
+ Moving a hub folder breaks two things, and this half fixes both:
223
+
224
+ 1. **The shortcut list** still points at the old folder. `khb list` marks it `MISSING`.
225
+ 2. **Absolute paths recorded inside the hub** that named the old location — `sources.yaml`
226
+ entries, `source:` headers in `raw/`, `log.md` rows, `resource:` front matter — are now
227
+ dangling.
228
+
229
+ Run from inside the moved hub, or name it. Nothing else is needed, because **the hub records
230
+ its own location**. `khb.json` carries a `path` key — canonical, plus `pathAs` for the
231
+ spelling khb was invoked through when the two differ — and any khb command run in a hub
232
+ compares it against where the hub actually is. A mismatch is the move, stated by the only
233
+ witness that travelled with the folder:
234
+
235
+ ```
236
+ khb: this hub was at D:\kb\old and is now at D:\kb\new.
237
+ khb: absolute paths recorded inside it still name the old location.
238
+ khb: repair them: khb update --path (--dry-run to preview)
239
+ ```
240
+
241
+ The old location moves into `movedFrom`, where it waits until `--path` works it off.
242
+ A hub moved twice before anyone repaired it lists both former homes and all of them are
243
+ rewritten in the one pass. The key costs a string and is inert — nothing reads it but this
244
+ repair — but it is what makes the repair need no arguments, and it survives what the registry
245
+ does not: a deleted `~/.khb`, a first run on a second machine, a folder handed to a colleague.
246
+
247
+ Failing that — a hub last touched by a khb too old to have recorded a location — the old path
248
+ is *inferred, on proof only*: each registry entry records the `created` stamp from the hub's
249
+ marker, minted once at `khb init`, so the dead entry carrying this hub's stamp is provably the
250
+ same hub. A mere name match is circumstantial and is put to the user as a question rather than
251
+ acted on. `--from` settles it either way, and is still the answer when the move predates all
252
+ of this.
253
+
254
+ The rewrite itself is a **conversion, not an interpretation**, which is what keeps it in the
255
+ CLI rather than in an agent pass (§ AGENTS.md, division of labor): the same substring in
256
+ and out, no judgement about what a path means. Three properties make it safe to run
257
+ unattended:
258
+
259
+ - **Every spelling moves.** A path appears natively (`D:\a\b`), forward-slashed (`D:/a/b`),
260
+ and backslash-escaped inside JSON (`D:\\a\\b`, how `raw/` headers and `log.md` store a
261
+ source). All are matched, and each is rewritten to *the same spelling* of the new path, so
262
+ a JSON-escaped source stays JSON-escaped.
263
+ - **Matches end at a path boundary.** Moving `…/old` never touches `…/older`, a sibling
264
+ whose name merely starts the same way.
265
+ - **The new path is shielded from itself.** It is matched too, and rewritten to itself. That
266
+ is what makes an *overlapping* move safe — a hub lifted out of its parent (`…/kb/hub` →
267
+ `…/kb`) or pushed down into a subdirectory of where it stood (`…/kb` → `…/kb/hub`). In the
268
+ second case the old path occurs inside every already-correct reference, and without the
269
+ shield each would have the move applied to it a second time. Claiming those matches for an
270
+ identity rewrite also makes the whole command idempotent: run it twice and the second run
271
+ changes nothing. Only old and new naming *the same directory* is refused, there being no
272
+ move to repair.
273
+
274
+ `.git/`, `node_modules/` and the `inbox/` extraction cache are not walked; binary files and
275
+ anything over 8 MB are skipped. `--dry-run` reports the file-by-file hit count and writes
276
+ nothing.
277
+
278
+ Like `khb ingest`, it states the whole plan before writing anything and reports progress as
279
+ it walks (§2g).
280
+
281
+ #### `--schema` — a bundle's `sources.yaml` predates a field
282
+
283
+ A `sources.yaml` written before a field existed (e.g. `folder`/`files` sources gained
284
+ `exclude:`) has no way to discover it short of reading the docs. `scripts/lib/schema.ts`
285
+ holds the current, optional, backfillable field list per source `type`; this half diffs
286
+ every bundle's `sources.yaml` against it and, for anything missing, stages a default value —
287
+ or, for a field the schema has since deprecated, stages its removal. Comment-preserving:
288
+ it edits the parsed YAML document node-by-node (the `yaml` package's `Document` API) rather
289
+ than reserializing from scratch, so hand-written comments and structure in an untouched part
290
+ of the file survive. The one caveat is cosmetic, not correctness: re-serializing the whole
291
+ document can normalize whitespace in *other*, unrelated flow-style content in the same file.
292
+
293
+ There is no persisted schema version anywhere — the "schema" is just this file's current
294
+ shape, diffed fresh on every run, the same way `khb upgrade`'s `MANAGED`/`RETIRED` lists are
295
+ static and re-checked rather than tracked historically. `--dry-run` prints the changes and
296
+ writes nothing; otherwise it applies immediately, same convention as `--path`.
297
+
298
+ `khb upgrade` prints a one-line hint when either half of `update` has something pending —
299
+ never a prompt, since `upgrade` already runs unattended inside unrelated commands on version
300
+ drift, and a blocking question there would interrupt work that has nothing to do with either
301
+ repair.
302
+
303
+ The name says what it does, now that it does two things belonging to neither `khb upgrade`
304
+ (package-owned contract docs only) nor to a single-purpose `update-path`. The original
305
+ one-letter-from-`upgrade` objection to the shorter name held while `update` meant only path
306
+ repair; a second, unrelated repair under the same verb resolves it — `update` repairs what
307
+ the user's own bundles record, `upgrade` refreshes what the package owns, and the two no
308
+ longer read as near-synonyms.
309
+
310
+ ### 2f. First run — the wizard
311
+
312
+ A bare `khb` on a machine with no hubs has a terminal in front of it and knows the one
313
+ thing the user needs to hear, so it asks rather than referring them to the docs. Five
314
+ questions, every one with a default that Enter accepts: where the hub goes, what to call
315
+ it, a one-line description, which agent opens it, and a first bundle.
316
+
317
+ Three things keep it honest:
318
+
319
+ - **It asks only what `khb init` takes as flags**, and calls the same `createHub()`. A hub
320
+ born in the wizard is byte-identical to one made by hand — there is no second creation
321
+ path to drift.
322
+ - **Agents are detected, not guessed.** `claude` and `codex` are probed with `--version`
323
+ and the ones present are marked; the first found is the default. Any other command can be
324
+ typed instead, or `none`.
325
+ - **A path that is already a hub is adopted, not overwritten.** The machine simply had not
326
+ heard of it, so it joins the list under its own name and the wizard stops there.
327
+
328
+ No terminal, or `--path` (a script asking for a path), falls back to three lines of
329
+ guidance. `khb list` on an empty machine likewise reports rather than starts a
330
+ conversation.
331
+
332
+ ### 2g. Progress on the mechanical passes
333
+
334
+ `khb`'s half of the split is the deterministic half, and some of it is slow: a scanned PDF
335
+ is seconds per page, a video minutes per file, and a hub that has accumulated thousands of
336
+ raw documents takes a visible moment to walk. A silent process is indistinguishable from a
337
+ hung one, so every mechanical pass reports (`scripts/lib/log.ts`):
338
+
339
+ - **The plan, before any work.** Which hub, which bundle, which extractors are armed, how
340
+ many sources — or for `update --path`, the old path, the new path, and whether anything
341
+ will be written; for `update --schema`, which bundles and fields. A `--hub`/`$KHB_HUB`
342
+ run can target a folder you did not expect, and that must be visible before the first
343
+ write, not after.
344
+ - **Position within the run.** `[3/57]` per unit where a unit is slow enough to deserve its
345
+ own line (ingest's per-file extraction), and a single rewritten counter line where the
346
+ units are fast and numerous (`update --path` checking files).
347
+ - **An outcome per unit, and a closing summary** with wall-clock time.
348
+
349
+ The transient counter writes to **stderr** and repaints at ~12fps; with no terminal it
350
+ degrades to a milestone line every 500 units. So `khb … > out.txt` keeps clean, complete
351
+ stdout, and a CI log still shows the walk moving. There is no `--quiet`: the per-unit line
352
+ is the audit trail for a pass that rewrites files, and a run you have to repeat to find out
353
+ what it did is worse than a noisy one.
354
+
146
355
  ## 3. Routing model
147
356
 
148
357
  - `outer.index.md` lists every bundle with a one-line scope and "route here when" hints.
@@ -195,6 +404,7 @@ Each bundle declares its sources in `sources.yaml`:
195
404
  sources:
196
405
  - type: folder # local disk
197
406
  path: /abs/path/to/project-x
407
+ exclude: [drafts/] # optional — skip paths/globs before ingesting
198
408
  - type: web
199
409
  urls:
200
410
  - https://example.com/design-doc
@@ -282,11 +492,15 @@ puts them on the CLI side of the §Division-of-labor line.
282
492
  | DOCX | `mammoth`, `pandoc` if present | bundled | high |
283
493
  | ODT, PPTX | `fflate` + XML | bundled | high |
284
494
  | XLSX | `fflate` → one markdown table per sheet | bundled | high |
285
- | scanned PDF | `pdfium` + `tesseract.js` (WASM) | opt-in, ~75 MB | low |
286
- | Images (png/jpg/webp/tif) | `tesseract.js` | opt-in, ~75 MB | low |
495
+ | scanned PDF | `pdfium` + `tesseract.js` (WASM) | bundled, ~75 MB | low |
496
+ | Images (png/jpg/webp/tif) | `tesseract.js` | bundled, ~75 MB | low |
287
497
  | Audio, video | `whisper` / `faster-whisper` | opt-in, pip | low |
288
498
 
289
- Missing optional deps degrade to a ledger row with an empty `raw` and a printed install
499
+ The OCR stack is bundled rather than opt-in: an ingest that stops to ask for an install is
500
+ worse than an install that carries WASM nobody uses. Transcription stays opt-in because it is
501
+ a Python executable, not something a JS package manager can pull down.
502
+
503
+ A missing dep degrades to a ledger row with an empty `raw` and a printed install
290
504
  hint — never to a failed run. `quality: low` output is a standing invitation for the catalog
291
505
  pass to re-read the original: a vision read of a chart or a scanned table recovers what OCR
292
506
  drops, and rewrites the raw file with `extract_tool: claude-vision`.
package/document/faq.md CHANGED
@@ -90,6 +90,7 @@ For example:
90
90
  sources:
91
91
  - type: folder
92
92
  path: /absolute/path/to/documents
93
+ exclude: [drafts/] # optional — skip paths/globs before ingesting
93
94
  - type: files
94
95
  paths:
95
96
  - /absolute/path/to/one.pdf
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msareen/knowledge-hub-builder",
3
- "version": "0.1.5",
3
+ "version": "0.1.8",
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
@@ -3,16 +3,39 @@
3
3
  // so nothing that resolves a hub may be imported at module scope.
4
4
  import { version, findHub, markerIn, MARKER } from "./lib/paths";
5
5
 
6
- const COMMANDS: Record<string, { load: () => Promise<unknown>; help: string }> = {
7
- init: { load: () => import("./init"), help: "khb init [dir] create a hub here (or in dir)" },
8
- upgrade: { load: () => import("./init"), help: "khb upgrade refresh this hub's contract docs" },
9
- "new-bundle": { load: () => import("./new-bundle"), help: 'khb new-bundle <name> ["scope"] scaffold a bundle + register it' },
10
- ingest: { load: () => import("./ingest/index"), help: "khb ingest <bundle> [--force] acquire + extract declared sources → raw/ (name required once the hub has a bundle other than 'default')" },
11
- lint: { load: () => import("./lint"), help: "khb lint validate the hub against skills/lint/SKILL.md" },
12
- visualize: { load: () => import("./visualize"), help: "khb visualize [--port N] [--no-open] serve the live bundle graph in your browser; aliases: vis, viz" },
13
- export: { load: () => import("./export"), help: "khb export <bundle> [dest] standalone copy of one bundle" },
6
+ const COMMANDS: Record<string, { load: () => Promise<unknown>; usage: string; desc: string }> = {
7
+ init: { load: () => import("./init"), usage: 'khb init [dir] [--name N] [--description "…"]', desc: "create a hub here (or in dir)" },
8
+ upgrade: { load: () => import("./init"), usage: "khb upgrade", desc: "refresh this hub's contract docs" },
9
+ "new-bundle": { load: () => import("./new-bundle"), usage: 'khb new-bundle <name> ["scope"]', desc: "scaffold a bundle + register it" },
10
+ ingest: {
11
+ load: () => import("./ingest/index"),
12
+ usage: "khb ingest <bundle> [--force]",
13
+ desc: "acquire + extract declared sources → raw/ (name required once the hub has a bundle other than 'default')",
14
+ },
15
+ lint: { load: () => import("./lint"), usage: "khb lint", desc: "validate the hub against skills/lint/SKILL.md" },
16
+ visualize: {
17
+ load: () => import("./visualize"),
18
+ usage: "khb visualize [--port N] [--no-open]",
19
+ desc: "serve the live bundle graph in your browser; aliases: vis, viz",
20
+ },
21
+ export: { load: () => import("./export"), usage: "khb export <bundle> [dest]", desc: "standalone copy of one bundle" },
22
+ list: { load: () => import("./hubs"), usage: "khb list [--json]", desc: "every hub on this machine" },
23
+ go: { load: () => import("./hubs"), usage: "khb go [name|N] [--path]", desc: "open a hub with your agent (bare 'khb' picks one)" },
24
+ agent: { load: () => import("./hubs"), usage: "khb agent [name] [--command X]", desc: "which agent 'khb go' launches" },
25
+ update: {
26
+ load: () => import("./hubs"),
27
+ usage: "khb update [new] [--path|-p] [--schema|-s] [--dry-run]",
28
+ desc: "repair a moved hub's paths and/or backfill sources.yaml to the current schema",
29
+ },
30
+ forget: { load: () => import("./hubs"), usage: "khb forget <name>", desc: "drop a hub from the list (folder untouched)" },
14
31
  };
15
32
 
33
+ /**
34
+ * Commands that work *outside* a hub, against ~/.khb/hubs-config.json. They must not
35
+ * resolve or upgrade a hub — their whole job is running before you are in one.
36
+ */
37
+ const REGISTRY_COMMANDS = new Set(["list", "go", "agent", "forget", "update"]);
38
+
16
39
  // Short forms that just resolve to a canonical command above — kept out of COMMANDS
17
40
  // itself so help text lists each command once. `-v` is taken by --version, so
18
41
  // `visualize` gets word-shaped aliases instead of a letter one.
@@ -34,15 +57,39 @@ if (hubAt >= 0) {
34
57
  }
35
58
 
36
59
  const cmd0 = argv.shift();
37
- const cmd = cmd0 && ALIASES[cmd0] ? ALIASES[cmd0] : cmd0;
60
+ // Bare `khb` is the way in from a cold terminal: pick a hub and open it. Help stays one
61
+ // word away, and is what you get when no hub has ever been registered.
62
+ const cmd = !cmd0 ? "go" : (ALIASES[cmd0] ?? cmd0);
63
+
64
+ if (cmd === "help" || cmd === "--help" || cmd === "-h") {
65
+ const width = Math.max(...Object.values(COMMANDS).map((c) => c.usage.length));
66
+ const printSection = (title: string, names: string[]) => {
67
+ console.log(title);
68
+ for (const name of names) {
69
+ const c = COMMANDS[name];
70
+ console.log(` ${c.usage.padEnd(width)} ${c.desc}`);
71
+ }
72
+ };
38
73
 
39
- if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
40
- console.log(`khb ${version()} — Knowledge Hub Builder\n`);
74
+ console.log(`khb ${version()} Knowledge Hub Builder`);
75
+ console.log();
41
76
  console.log(`khb is the supporting tool: it handles deterministic extraction, file plumbing,`);
42
77
  console.log(`validation, and export. Your AI agent — Claude, Codex, Gemini, or another`);
43
- console.log(`compatible agent — follows the workflow skills and orchestrates the knowledge work.\n`);
44
- for (const c of Object.values(COMMANDS)) console.log(" " + c.help);
45
- console.log(`\nGlobal: --hub <dir> operate on that hub instead of searching upward from cwd`);
78
+ console.log(`compatible agent — follows the workflow skills and orchestrates the knowledge work.`);
79
+ console.log();
80
+
81
+ printSection(
82
+ "In a hub:",
83
+ Object.keys(COMMANDS).filter((name) => !REGISTRY_COMMANDS.has(name)),
84
+ );
85
+ console.log();
86
+ printSection(
87
+ "Anywhere on this machine (no hub needed) — config and maintenance:",
88
+ Object.keys(COMMANDS).filter((name) => REGISTRY_COMMANDS.has(name)),
89
+ );
90
+ console.log();
91
+
92
+ console.log(`Global: --hub <dir> operate on that hub instead of searching upward from cwd`);
46
93
  console.log(`Docs: https://github.com/msareen/knowledge-hub-builder`);
47
94
  process.exit(0);
48
95
  }
@@ -63,11 +110,31 @@ if (!entry) {
63
110
  // stamped at an older version than the installed khb is stating an older contract than
64
111
  // the one the CLI now implements. Rather than let the two disagree, refresh the hub in
65
112
  // 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) {
113
+ // `init` has no hub yet, `upgrade` does this itself, the registry commands run outside
114
+ // any hub, and $KHB_NO_AUTO_UPGRADE opts out.
115
+ if (cmd !== "init" && cmd !== "upgrade" && !REGISTRY_COMMANDS.has(cmd)) {
68
116
  const hub = findHub();
69
117
  if (hub) {
70
- const { hubVersion, upgradeHub } = await import("./lib/upgrade");
118
+ // Any command run in a hub is proof the hub exists and is in use — record it, so the
119
+ // machine registry fills itself in without a migration or a `khb register` to recall.
120
+ const { registerHub, touchHub } = await import("./lib/registry");
121
+ registerHub(hub);
122
+ touchHub(hub);
123
+
124
+ // …and proof of where it is. The hub records its own location in its marker, so a move
125
+ // is noticed by the hub itself rather than inferred from a registry that may have been
126
+ // deleted or never have seen this machine. Must run before the drift check below:
127
+ // that restamps the marker, and would overwrite the old location before anyone read it.
128
+ const { recordLocation } = await import("./lib/upgrade");
129
+ const { moved } = recordLocation(hub);
130
+ if (moved) {
131
+ console.error(`khb: this hub was at ${moved} and is now at ${hub}.`);
132
+ console.error(`khb: absolute paths recorded inside it still name the old location.`);
133
+ console.error(`khb: repair them: khb update --path (--dry-run to preview)`);
134
+ }
135
+ }
136
+ if (hub && !process.env.KHB_NO_AUTO_UPGRADE) {
137
+ const { hubVersion, upgradeHub, updateHint } = await import("./lib/upgrade");
71
138
  // A marker under a pre-rename name is drift too, even at a matching version.
72
139
  if (hubVersion(hub) !== version() || markerIn(hub) !== MARKER) {
73
140
  const { from, to, pruned, renamed } = upgradeHub(hub);
@@ -77,6 +144,8 @@ if (cmd !== "init" && cmd !== "upgrade" && !process.env.KHB_NO_AUTO_UPGRADE) {
77
144
  );
78
145
  if (renamed) console.error(`khb: renamed ${renamed} -> khb.json`);
79
146
  if (pruned.length) console.error(`khb: removed (no longer part of the contract): ${pruned.join(", ")}`);
147
+ const hint = updateHint(hub);
148
+ if (hint) console.error(hint);
80
149
  }
81
150
  }
82
151
  }