@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.
@@ -0,0 +1,165 @@
1
+ // Mechanical path rewriting for `khb update --path`: after a hub is moved, replace every literal
2
+ // reference to its old location with the new one.
3
+ //
4
+ // Strictly a conversion, not an interpretation — the same substring, in and out, with no
5
+ // judgement about what a path means. That is what keeps it in the CLI: the alternative is
6
+ // an agent reading each file and deciding, which for a byte-identical prefix swap is both
7
+ // slower and less reliable than a regex. See AGENTS.md, "Division of labor".
8
+ import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
9
+ import { join, relative } from "node:path";
10
+ import { ticker } from "./log";
11
+
12
+ /** Directories never walked: VCS internals, installed packages, regenerable caches. */
13
+ const SKIP_DIRS = new Set([".git", "node_modules", "inbox"]);
14
+
15
+ /** A file bigger than this is a corpus artefact, not something holding a path reference. */
16
+ const MAX_BYTES = 8 * 1024 * 1024;
17
+
18
+ export type Hit = { file: string; count: number };
19
+ export type RewriteResult = {
20
+ scanned: number;
21
+ hits: Hit[];
22
+ /** Files that matched but could not be written — read-only, locked, gone. */
23
+ failed: { file: string; reason: string }[];
24
+ };
25
+
26
+ function* walk(dir: string, root = dir): Generator<string> {
27
+ let entries;
28
+ try {
29
+ entries = readdirSync(dir, { withFileTypes: true });
30
+ } catch {
31
+ return; // unreadable directory: nothing to rewrite in what we cannot open
32
+ }
33
+ for (const e of entries) {
34
+ const p = join(dir, e.name);
35
+ if (e.isDirectory()) {
36
+ if (!SKIP_DIRS.has(e.name)) yield* walk(p, root);
37
+ } else if (e.isFile()) {
38
+ yield p;
39
+ }
40
+ }
41
+ }
42
+
43
+ /** Heuristic, and the standard one: a NUL byte in the first block means not text. */
44
+ function readText(path: string): string | undefined {
45
+ try {
46
+ if (statSync(path).size > MAX_BYTES) return undefined;
47
+ const buf = readFileSync(path);
48
+ if (buf.subarray(0, 8192).includes(0)) return undefined;
49
+ return buf.toString("utf8");
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ }
54
+
55
+ const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
56
+
57
+ /**
58
+ * The spellings one path takes in a hub's files, in a fixed order. A Windows path appears
59
+ * natively (`D:\a\b`), with forward slashes (`D:/a/b`, common in YAML people typed by
60
+ * hand), and backslash-escaped inside JSON — which is exactly how `raw/` provenance
61
+ * headers and `log.md` store a source. All are the same location and all must move.
62
+ * The order is what lets a matched spelling be mapped back to the same spelling of the
63
+ * new path, so a JSON-escaped source stays JSON-escaped after the rewrite.
64
+ */
65
+ function spellings(path: string): string[] {
66
+ const back = path.replace(/\//g, "\\");
67
+ return [back.replace(/\\/g, "\\\\"), back, path.replace(/\\/g, "/")];
68
+ }
69
+
70
+ /**
71
+ * Pair every spelling of every old path with the matching spelling of the new one.
72
+ *
73
+ * Several old paths, not one, because the same directory has more than one true name:
74
+ * a hub registered as `C:\Users\MANASV~1\…` and later canonicalized to
75
+ * `C:\Users\Manasvi Sareen\…` has both strings sitting in files written at different
76
+ * times, and a repair that fixed only the canonical one would leave the rest dangling.
77
+ */
78
+ function pairs(froms: string[], to: string): { find: string; replace: string }[] {
79
+ const out = new Map<string, string>();
80
+ const news = spellings(to);
81
+ for (const from of froms) {
82
+ spellings(from).forEach((form, i) => {
83
+ if (form && !out.has(form)) out.set(form, news[i]);
84
+ });
85
+ }
86
+ // The new path also maps to itself. Two cases need it, and both are ordinary moves:
87
+ // a hub lifted out of its parent (`…/kb/hub` → `…/kb`) and one pushed down into a
88
+ // subdirectory of where it stood (`…/kb` → `…/kb/hub`). The paths then share a prefix,
89
+ // and in the second case the old path matches *inside* every reference that is already
90
+ // correct — prepending the move a second time. Claiming those matches for an identity
91
+ // rewrite is what makes the overlap safe, and makes any re-run a no-op.
92
+ for (const form of news) if (form && !out.has(form)) out.set(form, form);
93
+ // Longest first, so a `\\`-escaped form is consumed whole rather than partly matched by
94
+ // a shorter spelling of the same path — and so the identity above wins wherever it and
95
+ // an old path could both match, alternation being ordered.
96
+ return [...out].map(([find, replace]) => ({ find, replace })).sort((a, b) => b.find.length - a.find.length);
97
+ }
98
+
99
+ /**
100
+ * Replace every reference to any of `froms` with `to` in the text files under `root`.
101
+ * `dryRun` reports what would change without touching anything.
102
+ */
103
+ export function rewritePaths(
104
+ root: string,
105
+ froms: string[],
106
+ to: string,
107
+ opts: { dryRun?: boolean; onStart?: (files: number) => void } = {},
108
+ ): RewriteResult {
109
+ const table = pairs(froms, to);
110
+ const ci = process.platform === "win32";
111
+ const key = (s: string) => (ci ? s.toLowerCase() : s);
112
+ const lookup = new Map(table.map((p) => [key(p.find), p.replace]));
113
+ // Only where the match ends at a path boundary: without the lookahead, moving `…/old`
114
+ // would also rewrite `…/older`, a sibling whose name merely starts the same way.
115
+ const re = new RegExp(
116
+ `(?:${table.map((p) => escapeRe(p.find)).join("|")})(?=[\\\\/"'\\s,;:)\\]}]|$)`,
117
+ ci ? "gi" : "g",
118
+ );
119
+ const hits: Hit[] = [];
120
+ const failed: { file: string; reason: string }[] = [];
121
+ let scanned = 0;
122
+
123
+ // Enumerate before reading, so the counter can say "of how many". The walk is directory
124
+ // entries only — cheap next to opening every file, and worth it for a hub whose raw/ has
125
+ // grown to thousands of documents and would otherwise sit silent.
126
+ const files = [...walk(root)];
127
+ opts.onStart?.(files.length);
128
+ const progress = ticker("checking", files.length);
129
+
130
+ for (const file of files) {
131
+ progress.tick(hits.length ? `${hits.length} file(s) with references` : "");
132
+ const text = readText(file);
133
+ if (text === undefined) continue;
134
+ scanned++;
135
+ let count = 0;
136
+ const next = text.replace(re, (m) => {
137
+ const to = lookup.get(key(m));
138
+ if (to === undefined) return m; // not one of ours; leave the text exactly as found
139
+ if (key(to) === key(m)) return m; // already the new path — matched only to shield it
140
+ count++;
141
+ return to;
142
+ });
143
+ if (!count) continue;
144
+ hits.push({ file: relative(root, file) || file, count });
145
+ if (opts.dryRun) continue;
146
+ try {
147
+ writeFileSync(file, next);
148
+ } catch (e) {
149
+ failed.push({ file: relative(root, file) || file, reason: (e as Error).message });
150
+ }
151
+ }
152
+ progress.done();
153
+ return { scanned, hits, failed };
154
+ }
155
+
156
+ /**
157
+ * True when the two spellings name one directory — the only case `khb update --path`
158
+ * refuses, since there is then no move to repair. Overlapping-but-different paths used to
159
+ * be refused alongside it; `pairs()` now shields the new path from being matched inside
160
+ * itself, which is what made the overlap safe to rewrite.
161
+ */
162
+ export function sameLocation(from: string, to: string): boolean {
163
+ const norm = (p: string) => (process.platform === "win32" ? p.toLowerCase() : p).replace(/[\\/]+$/, "");
164
+ return norm(from) === norm(to);
165
+ }
@@ -0,0 +1,75 @@
1
+ // The current shape of bundles/<b>/sources.yaml, and a comment-preserving diff/apply against
2
+ // it. Not a persisted schema-version: the "schema" is just this file's current field lists,
3
+ // checked fresh every run — same as MANAGED/RETIRED in paths.ts are static lists re-checked on
4
+ // every upgrade rather than tracked historically. Deliberately excludes util.ts, so it can be
5
+ // imported from lib/upgrade.ts, which must resolve before a hub is.
6
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { parseDocument, isMap, isSeq, YAMLMap } from "yaml";
9
+
10
+ export type FieldSchema = { key: string; default: unknown } | { key: string; deprecated: true };
11
+
12
+ /**
13
+ * Optional, backfillable fields per source `type`. Required fields (`path`, `paths`, `urls`)
14
+ * are not listed here — a missing one is already a hard validation error in
15
+ * scripts/ingest/index.ts, not something to silently default.
16
+ */
17
+ export const SOURCES_SCHEMA: Record<string, FieldSchema[]> = {
18
+ folder: [{ key: "exclude", default: [] }],
19
+ files: [{ key: "exclude", default: [] }],
20
+ web: [],
21
+ };
22
+
23
+ export type SourcesDiff = { path: string; doc: ReturnType<typeof parseDocument>; changes: string[] };
24
+
25
+ /** Every bundle directory under a hub, without going through util.ts's HUB singleton. */
26
+ export function listBundleDirs(hub: string): string[] {
27
+ const dir = join(hub, "bundles");
28
+ if (!existsSync(dir)) return [];
29
+ return readdirSync(dir)
30
+ .map((d) => join(dir, d))
31
+ .filter((d) => statSync(d).isDirectory());
32
+ }
33
+
34
+ /** Diff one bundle's sources.yaml against SOURCES_SCHEMA. Null if there's nothing to change. */
35
+ export function diffSourcesYaml(bundleDir: string): SourcesDiff | null {
36
+ const path = join(bundleDir, "sources.yaml");
37
+ if (!existsSync(path)) return null;
38
+ const doc = parseDocument(readFileSync(path, "utf8"));
39
+ const sources = doc.get("sources");
40
+ if (!isSeq(sources)) return null;
41
+
42
+ const changes: string[] = [];
43
+ sources.items.forEach((item, i) => {
44
+ if (!isMap(item)) return;
45
+ const type = item.get("type");
46
+ if (typeof type !== "string") return;
47
+ const fields = SOURCES_SCHEMA[type];
48
+ if (!fields) return;
49
+ for (const field of fields) {
50
+ if ("deprecated" in field) {
51
+ if (item.has(field.key)) {
52
+ item.delete(field.key);
53
+ changes.push(`sources[${i}] (${type}): remove deprecated '${field.key}'`);
54
+ }
55
+ } else if (!item.has(field.key)) {
56
+ (item as YAMLMap).set(field.key, field.default);
57
+ changes.push(`sources[${i}] (${type}): add '${field.key}: ${JSON.stringify(field.default)}'`);
58
+ }
59
+ }
60
+ });
61
+
62
+ return changes.length ? { path, doc, changes } : null;
63
+ }
64
+
65
+ /** Diff every bundle in a hub. */
66
+ export function diffSourcesYamlAll(hub: string): SourcesDiff[] {
67
+ return listBundleDirs(hub)
68
+ .map(diffSourcesYaml)
69
+ .filter((d): d is SourcesDiff => d !== null);
70
+ }
71
+
72
+ /** Write a diff's staged changes back to disk. */
73
+ export function applySourcesDiff(diff: SourcesDiff): void {
74
+ writeFileSync(diff.path, diff.doc.toString());
75
+ }
@@ -5,8 +5,10 @@
5
5
  // Nothing here may import util.ts: the drift check runs before a hub is resolved, and
6
6
  // util.ts resolves one or exits.
7
7
  import { cpSync, mkdirSync, writeFileSync, readFileSync, existsSync, statSync, rmSync } from "node:fs";
8
- import { join, dirname } from "node:path";
8
+ import { join, dirname, resolve } from "node:path";
9
9
  import { PKG, MANAGED, RETIRED, MARKER, markerIn, version } from "./paths";
10
+ import { canonical } from "./registry";
11
+ import { diffSourcesYamlAll } from "./schema";
10
12
 
11
13
  export type UpgradeResult = {
12
14
  /** Version recorded in the hub's marker before the upgrade, if it recorded one. */
@@ -56,16 +58,159 @@ function pruneRetired(hub: string): string[] {
56
58
  return gone;
57
59
  }
58
60
 
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
- );
61
+ /** Whatever is in the hub's marker, or an empty object if it has none or it is broken. */
62
+ export function readMarker(hub: string): Record<string, unknown> {
63
+ try {
64
+ const found = markerIn(hub);
65
+ if (!found) return {};
66
+ const j = JSON.parse(readFileSync(join(hub, found), "utf8"));
67
+ return j && typeof j === "object" ? j : {};
68
+ } catch {
69
+ return {};
70
+ }
71
+ }
72
+
73
+ /** `movedFrom` reads as one path or several — normalise both to a list. */
74
+ const asList = (v: unknown): string[] =>
75
+ typeof v === "string" ? [v] : Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : [];
76
+
77
+ /**
78
+ * Where the hub is, in the two spellings that matter. `path` is canonical, so it is
79
+ * comparable; `pathAs` is the spelling khb was actually invoked through, kept only when it
80
+ * differs — `C:\Users\MANASV~1\…` for `C:\Users\Manasvi Sareen\…`, or a symlink standing in
81
+ * for its target. That second one is not decoration: files written during a run hold the
82
+ * spelling of *that* run, so a hub only ever reached by its short name has a `raw/` full of
83
+ * short-name sources, and a repair that knew only the canonical form would miss every one.
84
+ */
85
+ function location(hub: string): { path: string; pathAs?: string } {
86
+ const path = canonical(hub);
87
+ const pathAs = resolve(hub);
88
+ return pathAs === path ? { path } : { path, pathAs };
89
+ }
90
+
91
+ /**
92
+ * Write khb.json with the installed version. khb owns six keys — `khb`, `created`,
93
+ * `upgraded`, `path`, `pathAs`, `movedFrom` — and everything else in the marker belongs to
94
+ * whoever put it there: the hub's `name` and `description`, or a note the user added. Merge
95
+ * rather than replace, or an upgrade would silently eat what it does not recognise.
96
+ */
97
+ export function stamp(hub: string, created?: string, extra: Record<string, unknown> = {}) {
98
+ const path = join(hub, MARKER);
99
+ const existing = readMarker(hub);
100
+ const merged: Record<string, unknown> = {
101
+ ...existing,
102
+ ...extra,
103
+ khb: version(),
104
+ created: created ?? (existing.created as string) ?? new Date().toISOString(),
105
+ upgraded: new Date().toISOString(),
106
+ ...location(hub),
107
+ };
108
+ if (!location(hub).pathAs) delete merged.pathAs;
109
+ delete merged.bkr; // pre-rename version field; `khb` replaces it
110
+ writeFileSync(path, JSON.stringify(merged, null, 2) + "\n");
111
+ }
112
+
113
+ export type Located = {
114
+ /** Where the marker said the hub was, when that is no longer where it is. */
115
+ moved?: string;
116
+ /** Every stale location recorded and not yet repaired, oldest first. */
117
+ movedFrom: string[];
118
+ };
119
+
120
+ /**
121
+ * Keep the hub's own record of where it lives current, and notice when it changed.
122
+ *
123
+ * The marker is the one thing that travels *with* the folder, so it is the only place a
124
+ * move can be detected without being told: the machine registry knows a hub went missing
125
+ * but not which live hub it became, and after `~/.khb` is deleted or the folder is opened
126
+ * on another machine it knows nothing at all. A `path` key costs one string and turns
127
+ * "khb update --path --from <the path you must now remember>" into a command with no
128
+ * arguments.
129
+ *
130
+ * Stale locations accumulate in `movedFrom` rather than replacing each other: a hub moved
131
+ * twice before anyone repaired it has references to both former homes, and `update --path`
132
+ * rewrites the whole list in one pass. `khb update --path` clears it — see `clearMoved`.
133
+ *
134
+ * Writes only when something actually changed, so the common case leaves khb.json — and
135
+ * anyone's git status — untouched.
136
+ */
137
+ export function recordLocation(hub: string): Located {
138
+ const now = location(hub);
139
+ const marker = readMarker(hub);
140
+ const was = typeof marker.path === "string" ? marker.path : undefined;
141
+ const movedFrom = asList(marker.movedFrom);
142
+
143
+ if (was === now.path) {
144
+ // Same place. Only one thing can still be missing: the alias this run came in through,
145
+ // when the hub was last located by its canonical name and is now being reached by a
146
+ // short name or a symlink. Filled in once, never replaced — replacing it would rewrite
147
+ // khb.json on every alternation between two equally valid spellings.
148
+ if (now.pathAs && typeof marker.pathAs !== "string") {
149
+ try {
150
+ writeFileSync(
151
+ join(hub, markerIn(hub) ?? MARKER),
152
+ JSON.stringify({ ...marker, ...now }, null, 2) + "\n",
153
+ );
154
+ } catch {
155
+ /* read-only hub */
156
+ }
157
+ }
158
+ return { movedFrom };
159
+ }
160
+ const moved = was;
161
+ // The alias first, the canonical form last: `update --path` reports the final entry as the
162
+ // move, and one directory's two names are better reported under the comparable one.
163
+ for (const p of [marker.pathAs, was])
164
+ if (typeof p === "string" && p && !movedFrom.includes(p)) movedFrom.push(p);
165
+
166
+ const next: Record<string, unknown> = { ...marker, ...now };
167
+ if (!now.pathAs) delete next.pathAs;
168
+ if (movedFrom.length) next.movedFrom = movedFrom;
169
+ try {
170
+ // Into the marker as it is *named* here, not MARKER: a hub still carrying a pre-rename
171
+ // filename is about to be renamed by the drift check, and writing the new name first
172
+ // would leave it holding two markers.
173
+ writeFileSync(join(hub, markerIn(hub) ?? MARKER), JSON.stringify(next, null, 2) + "\n");
174
+ } catch {
175
+ /* read-only hub: the location is a convenience, never a precondition for the command */
176
+ }
177
+ return { moved, movedFrom };
178
+ }
179
+
180
+ /**
181
+ * Every location this hub has recorded that is no longer where it stands, oldest first and
182
+ * ending with the most recent one — which is the move to report.
183
+ *
184
+ * Reads `path`/`pathAs` as well as the `movedFrom` backlog, because `khb update --path` runs
185
+ * outside a hub and therefore outside the drift check that calls `recordLocation`: repairing
186
+ * a move directly, with no khb command run in between, must work exactly as well.
187
+ */
188
+ export function staleLocations(hub: string): string[] {
189
+ const marker = readMarker(hub);
190
+ const key = (s: string) => (process.platform === "win32" ? s.toLowerCase() : s);
191
+ const mine = new Set([key(canonical(hub)), key(resolve(hub))]);
192
+ const seen = new Set<string>();
193
+ const out: string[] = [];
194
+ for (const p of [...asList(marker.movedFrom), marker.pathAs, marker.path]) {
195
+ if (typeof p !== "string" || !p) continue;
196
+ // canonical() of a folder that no longer exists is just its resolved form, which is
197
+ // what makes a former home comparable at all.
198
+ if (mine.has(key(canonical(p))) || mine.has(key(p)) || seen.has(key(p))) continue;
199
+ seen.add(key(p));
200
+ out.push(p);
201
+ }
202
+ return out;
203
+ }
204
+
205
+ /** The move is repaired: drop the backlog, keep `path` pointing where the hub now is. */
206
+ export function clearMoved(hub: string): void {
207
+ const marker = readMarker(hub);
208
+ const now = location(hub);
209
+ if (!("movedFrom" in marker) && marker.path === now.path && marker.pathAs === now.pathAs) return;
210
+ delete marker.movedFrom;
211
+ Object.assign(marker, now);
212
+ if (!now.pathAs) delete marker.pathAs;
213
+ writeFileSync(join(hub, markerIn(hub) ?? MARKER), JSON.stringify(marker, null, 2) + "\n");
69
214
  }
70
215
 
71
216
  /**
@@ -78,16 +223,40 @@ export function upgradeHub(hub: string): UpgradeResult {
78
223
  const found = markerIn(hub)!;
79
224
  let created: string | undefined;
80
225
  let from: string | undefined;
226
+ // Carried forward by hand rather than left to stamp(): under a legacy name the file is
227
+ // deleted below, so its contents must be read out before it goes.
228
+ let carried: Record<string, unknown> = {};
81
229
  try {
82
230
  const before = JSON.parse(readFileSync(join(hub, found), "utf8"));
83
231
  created = before.created;
84
232
  from = before.khb ?? before.bkr;
233
+ carried = before;
85
234
  } catch {
86
235
  /* unreadable marker: rewritten below with today's date */
87
236
  }
88
237
  if (found !== MARKER) rmSync(join(hub, found));
89
238
  const synced = syncManaged(hub);
90
239
  const pruned = pruneRetired(hub);
91
- stamp(hub, created);
240
+ stamp(hub, created, carried);
92
241
  return { from, to: version(), synced, pruned, renamed: found === MARKER ? undefined : found };
93
242
  }
243
+
244
+ /**
245
+ * A one-line nudge, printed after `upgradeHub` runs (explicit `khb upgrade` or an
246
+ * auto-triggered one): `khb update` has a path repair and/or a sources.yaml schema backfill
247
+ * pending. Never a prompt — `upgrade` already runs unattended inside unrelated commands, so
248
+ * offering more than a suggestion here would mean blocking commands that have nothing to do
249
+ * with either half of `update`.
250
+ */
251
+ export function updateHint(hub: string): string | undefined {
252
+ const pathPending = staleLocations(hub).length > 0;
253
+ const schemaDiffs = diffSourcesYamlAll(hub);
254
+ if (!pathPending && !schemaDiffs.length) return undefined;
255
+ const parts: string[] = [];
256
+ if (pathPending) parts.push("path: repair needed");
257
+ if (schemaDiffs.length) {
258
+ const fields = schemaDiffs.reduce((n, d) => n + d.changes.length, 0);
259
+ parts.push(`schema: ${fields} field(s) across ${schemaDiffs.length} bundle(s)`);
260
+ }
261
+ return `khb: 'khb update' has changes available (${parts.join("; ")}) — run 'khb update' to apply, or 'khb update --dry-run' to preview.`;
262
+ }
@@ -64,16 +64,26 @@ empty `sources.yaml` has nothing to re-ingest, so there the only answer is a new
64
64
  for it. Do not infer sources from nearby files, do not edit `sources.yaml`, and do not run
65
65
  `khb ingest` until the user has answered.
66
66
 
67
+ For a `folder` or `files` source, ask one more thing before running `khb ingest`: **anything
68
+ to exclude from this source?** (default: no — most sources want everything ingested). If the
69
+ user names folders, files or patterns to skip, write them into that source's `exclude:` list
70
+ yourself — do not run `khb ingest` until this is settled either, for the same reason as the
71
+ question above: a declaration you have not confirmed is not yet a plan.
72
+
67
73
  Sources live in `bundles/<bundle>/sources.yaml`:
68
74
 
69
75
  ```yaml
70
76
  sources:
71
77
  - type: folder # walk a directory tree
72
78
  path: /abs/path/to/project-x
79
+ exclude: # optional — skip these before ingesting
80
+ - drafts/ # a plain entry: matches this path or anything under it
81
+ - "**/*.tmp" # a glob (has * ? [): matched with Bun.Glob
73
82
  - type: files # a scattered, explicitly named set
74
83
  paths:
75
84
  - /abs/path/to/one.pdf
76
85
  - /abs/path/to/two.xlsx
86
+ # exclude: also accepted here, matched against each path's basename
77
87
  - type: web
78
88
  urls:
79
89
  - https://example.com/design-doc
@@ -83,6 +93,10 @@ sources:
83
93
  space: PROJX
84
94
  ```
85
95
 
96
+ `exclude` entries can also be fully-qualified absolute paths (e.g. `D:\corpus\project-x\drafts`
97
+ or `/abs/path/to/project-x/drafts`) instead of paths/patterns relative to the source — either
98
+ form matches, plain or glob.
99
+
86
100
  Nothing is copied by declaring a source.
87
101
 
88
102
  ## 3. Run it
@@ -141,16 +155,22 @@ curation, not transcription.
141
155
  Extracted text is cached hub-wide by content hash at `inbox/extracted/<sha256>.md`, so the
142
156
  same file appearing in two bundles converts once.
143
157
 
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:
158
+ **OCR needs no setup.** `@hyzyla/pdfium`, `sharp` and `tesseract.js` are dependencies of khb
159
+ itself, so a scanned PDF or a photographed page is read on the first run, in any hub, without
160
+ asking the user to install anything.
161
+
162
+ Transcription is the one route that can be absent: it wants a `whisper` or `faster-whisper`
163
+ executable on `PATH`.
146
164
 
147
165
  ```
148
- bun add @hyzyla/pdfium sharp tesseract.js # OCR — ~75 MB WASM, no system binary
149
166
  pip install -U openai-whisper # transcription (faster-whisper also works)
150
167
  ```
151
168
 
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.
169
+ When any extractor is unavailable khb says so once and records the affected files as pending
170
+ rather than failing the run a `log.md` row with an empty `raw`, waiting for the dependency.
171
+ If khb ever prints a `bun add` hint for the OCR packages, its own install tree is incomplete;
172
+ install them where `khb` resolves modules from — for a global install that is the khb package
173
+ directory, not your hub — and khb prints the exact `cd … && bun add …` to use.
154
174
 
155
175
  ## 4. Sources khb cannot reach
156
176
 
@@ -229,11 +249,30 @@ Still on you, not khb: a source **modified in place** keeps its `curated` value,
229
249
  concept derived from it does not re-enter the backlog even though its material changed.
230
250
  Watch for `raw/` files whose content shifted and re-catalog them deliberately.
231
251
 
232
- ## Hand off
252
+ ## Hand off — offer the catalog pass
233
253
 
234
254
  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.
255
+ landed, what didn't and why, and how many `log.md` rows are now uncurated.
256
+
257
+ Then **offer to catalog, and wait for the answer.** Raw text is not yet knowledge — a bundle
258
+ left at the end of ingest has a backlog and nothing citable — so never stop silently on the
259
+ summary, and never start cataloging unasked either. Name the bundle and the size of the
260
+ backlog in the offer, so the answer is informed:
261
+
262
+ > Ingest landed 94 files in `real-estate/raw/`; 94 rows are uncurated. Shall I catalog them
263
+ > into concept docs now?
264
+
265
+ Take the answer at face value:
266
+
267
+ - **yes** → continue with the [catalog skill](../catalog/SKILL.md), on that bundle, reading
268
+ the backlog from `log.md`.
269
+ - **no, or not now** → stop. The ledger is the durable backlog, so nothing is lost; say that
270
+ the uncurated rows are waiting whenever they want to pick it up.
271
+ - **only part of it** — one folder, one document, the low-quality files first → catalog that
272
+ subset and leave the rest of the rows uncurated.
273
+
274
+ Offer once, for the bundle you just ingested. Do not offer to catalog a bundle this run did
275
+ not touch, and do not roll a "yes" onward into a second bundle's backlog.
237
276
 
238
277
  ## Hygiene
239
278