@msareen/knowledge-hub-builder 0.1.7 → 0.2.0

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
+ }
@@ -124,6 +124,13 @@ export function retargetRaw(bundleDir: string, rawRel: string, source: string):
124
124
  return true;
125
125
  }
126
126
 
127
+ /**
128
+ * One spelling of a path, for comparing two of them. Absolute, and case-folded on Windows,
129
+ * where `D:\Corpus\Talk.mp4` and `d:/corpus/talk.mp4` are the same file.
130
+ */
131
+ export const normPath = (p: string) =>
132
+ process.platform === "win32" ? resolve(p).toLowerCase() : resolve(p);
133
+
127
134
  export const sha256 = (buf: Buffer | string) => createHash("sha256").update(buf).digest("hex");
128
135
 
129
136
  /** Hash a file in chunks — corpora contain multi-GB binaries we must not slurp. */
package/scripts/lint.ts CHANGED
@@ -4,6 +4,9 @@ import { detail, section, totalElapsed } from "./lib/log";
4
4
  import { readdirSync, statSync } from "node:fs";
5
5
  import { dirname, relative } from "node:path";
6
6
  import { parse as parseYaml } from "yaml";
7
+ import { rejectUnknownFlags } from "./lib/args";
8
+
9
+ rejectUnknownFlags(process.argv.slice(2), "khb lint");
7
10
 
8
11
  /** OKF v0.1 concept frontmatter. Unknown keys are warned, not rejected — OKF is permissive,
9
12
  * but a `titel:` typo silently loses the field, so it is worth one line of noise. */
@@ -3,8 +3,11 @@
3
3
  import { existsSync } from "node:fs";
4
4
  import { BUNDLES, join } from "./lib/util";
5
5
  import { createBundle, VALID_NAME } from "./lib/scaffold";
6
+ import { rejectUnknownFlags } from "./lib/args";
6
7
 
7
- const [name, scope = "TODO scope"] = process.argv.slice(2);
8
+ const argv = process.argv.slice(2);
9
+ rejectUnknownFlags(argv, 'khb new-bundle <name> ["scope"]');
10
+ const [name, scope = "TODO scope"] = argv;
8
11
  if (!name || !VALID_NAME.test(name)) {
9
12
  console.error("Usage: khb new-bundle <name> [scope] (lowercase, digits, hyphens)");
10
13
  process.exit(1);
@@ -7,19 +7,24 @@
7
7
  // process. `--port N` pins a specific port.
8
8
  import { buildGraphData, readConceptFile } from "./lib/graph";
9
9
  import { renderGraphPage } from "./lib/graph-page";
10
+ import { takeFlag, takeOpt, rejectUnknownFlags } from "./lib/args";
10
11
 
12
+ const USAGE = "khb visualize [--port N] [--no-open]";
11
13
  const argv = process.argv.slice(2);
12
14
  // port 0 asks the OS for any free port — no fixed default to collide with something else
13
- // already running on the machine. Both `--port N` and `--port=N` pin it, since the docs
14
- // have always shown the spaced form.
15
- const eqArg = argv.find((a) => a.startsWith("--port="));
16
- const spacedArg = argv[argv.indexOf("--port") + 1];
17
- const requestedPort = eqArg
18
- ? Number(eqArg.slice("--port=".length))
19
- : argv.includes("--port") && spacedArg
20
- ? Number(spacedArg)
21
- : 0;
22
- const noOpen = argv.includes("--no-open");
15
+ // already running on the machine. `--port N` and `--port=N` both pin it.
16
+ const portArg = takeOpt(argv, "--port");
17
+ const noOpen = takeFlag(argv, "--no-open");
18
+ rejectUnknownFlags(argv, USAGE);
19
+
20
+ // A port that is not a port used to become NaN and silently serve on a random one, which
21
+ // looks like the flag working right up until nothing is listening where you expected.
22
+ const requestedPort = portArg === undefined ? 0 : Number(portArg);
23
+ if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) {
24
+ console.error(`--port must be a number between 0 and 65535, not: ${portArg}`);
25
+ console.error(`Usage: ${USAGE}`);
26
+ process.exit(1);
27
+ }
23
28
 
24
29
  function summarize(data: ReturnType<typeof buildGraphData>) {
25
30
  const concepts = Object.values(data.bundleGraphs).reduce((n, g) => n + g.concepts.length, 0);
@@ -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
@@ -103,6 +117,7 @@ locally. Read the summary it prints — the counts are the state of the world:
103
117
  | `unchanged, skipped` | already acquired at this exact content hash |
104
118
  | `extracted` / `reused from the extraction cache` | converted now / converted by an earlier run or another bundle |
105
119
  | `read by OCR` / `transcribed` | lossy routes — see quality, below |
120
+ | `read from a caption sidecar` | a recording whose words were read off its `.vtt`/`.srt` instead |
106
121
  | `marked quality: low` | verify these against the source when cataloging |
107
122
  | `not extracted` | got a ledger row with an empty `raw`; the per-file line says why |
108
123
 
@@ -136,22 +151,79 @@ curation, not transcription.
136
151
  | `.xlsx` | `fflate` → one markdown table per sheet | high |
137
152
  | `.pdf` (scanned, no text layer) | `pdfium` + `tesseract.js`, automatically | **low** |
138
153
  | `.png .jpg .webp .tif .gif` | `tesseract.js`, automatically | **low** |
139
- | `.mp3 .wav .m4a .mp4 .mov .mkv` | local `whisper` / `faster-whisper` | **low** |
154
+ | `.mp3 .wav .m4a .mp4 .mov .mkv` | local `vno` (whisper.cpp), else `whisper` / `faster-whisper` | **low** |
155
+ | `.vtt .srt` | built-in caption reader | high |
140
156
 
141
157
  Extracted text is cached hub-wide by content hash at `inbox/extracted/<sha256>.md`, so the
142
158
  same file appearing in two bundles converts once.
143
159
 
160
+ **A recording next to its captions is one source, not two.** `talk.vtt` (or `talk.en.vtt`,
161
+ or `talk.srt`) beside `talk.mp4` is that recording's words, already written down by someone
162
+ who could hear it — so khb reads them instead of guessing at them with whisper. The pair
163
+ gets one `log.md` row, under the recording; the sidecar earns no row and no `raw/` file of
164
+ its own, and the recording's `extract_tool` names the file the text came from. It is both
165
+ free and better than transcription, so it happens even under `--skip-audio`.
166
+
167
+ Two things follow. The pair's identity is *both* files, so correcting a caption re-ingests
168
+ the recording rather than leaving a stale row marked unchanged. And khb never picks between
169
+ sidecars: `talk.en.vtt` next to `talk.fr.vtt` is a choice about audience, so it transcribes
170
+ instead and leaves both files to be pointed at explicitly. A caption with no recording
171
+ beside it — or one whose recording this source does not visit, because it is excluded or
172
+ simply not listed — is an ordinary source and gets its own row. That is also the lever:
173
+ excluding a sidecar does not unpair it, since `exclude` governs what earns a `raw/` file and
174
+ a paired sidecar never earns one; exclude the *recording* to have its captions ingested
175
+ alone.
176
+
177
+ The caption reader drops what belongs to the player and keeps what belongs to the
178
+ transcript: cue indices and timecodes go, `<v Name>` becomes a speaker label, the rolling
179
+ repetition auto-generated captions leave behind is collapsed, and anything longer than five
180
+ minutes gets a coarse `## h:mm:ss` heading per interval so a passage can be found in the
181
+ source recording. Quality is `high` — the words are what the file says, not what an
182
+ extractor guessed — but auto-generated captions are still ASR underneath, so treat a
183
+ transcript that reads like a machine wrote it the way you would treat one.
184
+
144
185
  **OCR needs no setup.** `@hyzyla/pdfium`, `sharp` and `tesseract.js` are dependencies of khb
145
186
  itself, so a scanned PDF or a photographed page is read on the first run, in any hub, without
146
187
  asking the user to install anything.
147
188
 
148
- Transcription is the one route that can be absent: it wants a `whisper` or `faster-whisper`
149
- executable on `PATH`.
189
+ Transcription is the one route that can be absent. It wants a transcriber on `PATH`, and
190
+ takes the first of these it finds:
150
191
 
151
192
  ```
152
- pip install -U openai-whisper # transcription (faster-whisper also works)
193
+ npm install -g @msareen/voice-notes-organizer # vno whisper.cpp, preferred
194
+ pip install -U openai-whisper # whisper (faster-whisper also works)
153
195
  ```
154
196
 
197
+ `vno` is preferred where both are set up: it is whisper.cpp rather than the Python
198
+ whisper, so it is markedly faster on the same audio and uses whatever acceleration the
199
+ machine has, it installs its own ffmpeg and model, and it emits WebVTT — which means a
200
+ transcript with `## h:mm:ss` anchors instead of an undifferentiated wall of text. khb runs
201
+ it as `vno t <file> -o <cache path> --no-open` with stdin closed, so nothing is written
202
+ beside your recordings and vno's setup offers degrade to printed instructions instead of
203
+ prompts.
204
+
205
+ khb gates on `vno status` before using it, because installed and ready are different things
206
+ — vno needs ffmpeg, whisper.cpp and a model, and reports on all three. **A vno that is not
207
+ set up is an amber gate, never a red one.** The run does not stop and nothing else is
208
+ affected: whisper takes over if you have it, and if you don't, the recordings pend with an
209
+ empty `raw` exactly like any other unavailable extractor while the rest of the corpus is
210
+ ingested normally. What you get is the reason and the fix, on the file's own line and again
211
+ in `log.md`:
212
+
213
+ ```
214
+ [ 3/12] D:\corpus\standup.m4a
215
+ no captions beside it — transcribing (minutes per file) …
216
+ vno is installed but not set up: ffmpeg, whisper.cpp — run: vno setup
217
+ pending — vno is installed but not set up: ffmpeg, whisper.cpp — run: vno setup
218
+ ```
219
+
220
+ Run `vno setup` yourself and re-run the ingest; the pending rows fill in. khb will not run
221
+ it for you — installing software nobody asked it to install is not a conversion step.
222
+
223
+ Either engine is a local binary doing a reproducible conversion, and its output is
224
+ `quality: low` all the same: it is a machine's guess at audio, and the recording is still
225
+ the thing to re-read when a passage looks wrong.
226
+
155
227
  When any extractor is unavailable khb says so once and records the affected files as pending
156
228
  rather than failing the run — a `log.md` row with an empty `raw`, waiting for the dependency.
157
229
  If khb ever prints a `bun add` hint for the OCR packages, its own install tree is incomplete;