@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.
@@ -9,13 +9,13 @@
9
9
  // what the text *says* is the catalog pass (skills/catalog/SKILL.md).
10
10
  import { readFileSync, existsSync } from "node:fs";
11
11
  import { basename } from "node:path";
12
- import { writeRaw, sha256File, rawNameFor, retargetRaw } from "../lib/util";
12
+ import { writeRaw, sha256, sha256File, rawNameFor, retargetRaw, normPath } from "../lib/util";
13
13
  import { record, isFresh, identify, adopt, type Entry } from "../lib/ledger";
14
14
  import {
15
15
  extractCached, ocrCached, ocrImageCached, transcribeCached,
16
16
  extractedBody, extractedPath, type Extraction,
17
17
  } from "../lib/extract";
18
- import { kindOf, extOf, mdName } from "./exts";
18
+ import { captionFor, kindOf, mediaFor, extOf, mdName } from "./exts";
19
19
  import { detectPasswordProtected, PROTECTABLE } from "./protect";
20
20
  import { item, note, outcome } from "../lib/log";
21
21
 
@@ -23,14 +23,22 @@ export type Options = {
23
23
  force: boolean; // re-acquire even when the content hash is unchanged
24
24
  ocr: boolean; // OCR scanned PDFs and images (default on; ~seconds/page)
25
25
  audio: boolean; // transcribe audio/video (default on; ~minutes/file)
26
+ // Every path this source will visit, normalized. Only the caption/media pairing reads it
27
+ // — a sidecar may only be folded into a recording that is itself being acquired, or a
28
+ // `files:` source naming just the `.vtt` would acquire nothing at all.
29
+ scope?: ReadonlySet<string>;
26
30
  };
27
31
 
32
+ /** Will this source reach that file? An unset scope has no opinion, so: yes. */
33
+ const willVisit = (opts: Options, path: string) => !opts.scope || opts.scope.has(normPath(path));
34
+
28
35
  export type Counters = {
29
36
  copied: number; // text files, taken verbatim
30
37
  extracted: number; // converted this run
31
38
  fromCache: number; // converted by an earlier run or another bundle
32
39
  ocrd: number;
33
40
  transcribed: number;
41
+ captioned: number; // read from a caption sidecar — the whisper run it saved
34
42
  lowQuality: number; // OCR/ASR output — worth re-reading from source during curation
35
43
  skipped: number; // unchanged since last ingest
36
44
  moved: number; // same bytes at a new path — row re-pointed, nothing re-extracted
@@ -41,7 +49,7 @@ export type Counters = {
41
49
  };
42
50
 
43
51
  export const newCounters = (): Counters => ({
44
- copied: 0, extracted: 0, fromCache: 0, ocrd: 0, transcribed: 0,
52
+ copied: 0, extracted: 0, fromCache: 0, ocrd: 0, transcribed: 0, captioned: 0,
45
53
  lowQuality: 0, skipped: 0, moved: 0, pending: 0,
46
54
  });
47
55
 
@@ -67,7 +75,26 @@ export async function acquireFile(
67
75
  // real time, and a run that printed only successes left the slow file unnamed.
68
76
  item(at, path);
69
77
  const kind = kindOf(path);
70
- const hash = await sha256File(path);
78
+
79
+ // A caption sidecar is not a source of its own: `talk.vtt` beside `talk.mp4` is that
80
+ // recording's words, and the recording's row claims them below. Acquiring it here as well
81
+ // would spend a second raw/ file, a second uncurated ledger row and eventually a second
82
+ // concept on the same sentences. A caption with no recording beside it — or one whose
83
+ // recording this source will not visit — is a source like any other and falls through.
84
+ if (kind === "caption") {
85
+ const media = mediaFor(path);
86
+ if (media && willVisit(opts, media)) {
87
+ outcome(`captions for ${basename(media)} — acquired with the recording`);
88
+ return;
89
+ }
90
+ }
91
+
92
+ // Identity of a captioned recording is the pair's, not the file's. Hashing the media
93
+ // alone would let a corrected transcript sit next to an "unchanged, skipped" row forever.
94
+ const captions = kind === "av" ? captionFor(path) : undefined;
95
+ const own = await sha256File(path);
96
+ const capHash = captions ? await sha256File(captions) : kind === "caption" ? own : undefined;
97
+ const hash = captions ? sha256(`${own}:${capHash}`) : own;
71
98
  if (kind === "skip") {
72
99
  pend(entries, path, hash, c, "no extractor for this format");
73
100
  return;
@@ -123,12 +150,16 @@ export async function acquireFile(
123
150
  return;
124
151
  }
125
152
 
126
- const hit = existsSync(extractedPath(hash));
153
+ // The extraction cache is keyed on the bytes that actually get converted, which for a
154
+ // captioned recording is the sidecar — so the same captions beside a re-encoded copy of
155
+ // the video, or ingested alone into another bundle, hit the same entry.
156
+ const key = capHash ?? hash;
157
+ const hit = existsSync(extractedPath(key));
127
158
  let res: Extraction;
128
159
 
129
160
  if (kind === "doc") {
130
161
  note(hit ? `${ext.slice(1)} — reusing cached extraction` : `extracting ${ext.slice(1)} …`);
131
- res = await extractCached(path, hash, ext);
162
+ res = await extractCached(path, key, ext);
132
163
  // Pages but no text layer: the file is fine, the reader was wrong. OCR is the remedy,
133
164
  // and running it here is what keeps ingest a single pass instead of a hunt afterwards.
134
165
  if (res.status === "needs-ocr") {
@@ -137,7 +168,7 @@ export async function acquireFile(
137
168
  return;
138
169
  }
139
170
  note(`no text layer, ${res.pages}p — scanned, running OCR (seconds per page)`);
140
- res = await ocrCached(path, hash);
171
+ res = await ocrCached(path, key);
141
172
  if (res.status === "ok") c.ocrd++;
142
173
  }
143
174
  } else if (kind === "image") {
@@ -146,15 +177,22 @@ export async function acquireFile(
146
177
  return;
147
178
  }
148
179
  note(hit ? "image — reusing cached OCR" : "image — running OCR …");
149
- res = await ocrImageCached(path, hash);
180
+ res = await ocrImageCached(path, key);
150
181
  if (res.status === "ok" && !hit) c.ocrd++;
182
+ } else if (kind === "caption" || captions) {
183
+ // Ahead of the --skip-audio check on purpose: that flag exists to skip minutes of CPU
184
+ // per file, and reading a sidecar costs none. A pair is acquired even on a fast run.
185
+ const src = captions ?? path;
186
+ note(hit ? "captions — reusing cached extraction" : `reading captions from ${basename(src)} …`);
187
+ res = await extractCached(src, key, extOf(src));
188
+ if (res.status === "ok" && captions) c.captioned++;
151
189
  } else {
152
190
  if (!opts.audio) {
153
191
  pend(entries, path, hash, c, "audio/video (--skip-audio)");
154
192
  return;
155
193
  }
156
- note(hit ? "audio/video — reusing cached transcript" : "transcribing with whisper (minutes per file) …");
157
- res = await transcribeCached(path, hash);
194
+ note(hit ? "audio/video — reusing cached transcript" : "no captions beside it — transcribing (minutes per file) …");
195
+ res = await transcribeCached(path, key);
158
196
  if (res.status === "ok" && !hit) c.transcribed++;
159
197
  }
160
198
 
@@ -165,14 +203,18 @@ export async function acquireFile(
165
203
 
166
204
  // Copy out of the hash-keyed cache rather than moving: raw/ stays derived and the cache
167
205
  // stays reusable by any other bundle that holds the same content.
168
- const raw = writeRaw(rawDir, file, { source: path, sha256: hash.slice(0, 12), tool: res.tool, quality: res.quality }, extractedBody(res.path));
206
+ // Name the sidecar in the provenance header, not just in this run's output: the recording
207
+ // is the source, but which file the words were read from is what a curator needs to know
208
+ // when the transcript and the audio disagree.
209
+ const tool = captions ? `${res.tool} (sidecar: ${basename(captions)})` : res.tool;
210
+ const raw = writeRaw(rawDir, file, { source: path, sha256: hash.slice(0, 12), tool, quality: res.quality }, extractedBody(res.path));
169
211
  stamp(raw);
170
212
  if (hit) c.fromCache++;
171
- else if (kind === "doc") c.extracted++;
213
+ else if (kind === "doc" || kind === "caption") c.extracted++;
172
214
  if (res.quality === "low") c.lowQuality++;
173
215
  // Name the tool and the quality on the line: `quality: low` is the flag that tells
174
216
  // curation to re-read the original, and burying it in the file made it easy to miss.
175
- outcome(`${hit ? "cached" : "extracted"} → ${raw} [${res.tool}, quality: ${res.quality}]`);
217
+ outcome(`${hit ? "cached" : "extracted"} → ${raw} [${tool}, quality: ${res.quality}]`);
176
218
  }
177
219
 
178
220
  export function report(c: Counters) {
@@ -184,6 +226,7 @@ export function report(c: Counters) {
184
226
  line(c.fromCache, "reused from the extraction cache (inbox/extracted/)");
185
227
  line(c.ocrd, "read by OCR");
186
228
  line(c.transcribed, "transcribed");
229
+ line(c.captioned, "read from a caption sidecar (no transcription needed)");
187
230
  line(c.lowQuality, "marked `quality: low` — verify against the source when curating");
188
231
  line(c.pending, "not extracted (empty `raw` in log.md)");
189
232
  }
@@ -0,0 +1,37 @@
1
+ // One rule for `exclude:` entries in sources.yaml, shared by folder and files sources. An
2
+ // entry that starts with a drive letter or `/` is absolute and is checked against the file's
3
+ // absolute path; anything else is relative and checked against the file's path relative to
4
+ // its source root (or, for `files` sources, its basename). Within either case, an entry with
5
+ // no glob metacharacter (* ? [) is a plain prefix — it matches the path itself or anything
6
+ // under it — and an entry with one is a Bun.Glob pattern. Bun.Glob is a global, no import
7
+ // needed.
8
+ import { isAbsolute } from "node:path";
9
+
10
+ const isGlobPattern = (p: string) => /[*?[]/.test(p);
11
+ const posix = (p: string) => p.replaceAll("\\", "/");
12
+
13
+ type Matcher = (path: string) => boolean;
14
+
15
+ function makeMatcher(patterns: string[]): Matcher {
16
+ const plain: string[] = [];
17
+ const globs: Bun.Glob[] = [];
18
+ for (const raw of patterns) {
19
+ const p = posix(raw).replace(/\/+$/, ""); // trailing slash is cosmetic on a plain entry
20
+ if (isGlobPattern(p)) globs.push(new Bun.Glob(p));
21
+ else plain.push(p);
22
+ }
23
+ return (path: string) =>
24
+ plain.some((p) => path === p || path.startsWith(`${p}/`)) || globs.some((g) => g.match(path));
25
+ }
26
+
27
+ /**
28
+ * Build an excluder from `sources.yaml`'s `exclude:` list. The returned function takes a
29
+ * file's absolute path and its path relative to the source (or basename, for `files`
30
+ * sources) and reports whether either matched an exclude entry of the corresponding kind.
31
+ */
32
+ export function makeExcluder(patterns: string[] | undefined): (absPath: string, relPath: string) => boolean {
33
+ if (!patterns?.length) return () => false;
34
+ const abs = makeMatcher(patterns.filter((p) => isAbsolute(p)).map(posix));
35
+ const rel = makeMatcher(patterns.filter((p) => !isAbsolute(p)));
36
+ return (absPath: string, relPath: string) => abs(posix(absPath)) || rel(relPath);
37
+ }
@@ -2,6 +2,10 @@
2
2
  //
3
3
  // The kind decides which extractor runs, and every kind here has one — ingest is a single
4
4
  // flat pass that converts everything it can, locally, with no agent turn in the middle.
5
+ import { readdirSync } from "node:fs";
6
+ import { dirname, basename, join } from "node:path";
7
+ import { normPath } from "../lib/util";
8
+
5
9
  export const TEXT = [".md", ".txt", ".rst", ".adoc", ".html", ".csv", ".json", ".yaml", ".yml"];
6
10
  /** Born-digital documents: khb's own pure-JS libraries read these, no system install. */
7
11
  export const DOC = [".pdf", ".docx", ".odt", ".xlsx", ".pptx"];
@@ -9,6 +13,12 @@ export const DOC = [".pdf", ".docx", ".odt", ".xlsx", ".pptx"];
9
13
  export const IMAGE = [".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff", ".gif"];
10
14
  /** Audio and video: local whisper. Minutes of CPU per file, hence `--skip-audio`. */
11
15
  export const AV = [".mp3", ".wav", ".m4a", ".flac", ".ogg", ".mp4", ".mov", ".mkv", ".webm"];
16
+ /**
17
+ * Subtitle sidecars: the words of a recording, already written down by someone who could
18
+ * hear it. Ordered by preference — the same captions often exist in both containers, and
19
+ * `.vtt` carries speaker names where `.srt` does not.
20
+ */
21
+ export const CAPTION = [".vtt", ".srt"];
12
22
 
13
23
  export const extOf = (p: string) => {
14
24
  const i = p.lastIndexOf(".");
@@ -18,12 +28,83 @@ export const extOf = (p: string) => {
18
28
  /** raw/ files are always markdown — don't produce "budget.md.md". */
19
29
  export const mdName = (n: string) => (n.toLowerCase().endsWith(".md") ? n : n + ".md");
20
30
 
21
- export type Kind = "text" | "doc" | "image" | "av" | "skip";
31
+ export type Kind = "text" | "doc" | "image" | "av" | "caption" | "skip";
22
32
  export function kindOf(path: string): Kind {
23
33
  const e = extOf(path);
24
34
  if (TEXT.includes(e)) return "text";
25
35
  if (DOC.includes(e)) return "doc";
26
36
  if (IMAGE.includes(e)) return "image";
27
37
  if (AV.includes(e)) return "av";
38
+ if (CAPTION.includes(e)) return "caption";
28
39
  return "skip";
29
40
  }
41
+
42
+ // --- sidecar pairing -------------------------------------------------------------------
43
+ //
44
+ // `talk.vtt` next to `talk.mp4` is not a second source, it is that recording's transcript.
45
+ // Pairing them is what lets ingest read the words instead of guessing at them with whisper,
46
+ // for no CPU and at higher fidelity. Both directions of the pairing are decided here so the
47
+ // two sides can never disagree about which file belongs to which.
48
+
49
+ /** One readdir per directory per run — a folder walk asks about the same siblings a lot. */
50
+ const listing = new Map<string, string[]>();
51
+ function siblings(path: string): { dir: string; names: string[] } {
52
+ const dir = dirname(path) || ".";
53
+ let names = listing.get(dir);
54
+ if (!names) {
55
+ try {
56
+ names = readdirSync(dir);
57
+ } catch {
58
+ names = []; // unreadable directory: no sidecar, and the file itself will say why
59
+ }
60
+ listing.set(dir, names);
61
+ }
62
+ return { dir, names };
63
+ }
64
+
65
+ const stemOf = (name: string) => name.slice(0, name.length - extOf(name).length);
66
+
67
+ /**
68
+ * A caption file's stem usually carries a language tag — `talk.en.vtt`, `talk.pt-BR.vtt`,
69
+ * which is what yt-dlp and every "download the subtitles" button writes — beside a plain
70
+ * `talk.mp4`. Strip one tag so the pair still matches.
71
+ */
72
+ const untag = (stem: string) => stem.replace(/\.[a-z]{2,3}(?:-[A-Za-z]{2,4})?$/, "");
73
+
74
+ /**
75
+ * The caption sidecar belonging to a media file, when there is one obvious candidate.
76
+ *
77
+ * Two languages on disk is a choice about audience, and khb does not make choices: it
78
+ * transcribes instead and leaves the sidecars for a human to point at. The same captions in
79
+ * two containers is not a choice — they are the same words — so CAPTION order settles it.
80
+ */
81
+ export function captionFor(media: string): string | undefined {
82
+ if (kindOf(media) !== "av") return undefined;
83
+ const { dir, names } = siblings(media);
84
+ const stem = stemOf(basename(media));
85
+ const captions = names.filter((n) => kindOf(n) === "caption");
86
+ const exact = captions.filter((n) => stemOf(n) === stem);
87
+ const pool = exact.length ? exact : captions.filter((n) => untag(stemOf(n)) === stem);
88
+ if (!pool.length) return undefined;
89
+ if (!exact.length && new Set(pool.map(stemOf)).size > 1) return undefined; // several languages
90
+ const best = [...pool].sort((a, b) => CAPTION.indexOf(extOf(a)) - CAPTION.indexOf(extOf(b)))[0];
91
+ return join(dir, best);
92
+ }
93
+
94
+ /**
95
+ * The media file a caption sidecar belongs to, if any — deliberately defined in terms of
96
+ * `captionFor`, so a caption is only somebody's sidecar when that somebody would actually
97
+ * claim it. A caption with no recording beside it is a source in its own right.
98
+ */
99
+ export function mediaFor(caption: string): string | undefined {
100
+ if (kindOf(caption) !== "caption") return undefined;
101
+ const { dir, names } = siblings(caption);
102
+ const stems = new Set([stemOf(basename(caption)), untag(stemOf(basename(caption)))]);
103
+ for (const n of names) {
104
+ if (kindOf(n) !== "av" || !stems.has(stemOf(n))) continue;
105
+ const media = join(dir, n);
106
+ const found = captionFor(media);
107
+ if (found && normPath(found) === normPath(caption)) return media;
108
+ }
109
+ return undefined;
110
+ }
@@ -1,10 +1,11 @@
1
1
  // Explicit file list → raw/files/. Same acquisition as a folder source, for the case where
2
2
  // the interesting files are scattered and naming them is easier than naming a root.
3
3
  import { existsSync } from "node:fs";
4
- import { basename } from "../lib/util";
4
+ import { basename, normPath } from "../lib/util";
5
5
  import type { Entry } from "../lib/ledger";
6
6
  import { acquireFile, newCounters, report, type Options } from "./acquire";
7
7
  import { detail, item, outcome, pos } from "../lib/log";
8
+ import { makeExcluder } from "./exclude";
8
9
  import type { Source } from "./index";
9
10
 
10
11
  export async function ingestFiles(
@@ -15,15 +16,25 @@ export async function ingestFiles(
15
16
  opts: Options,
16
17
  ) {
17
18
  detail(`${s.paths.length} file(s) declared`);
19
+ const excluded = makeExcluder(s.exclude);
20
+ const paths = s.paths.filter((p) => !excluded(p, basename(p)));
21
+ const skippedCount = s.paths.length - paths.length;
22
+ if (skippedCount) detail(`${skippedCount} excluded by 'exclude' rule(s), ${paths.length} remain`);
23
+
24
+ // What this source will visit, for the caption/media pairing: a `.vtt` is only folded
25
+ // into a recording that is itself on the list, so naming just the sidecar still acquires
26
+ // it on its own.
27
+ const scoped = { ...opts, scope: new Set(paths.map((p) => normPath(p))) };
28
+
18
29
  const c = newCounters();
19
- for (const [i, p] of s.paths.entries()) {
20
- const at = pos(i + 1, s.paths.length);
30
+ for (const [i, p] of paths.entries()) {
31
+ const at = pos(i + 1, paths.length);
21
32
  if (!existsSync(p)) {
22
33
  item(at, p);
23
34
  outcome("missing, skipped");
24
35
  continue;
25
36
  }
26
- await acquireFile(at, p, basename(p), rawDir, bundleDir, entries, c, opts);
37
+ await acquireFile(at, p, basename(p), rawDir, bundleDir, entries, c, scoped);
27
38
  }
28
39
  report(c);
29
40
  }
@@ -2,10 +2,11 @@
2
2
  // owns the extraction decisions. Unchanged files (same content hash, raw/ copy still
3
3
  // present) are skipped.
4
4
  import { readdirSync, statSync, existsSync } from "node:fs";
5
- import { join } from "../lib/util";
5
+ import { join, normPath } from "../lib/util";
6
6
  import type { Entry } from "../lib/ledger";
7
7
  import { acquireFile, newCounters, report, type Options } from "./acquire";
8
8
  import { detail, pos } from "../lib/log";
9
+ import { makeExcluder } from "./exclude";
9
10
  import type { Source } from "./index";
10
11
 
11
12
  export async function ingestFolder(
@@ -30,15 +31,29 @@ export async function ingestFolder(
30
31
  // take a while to enumerate, and knowing the denominator is what makes "[ 3/57]" mean
31
32
  // anything to someone deciding whether to wait.
32
33
  detail(`scanning ${s.path} …`);
33
- const files = walk(s.path);
34
- detail(`${files.length} file(s) found`);
34
+ const all = walk(s.path);
35
+ detail(`${all.length} file(s) found`);
36
+
37
+ // relOf is posix-normalized so an 'exclude' entry like "drafts/" behaves the same whether
38
+ // the corpus was walked on Windows or POSIX; it's computed once and reused for both the
39
+ // exclude check and the flattened raw/ filename below.
40
+ const relOf = (p: string) => p.slice(s.path.length + 1).replaceAll("\\", "/");
41
+ const excluded = makeExcluder(s.exclude);
42
+ const files = all.filter((p) => !excluded(p, relOf(p)));
43
+ const skippedCount = all.length - files.length;
44
+ if (skippedCount) detail(`${skippedCount} excluded by 'exclude' rule(s), ${files.length} remain`);
45
+
46
+ // What this walk will visit, for the caption/media pairing: a `.vtt` whose recording is
47
+ // excluded (or simply absent) is acquired on its own rather than folded into a row that
48
+ // would never appear.
49
+ const scoped = { ...opts, scope: new Set(files.map((p) => normPath(p))) };
35
50
 
36
51
  const c = newCounters();
37
52
  for (const [i, p] of files.entries()) {
38
53
  // Flatten the subtree into the filename so two `notes.md` in sibling folders don't
39
54
  // collide in raw/, and so the origin stays legible without opening the file.
40
- const rel = p.slice(s.path.length + 1).replaceAll(/[\\/]/g, "__");
41
- await acquireFile(pos(i + 1, files.length), p, rel, rawDir, bundleDir, entries, c, opts);
55
+ const rel = relOf(p).replaceAll("/", "__");
56
+ await acquireFile(pos(i + 1, files.length), p, rel, rawDir, bundleDir, entries, c, scoped);
42
57
  }
43
58
  report(c);
44
59
  }
@@ -14,17 +14,18 @@ import { read, join, HUB } from "../lib/util";
14
14
  import { detail, section, totalElapsed } from "../lib/log";
15
15
  import { bundleForIngest, listBundles, DEFAULT_BUNDLE } from "../lib/scaffold";
16
16
  import { readLedger, writeLedger } from "../lib/ledger";
17
- import { takeFlag } from "../lib/args";
17
+ import { takeFlag, rejectUnknownFlags } from "../lib/args";
18
18
  import { ingestFolder } from "./folder";
19
19
  import { ingestFiles } from "./files";
20
20
  import { ingestWeb } from "./web";
21
21
  import type { Options } from "./acquire";
22
22
 
23
23
  export type Source =
24
- | { type: "folder"; path: string }
25
- | { type: "files"; paths: string[] }
24
+ | { type: "folder"; path: string; exclude?: string[] }
25
+ | { type: "files"; paths: string[]; exclude?: string[] }
26
26
  | { type: "web"; urls: string[] };
27
27
 
28
+ const USAGE = "khb ingest [bundle] [--force] [--skip-ocr] [--skip-audio]";
28
29
  const argv = process.argv.slice(2);
29
30
  const opts: Options = {
30
31
  force: takeFlag(argv, "--force"),
@@ -33,14 +34,10 @@ const opts: Options = {
33
34
  ocr: !takeFlag(argv, "--skip-ocr"),
34
35
  audio: !takeFlag(argv, "--skip-audio"),
35
36
  };
36
- const unknownFlag = argv.find((a) => a.startsWith("--"));
37
- if (unknownFlag) {
38
- console.error(`Unknown ingest flag: ${unknownFlag}`);
39
- process.exit(1);
40
- }
41
- const positional = argv.filter((a) => !a.startsWith("--"));
37
+ rejectUnknownFlags(argv, USAGE);
38
+ const positional = argv;
42
39
  if (positional.length > 1) {
43
- console.error("Usage: khb ingest [bundle] [--force] [--skip-ocr] [--skip-audio]");
40
+ console.error(`Usage: ${USAGE}`);
44
41
  process.exit(1);
45
42
  }
46
43
  // No bundle named: which one owns the material is a human decision (AGENTS.md) and a CLI
@@ -52,7 +49,7 @@ if (!bundle) {
52
49
  const have = listBundles();
53
50
  const onlyLanding = have.length === 1 && have[0] === DEFAULT_BUNDLE;
54
51
  if (have.length && !onlyLanding) {
55
- console.error("Usage: khb ingest [bundle] [--force] [--skip-ocr] [--skip-audio]");
52
+ console.error(`Usage: ${USAGE}`);
56
53
  console.error(`\nBundles in this hub: ${have.join(", ")}`);
57
54
  console.error(`Name the one that owns this material, or start a new one:`);
58
55
  console.error(` khb new-bundle <name> "<scope>"`);
@@ -94,6 +91,14 @@ for (const [i, s] of sources.entries()) {
94
91
  process.exit(1);
95
92
  }
96
93
  }
94
+ if (
95
+ (s.type === "folder" || s.type === "files") &&
96
+ s.exclude !== undefined &&
97
+ (!Array.isArray(s.exclude) || s.exclude.some((v: unknown) => typeof v !== "string"))
98
+ ) {
99
+ console.error(`${bundle}: ${at}.exclude must be a list of strings`);
100
+ process.exit(1);
101
+ }
97
102
  }
98
103
  if (!sources.length) {
99
104
  console.log(`${bundle}: no sources configured.`);
package/scripts/init.ts CHANGED
@@ -8,22 +8,54 @@
8
8
  //
9
9
  // The mechanism itself lives in lib/upgrade.ts, because cli.ts also runs it on version
10
10
  // drift before any hub command.
11
- import { cpSync, mkdirSync, existsSync } from "node:fs";
12
- import { join, resolve, basename } from "node:path";
13
- import { HUB_TEMPLATE, MARKER, markerIn } from "./lib/paths";
14
- import { upgradeHub, syncManaged, stamp } from "./lib/upgrade";
11
+ import { resolve, basename } from "node:path";
12
+ import { MARKER, markerIn } from "./lib/paths";
13
+ import { recordLocation, upgradeHub, updateHint } from "./lib/upgrade";
14
+ import { takeOpt, rejectUnknownFlags } from "./lib/args";
15
15
 
16
16
  const upgrading = process.env.KHB_SUBCOMMAND === "upgrade";
17
- const [dirArg] = process.argv.slice(2);
17
+ const argv = process.argv.slice(2);
18
+
19
+ // A hub describes itself in its own marker, and the machine-level registry reads those
20
+ // two fields from there — so the label follows the hub when it is moved or cloned onto
21
+ // another machine, instead of living only in one laptop's shortcut list.
22
+ // Both describe a hub being created, so `upgrade` must not consume them: left in argv they
23
+ // are refused below like any other unknown option, instead of being silently swallowed by a
24
+ // command whose help says it takes no flags at all.
25
+ const nameOpt = upgrading ? undefined : takeOpt(argv, "--name");
26
+ const descOpt = upgrading ? undefined : takeOpt(argv, "--description");
27
+ rejectUnknownFlags(argv, upgrading ? "khb upgrade" : 'khb init [dir] [--name N] [--description "…"]');
28
+ const [dirArg] = argv;
18
29
 
19
30
  if (upgrading) {
20
31
  const { HUB } = await import("./lib/util"); // resolves the hub, or exits with guidance
32
+
33
+ // cli.ts runs these two before every *other* in-hub command and skips `upgrade`, on the
34
+ // grounds that upgrade does the refresh itself. Neither of these is the refresh:
35
+ //
36
+ // - unregistered, a hub you only ever upgrade never appears in `khb list` or `khb go`;
37
+ // - unrecorded, a hub upgraded right after a move loses the move. `upgradeHub` stamps
38
+ // the marker with wherever the hub is now, so the old path has to be read — and
39
+ // appended to `movedFrom` — before that happens, or `khb update --path` is left with
40
+ // nothing to repair from and the arguments it exists to avoid.
41
+ const { registerHub, touchHub } = await import("./lib/registry");
42
+ registerHub(HUB);
43
+ touchHub(HUB);
44
+ const { moved } = recordLocation(HUB);
45
+ if (moved) {
46
+ console.log(`This hub was at ${moved} and is now at ${HUB}.`);
47
+ console.log(` absolute paths recorded inside it still name the old location.`);
48
+ console.log(` repair them: khb update --path (--dry-run to preview)`);
49
+ }
50
+
21
51
  const { from, to, synced, pruned, renamed } = upgradeHub(HUB);
22
52
  console.log(`Upgraded ${HUB}: ${from ?? "?"} -> ${to}`);
23
53
  console.log(` refreshed: ${synced.join(", ")}`);
24
54
  if (renamed) console.log(` renamed: ${renamed} -> ${MARKER}`);
25
55
  if (pruned.length) console.log(` removed (no longer part of the contract): ${pruned.join(", ")}`);
26
56
  console.log(`Your bundles/ and outer.index.md were not touched. Next: khb lint`);
57
+ const hint = updateHint(HUB);
58
+ if (hint) console.log(hint);
27
59
  } else {
28
60
  const hub = resolve(dirArg ?? process.cwd());
29
61
 
@@ -33,14 +65,8 @@ if (upgrading) {
33
65
  process.exit(1);
34
66
  }
35
67
 
36
- mkdirSync(join(hub, "bundles"), { recursive: true });
37
- cpSync(join(HUB_TEMPLATE, "outer.index.md"), join(hub, "outer.index.md"));
38
- // Dotfiles: shipped unprefixed so npm doesn't swallow them, renamed on the way in.
39
- // Never clobber — `khb init` may be run inside a folder that is already a git repo.
40
- for (const f of ["gitignore", "gitattributes"])
41
- if (!existsSync(join(hub, `.${f}`))) cpSync(join(HUB_TEMPLATE, f), join(hub, `.${f}`));
42
- const synced = syncManaged(hub);
43
- stamp(hub);
68
+ const { createHub } = await import("./lib/create");
69
+ const { synced, entry } = createHub(hub, { name: nameOpt, description: descOpt });
44
70
 
45
71
  console.log(`Hub created: ${hub}`);
46
72
  console.log(` khb.json, outer.index.md, bundles/, .gitignore, .gitattributes`);
@@ -50,4 +76,5 @@ if (upgrading) {
50
76
  console.log(` git init # optional, but recommended`);
51
77
  console.log(` khb new-bundle <name> "<scope>" # your first bundle`);
52
78
  console.log(`\nThen open this folder with Claude or Codex — both load AGENTS.md and the workflow skills.`);
79
+ console.log(`Registered as "${entry.name}" — from any terminal, 'khb' comes back here and starts your agent.`);
53
80
  }
@@ -1,8 +1,49 @@
1
1
  // Tiny argv helpers shared by the scripts that take flags. Each `take*` removes what it
2
2
  // consumed from the array, so whatever is left is the positional arguments.
3
- export function takeFlag(args: string[], name: string): boolean {
3
+ export function takeFlag(args: string[], ...names: string[]): boolean {
4
+ for (const name of names) {
5
+ const i = args.indexOf(name);
6
+ if (i >= 0) {
7
+ args.splice(i, 1);
8
+ return true;
9
+ }
10
+ }
11
+ return false;
12
+ }
13
+
14
+ /**
15
+ * Take `--name value`, removing both. Also accepts `--name=value`, since that is the form
16
+ * fingers produce when the docs show the spaced one.
17
+ */
18
+ export function takeOpt(args: string[], name: string): string | undefined {
19
+ const eq = args.findIndex((a) => a.startsWith(`${name}=`));
20
+ if (eq >= 0) {
21
+ const [v] = args.splice(eq, 1);
22
+ return v.slice(name.length + 1);
23
+ }
4
24
  const i = args.indexOf(name);
5
- if (i < 0) return false;
6
- args.splice(i, 1);
7
- return true;
25
+ if (i < 0) return undefined;
26
+ const v = args[i + 1];
27
+ if (v === undefined) {
28
+ console.error(`${name} needs a value`);
29
+ process.exit(1);
30
+ }
31
+ args.splice(i, 2);
32
+ return v;
33
+ }
34
+
35
+ /**
36
+ * Refuse anything flag-shaped this command does not understand. Call it once every
37
+ * `takeFlag`/`takeOpt` has removed what it consumed, so whatever remains is positional.
38
+ *
39
+ * Silence is the wrong default here: an unrecognized flag left in the array becomes a
40
+ * positional argument, and `khb export mybundle --force` used to quietly export into a
41
+ * directory named `--force`. A typo should cost an error message, not a mystery folder.
42
+ */
43
+ export function rejectUnknownFlags(args: string[], usage: string): void {
44
+ const bad = args.find((a) => a.length > 1 && a.startsWith("-"));
45
+ if (!bad) return;
46
+ console.error(`Unknown option: ${bad}`);
47
+ console.error(`Usage: ${usage}`);
48
+ process.exit(1);
8
49
  }
@@ -0,0 +1,39 @@
1
+ // Creating a hub, as a function rather than a script.
2
+ //
3
+ // Two callers need it and must not diverge: `khb init`, and the first-run wizard a bare
4
+ // `khb` opens when the machine has no hubs yet. A hub made by the wizard is the same hub
5
+ // down to the byte — the wizard only asks the questions `init` takes as flags.
6
+ import { cpSync, mkdirSync, existsSync } from "node:fs";
7
+ import { join, resolve } from "node:path";
8
+ import { HUB_TEMPLATE } from "./paths";
9
+ import { syncManaged, stamp } from "./upgrade";
10
+ import { registerHub, type HubEntry } from "./registry";
11
+
12
+ export type CreatedHub = {
13
+ hub: string;
14
+ /** Package-owned contract files copied in, for the caller to report. */
15
+ synced: string[];
16
+ entry: HubEntry;
17
+ };
18
+
19
+ export function createHub(
20
+ dir: string,
21
+ opts: { name?: string; description?: string } = {},
22
+ ): CreatedHub {
23
+ const hub = resolve(dir);
24
+ mkdirSync(join(hub, "bundles"), { recursive: true });
25
+ cpSync(join(HUB_TEMPLATE, "outer.index.md"), join(hub, "outer.index.md"));
26
+ // Dotfiles: shipped unprefixed so npm doesn't swallow them, renamed on the way in.
27
+ // Never clobber — a hub may be created inside a folder that is already a git repo.
28
+ for (const f of ["gitignore", "gitattributes"])
29
+ if (!existsSync(join(hub, `.${f}`))) cpSync(join(HUB_TEMPLATE, f), join(hub, `.${f}`));
30
+ const synced = syncManaged(hub);
31
+ stamp(hub, undefined, {
32
+ ...(opts.name ? { name: opts.name } : {}),
33
+ ...(opts.description ? { description: opts.description } : {}),
34
+ });
35
+ // Put it on the machine's shortcut list straight away, so a bare `khb` from any
36
+ // terminal can find its way back here without the user remembering the path.
37
+ const entry = registerHub(hub);
38
+ return { hub, synced, entry };
39
+ }