@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.
- package/.bundle_template/sources.yaml +1 -0
- package/AGENTS.md +25 -5
- package/README.md +104 -5
- package/SPEC.md +227 -4
- package/document/faq.md +1 -0
- package/package.json +1 -1
- package/scripts/cli.ts +111 -17
- package/scripts/export.ts +6 -1
- package/scripts/hubs.ts +691 -0
- package/scripts/ingest/acquire.ts +56 -13
- package/scripts/ingest/exclude.ts +37 -0
- package/scripts/ingest/exts.ts +82 -1
- package/scripts/ingest/files.ts +15 -4
- package/scripts/ingest/folder.ts +20 -5
- package/scripts/ingest/index.ts +16 -11
- package/scripts/init.ts +40 -13
- package/scripts/lib/args.ts +45 -4
- package/scripts/lib/create.ts +39 -0
- package/scripts/lib/extract.ts +269 -28
- package/scripts/lib/log.ts +40 -0
- package/scripts/lib/registry.ts +306 -0
- package/scripts/lib/relocate.ts +165 -0
- package/scripts/lib/schema.ts +75 -0
- package/scripts/lib/upgrade.ts +181 -12
- package/scripts/lib/util.ts +7 -0
- package/scripts/lint.ts +3 -0
- package/scripts/new-bundle.ts +4 -1
- package/scripts/visualize.ts +15 -10
- package/skills/ingest/SKILL.md +76 -4
- package/templates/hub/gitignore +4 -3
package/scripts/lib/extract.ts
CHANGED
|
@@ -77,8 +77,134 @@ function colIndex(ref: string): number {
|
|
|
77
77
|
return n - 1;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// --- captions --------------------------------------------------------------------------
|
|
81
|
+
//
|
|
82
|
+
// WebVTT and SRT are the same document in two dialects: an optional cue index, a timecode
|
|
83
|
+
// line, then the words. Everything but the words is scaffolding for a player, and reaches
|
|
84
|
+
// curation as noise — a transcript diced into three-second lines, every one of them
|
|
85
|
+
// preceded by a timestamp nobody will read.
|
|
86
|
+
//
|
|
87
|
+
// Two things are worth keeping. *When*, coarsely: a transcript nobody can point into is
|
|
88
|
+
// hard to cite, so anything longer than a chapter gets a heading per interval, enough to
|
|
89
|
+
// find the passage in the recording. And *who*, where the file says so: WebVTT's `<v Name>`
|
|
90
|
+
// is the only speaker attribution that survives from the original, and it is exactly what a
|
|
91
|
+
// meeting or an interview is read for.
|
|
92
|
+
//
|
|
93
|
+
// One thing is worth removing beyond the scaffolding. Auto-generated captions scroll: each
|
|
94
|
+
// cue repeats the tail of the one before it so the viewer sees a stable two-line window.
|
|
95
|
+
// Written down verbatim that doubles the transcript and reads as a stutter, so an
|
|
96
|
+
// overlapping head is trimmed instead of appended twice.
|
|
97
|
+
|
|
98
|
+
/** Seconds per `## h:mm:ss` heading, and the length below which a transcript gets none. */
|
|
99
|
+
const CHAPTER = 300;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Paragraph lengths, in characters. Captions have no paragraphs of their own — five minutes
|
|
103
|
+
* of speech arrives as one unbroken line — so they get made here: break at the first
|
|
104
|
+
* sentence end past SOFT, and give up and break anywhere past HARD, which is what
|
|
105
|
+
* auto-generated captions need, since they carry no punctuation to break on.
|
|
106
|
+
*/
|
|
107
|
+
const SOFT = 600;
|
|
108
|
+
const HARD = 1600;
|
|
109
|
+
|
|
110
|
+
/** "01:02:03.456" or "02:03.456" → seconds. */
|
|
111
|
+
function cueSeconds(t: string): number {
|
|
112
|
+
const m = t.match(/(?:(\d+):)?(\d{1,2}):(\d{2})[.,](\d{1,3})/);
|
|
113
|
+
if (!m) return 0;
|
|
114
|
+
return Number(m[1] ?? 0) * 3600 + Number(m[2]) * 60 + Number(m[3]) + Number(m[4]) / 1000;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A closing `.`, `?` or `!`, with the quote or bracket that may follow it. */
|
|
118
|
+
const SENTENCE_END = /[.!?]["')\]]?$/;
|
|
119
|
+
|
|
120
|
+
function stamp(s: number): string {
|
|
121
|
+
const pad = (n: number) => String(Math.floor(n)).padStart(2, "0");
|
|
122
|
+
return `${Math.floor(s / 3600)}:${pad((s % 3600) / 60)}:${pad(s % 60)}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A cue's payload: its speaker, if the file names one, and its words minus the markup. */
|
|
126
|
+
function cueText(lines: string[]): { speaker?: string; text: string } {
|
|
127
|
+
let speaker: string | undefined;
|
|
128
|
+
const text = lines
|
|
129
|
+
.join(" ")
|
|
130
|
+
// <v Roger Bingham> / <v.loud Esme> — the voice span names who is talking.
|
|
131
|
+
.replace(/<v[^\s>]*\s+([^>]*)>/g, (_, name: string) => {
|
|
132
|
+
speaker ??= name.trim();
|
|
133
|
+
return "";
|
|
134
|
+
})
|
|
135
|
+
// Everything else is presentation: <c> classes, </v>, and the inline <00:00:01.000>
|
|
136
|
+
// stamps that karaoke-style captions put between words.
|
|
137
|
+
.replace(/<[^>]*>/g, "")
|
|
138
|
+
.replace(/ /g, " ");
|
|
139
|
+
return { speaker, text: unxml(text).replace(/\s+/g, " ").trim() };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Append `next` to `text`, dropping the head the two share. Only a whole-word overlap of
|
|
144
|
+
* some length counts: trimming on a coincidental few characters would splice two different
|
|
145
|
+
* words into one, which is worse than the repetition it avoids.
|
|
146
|
+
*/
|
|
147
|
+
function joinOverlap(text: string, next: string): string {
|
|
148
|
+
for (let k = Math.min(next.length, 400); k >= 4; k--) {
|
|
149
|
+
if (!text.endsWith(next.slice(0, k))) continue;
|
|
150
|
+
const before = text[text.length - k - 1];
|
|
151
|
+
const after = next[k];
|
|
152
|
+
if ((before !== undefined && before !== " ") || (after !== undefined && after !== " ")) continue;
|
|
153
|
+
const rest = next.slice(k).trim();
|
|
154
|
+
return rest ? `${text} ${rest}` : text;
|
|
155
|
+
}
|
|
156
|
+
return `${text} ${next}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** WebVTT/SRT → prose. Pure text in, pure text out; no file access, so it is easy to test. */
|
|
160
|
+
export function captionText(raw: string): string {
|
|
161
|
+
type Para = { at: number; speaker?: string; text: string };
|
|
162
|
+
const paras: Para[] = [];
|
|
163
|
+
let open: Para | undefined;
|
|
164
|
+
|
|
165
|
+
for (const block of raw.replace(/\r\n?/g, "\n").split(/\n{2,}/)) {
|
|
166
|
+
const lines = block.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
167
|
+
// WEBVTT is the file signature; NOTE, STYLE and REGION blocks are metadata and CSS.
|
|
168
|
+
if (!lines.length || /^(WEBVTT|NOTE|STYLE|REGION)\b/.test(lines[0])) continue;
|
|
169
|
+
const timed = lines.findIndex((l) => l.includes("-->"));
|
|
170
|
+
if (timed === -1) continue; // no timecode: not a cue
|
|
171
|
+
const at = cueSeconds(lines[timed].split("-->")[0]);
|
|
172
|
+
const { speaker, text } = cueText(lines.slice(timed + 1));
|
|
173
|
+
if (!text) continue;
|
|
174
|
+
// A cue break means nothing — captions are cut to fit a screen, mid-sentence — so cues
|
|
175
|
+
// run together, and only a change of speaker or of chapter forces a new paragraph.
|
|
176
|
+
const newPara =
|
|
177
|
+
!open || open.speaker !== speaker || Math.floor(at / CHAPTER) !== Math.floor(open.at / CHAPTER);
|
|
178
|
+
if (newPara) paras.push((open = { at, speaker, text }));
|
|
179
|
+
else open.text = joinOverlap(open.text, text);
|
|
180
|
+
if (open.text.length >= (SENTENCE_END.test(open.text) ? SOFT : HARD)) open = undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const out: string[] = [];
|
|
184
|
+
let chapter = -1;
|
|
185
|
+
let voice: string | undefined;
|
|
186
|
+
const long = paras.length > 0 && paras[paras.length - 1].at >= CHAPTER;
|
|
187
|
+
for (const p of paras) {
|
|
188
|
+
if (long && Math.floor(p.at / CHAPTER) !== chapter) {
|
|
189
|
+
chapter = Math.floor(p.at / CHAPTER);
|
|
190
|
+
voice = undefined; // name the speaker again under a new heading
|
|
191
|
+
out.push(`## ${stamp(chapter * CHAPTER)}`);
|
|
192
|
+
}
|
|
193
|
+
// Label a turn, not every paragraph of one: a monologue broken for length is still one
|
|
194
|
+
// person talking, and repeating their name down the page reads as a new speaker.
|
|
195
|
+
out.push(p.speaker && p.speaker !== voice ? `**${p.speaker}:** ${p.text}` : p.text);
|
|
196
|
+
voice = p.speaker;
|
|
197
|
+
}
|
|
198
|
+
return out.join("\n\n");
|
|
199
|
+
}
|
|
200
|
+
|
|
80
201
|
/** Built-in, pure-JS extractors. Loaded lazily so `khb init` never pays for them. */
|
|
81
202
|
const LIBRARY: Record<string, (file: string) => Promise<LibResult>> = {
|
|
203
|
+
// Subtitle sidecars. Text already, but wrapped in cue indices and timecodes, so they get
|
|
204
|
+
// an extractor rather than a verbatim copy — and the extraction cache with it, since the
|
|
205
|
+
// same captions beside a re-encoded copy of a video are the same words.
|
|
206
|
+
".vtt": async (file) => ({ text: captionText(readFileSync(file, "utf8")) }),
|
|
207
|
+
".srt": async (file) => ({ text: captionText(readFileSync(file, "utf8")) }),
|
|
82
208
|
".pdf": async (file) => {
|
|
83
209
|
const { extractText, getDocumentProxy } = await import("unpdf");
|
|
84
210
|
const doc = await getDocumentProxy(new Uint8Array(readFileSync(file)));
|
|
@@ -334,48 +460,163 @@ export async function ocrImageCached(path: string, hash: string): Promise<Extrac
|
|
|
334
460
|
}
|
|
335
461
|
}
|
|
336
462
|
|
|
337
|
-
/**
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
463
|
+
/**
|
|
464
|
+
* Is `vno` installed, and is it actually ready to run?
|
|
465
|
+
*
|
|
466
|
+
* `vno status` exists for exactly this: it reports and installs nothing, and exits non-zero
|
|
467
|
+
* when ffmpeg or whisper.cpp or a model is missing — so a vno that is installed but never
|
|
468
|
+
* set up is caught here rather than discovered one silent per-file failure at a time.
|
|
469
|
+
* `--json` turns the same answer into the blocker list that makes khb's message actionable.
|
|
470
|
+
*
|
|
471
|
+
* An older vno predating `status` would fail this check for the wrong reason, so a
|
|
472
|
+
* response that is not JSON at all falls back to the presence test it used to get. That
|
|
473
|
+
* costs a second spawn only on the rare path.
|
|
474
|
+
*/
|
|
475
|
+
type VnoState =
|
|
476
|
+
| { state: "ready" }
|
|
477
|
+
| { state: "unset-up"; blockers: string[] } // installed, but ffmpeg/whisper.cpp/model missing
|
|
478
|
+
| { state: "absent" };
|
|
479
|
+
|
|
480
|
+
async function vnoStatus(): Promise<VnoState> {
|
|
481
|
+
let out: string;
|
|
482
|
+
let code: number;
|
|
483
|
+
try {
|
|
484
|
+
const proc = Bun.spawn(["vno", "status", "--json"], { stdout: "pipe", stderr: "ignore", stdin: "ignore" });
|
|
485
|
+
out = await new Response(proc.stdout).text();
|
|
486
|
+
code = await proc.exited;
|
|
487
|
+
} catch {
|
|
488
|
+
return { state: "absent" }; // not on PATH — expected, and not worth a warning
|
|
489
|
+
}
|
|
490
|
+
if (code === 0) return { state: "ready" };
|
|
491
|
+
try {
|
|
492
|
+
const blockers = JSON.parse(out).blockers;
|
|
493
|
+
return { state: "unset-up", blockers: Array.isArray(blockers) ? blockers.map(String) : [] };
|
|
494
|
+
} catch {
|
|
495
|
+
return (await runCli(["vno", "--version"])) ? { state: "ready" } : { state: "absent" };
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The local speech-to-text engine, probed once per process — spawning a status check per
|
|
501
|
+
* file would cost more than it saves.
|
|
502
|
+
*
|
|
503
|
+
* `vno` (@msareen/voice-notes-organizer) is preferred where it is set up. It is whisper.cpp
|
|
504
|
+
* under a wrapper, so it is markedly faster than the Python whisper on the same audio and
|
|
505
|
+
* uses whatever acceleration the machine has; it installs its own ffmpeg and model; and it
|
|
506
|
+
* emits WebVTT, which the caption reader above turns into a transcript with `## h:mm:ss`
|
|
507
|
+
* anchors instead of an undifferentiated wall of text. Same division of labor either way: a
|
|
508
|
+
* local binary doing a reproducible conversion, contacting no model.
|
|
509
|
+
*
|
|
510
|
+
* A vno that is installed but not set up is an amber gate, never a red one. Nothing about
|
|
511
|
+
* the run stops: whisper takes over if it is there, and if it is not, the recordings pend
|
|
512
|
+
* with an empty `raw` like every other unavailable extractor and the rest of the corpus is
|
|
513
|
+
* ingested regardless. All khb owes the user is an accurate reason and the one command that
|
|
514
|
+
* fixes it — running `vno setup` on their behalf would be khb installing software nobody
|
|
515
|
+
* asked it to install.
|
|
516
|
+
*/
|
|
517
|
+
type Engine = { bin: string; kind: "vno" | "whisper" };
|
|
518
|
+
let engine: Engine | null | undefined;
|
|
519
|
+
let vnoAmber: string | undefined; // set when vno is installed but not set up
|
|
520
|
+
|
|
521
|
+
async function asrEngine(): Promise<Engine | null> {
|
|
522
|
+
if (engine !== undefined) return engine;
|
|
523
|
+
|
|
524
|
+
const vno = await vnoStatus();
|
|
525
|
+
if (vno.state === "unset-up") {
|
|
526
|
+
const missing = vno.blockers.length ? `: ${vno.blockers.join(", ")}` : "";
|
|
527
|
+
vnoAmber = `vno is installed but not set up${missing} — run: vno setup`;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
engine =
|
|
531
|
+
vno.state === "ready" ? { bin: "vno", kind: "vno" }
|
|
532
|
+
: (await runCli(["whisper", "--help"])) ? { bin: "whisper", kind: "whisper" }
|
|
533
|
+
: (await runCli(["faster-whisper", "--help"])) ? { bin: "faster-whisper", kind: "whisper" }
|
|
534
|
+
: null;
|
|
535
|
+
|
|
536
|
+
// Amber: say it once, then carry on with whatever else is available.
|
|
537
|
+
if (vnoAmber) console.warn(` ${vnoAmber}${engine ? ` (using ${engine.bin} instead)` : ""}`);
|
|
538
|
+
if (!engine) {
|
|
539
|
+
console.warn(` no transcriber ready — audio and video skipped, everything else proceeds. Install either:`);
|
|
540
|
+
if (!vnoAmber) console.warn(` npm install -g @msareen/voice-notes-organizer (whisper.cpp; installs its own deps)`);
|
|
541
|
+
console.warn(` pip install -U openai-whisper`);
|
|
542
|
+
}
|
|
543
|
+
return engine;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* `vno t <file> -o <out> --no-open` — the one-shot path: no picker, no model prompt, and no
|
|
548
|
+
* requirement that the file live in vno's own library. The model is left to the user's vno
|
|
549
|
+
* settings rather than pinned here, since those are also where their acceleration lives.
|
|
550
|
+
*
|
|
551
|
+
* stdin is closed deliberately. vno offers to install a missing ffmpeg or whisper.cpp, and
|
|
552
|
+
* checks `isTTY` before asking — so a closed stdin turns that offer into printed
|
|
553
|
+
* instructions rather than a prompt with nobody there to answer it.
|
|
554
|
+
*/
|
|
555
|
+
async function transcribeVno(path: string, dest: string, out: string): Promise<Extraction> {
|
|
556
|
+
const tool = "vno (whisper.cpp)";
|
|
557
|
+
const vtt = join(out, "transcript.vtt");
|
|
558
|
+
const proc = Bun.spawn(["vno", "t", path, "-o", vtt, "--no-open"], {
|
|
559
|
+
stdout: "ignore",
|
|
560
|
+
stderr: "ignore",
|
|
561
|
+
stdin: "ignore",
|
|
562
|
+
});
|
|
563
|
+
await proc.exited;
|
|
564
|
+
// vno reports a missing file or an unusable dependency on stdout and still exits 0, so
|
|
565
|
+
// the transcript existing is the only signal worth trusting here.
|
|
566
|
+
if (!existsSync(vtt)) return { status: "failed", reason: "vno produced no transcript" };
|
|
567
|
+
const text = captionText(readFileSync(vtt, "utf8"));
|
|
568
|
+
if (!text) return { status: "failed", reason: "empty transcript" };
|
|
569
|
+
return { status: "ok", path: writeCache(dest, path, tool, "low", text), tool, quality: "low" };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** OpenAI whisper (or faster-whisper): plain text, written into a scratch --output_dir. */
|
|
573
|
+
async function transcribeWhisper(
|
|
574
|
+
path: string,
|
|
575
|
+
dest: string,
|
|
576
|
+
out: string,
|
|
577
|
+
bin: string,
|
|
578
|
+
model: string,
|
|
579
|
+
): Promise<Extraction> {
|
|
580
|
+
const tool = `${bin} (${model})`;
|
|
581
|
+
const proc = Bun.spawn([bin, path, "--model", model, "--output_format", "txt", "--output_dir", out], {
|
|
582
|
+
stdout: "ignore",
|
|
583
|
+
stderr: "ignore",
|
|
584
|
+
stdin: "ignore",
|
|
585
|
+
});
|
|
586
|
+
if ((await proc.exited) !== 0) return { status: "failed", reason: `${bin} exited non-zero` };
|
|
587
|
+
const txt = readdirSync(out).find((f) => f.endsWith(".txt"));
|
|
588
|
+
if (!txt) return { status: "failed", reason: `${bin} produced no transcript` };
|
|
589
|
+
const text = readFileSync(join(out, txt), "utf8").trim();
|
|
590
|
+
if (!text) return { status: "failed", reason: "empty transcript" };
|
|
591
|
+
return { status: "ok", path: writeCache(dest, path, tool, "low", text), tool, quality: "low" };
|
|
346
592
|
}
|
|
347
593
|
|
|
348
594
|
/**
|
|
349
|
-
* Transcribe audio or video
|
|
350
|
-
*
|
|
595
|
+
* Transcribe audio or video locally. Video needs no demux step — both engines read the
|
|
596
|
+
* audio track directly.
|
|
351
597
|
*
|
|
352
598
|
* Minutes of CPU per file, so this is the one extractor worth interrupting: `khb ingest
|
|
353
|
-
* --skip-audio` leaves the rows pending and everything else proceeds.
|
|
354
|
-
*
|
|
599
|
+
* --skip-audio` leaves the rows pending and everything else proceeds. A recording that
|
|
600
|
+
* arrived with captions beside it never gets here at all; ingest reads those instead.
|
|
355
601
|
*/
|
|
356
602
|
export async function transcribeCached(path: string, hash: string, model = "base"): Promise<Extraction> {
|
|
357
603
|
const dest = extractedPath(hash);
|
|
358
604
|
if (existsSync(dest)) return cacheHit(dest);
|
|
359
605
|
|
|
360
|
-
const
|
|
361
|
-
|
|
606
|
+
const eng = await asrEngine();
|
|
607
|
+
// The pending row carries the reason a person can act on: "not set up" and "not installed"
|
|
608
|
+
// have different fixes, and the ledger is where this is read back weeks later.
|
|
609
|
+
if (!eng) return { status: "failed", reason: vnoAmber ?? "no transcriber installed" };
|
|
610
|
+
note(`transcribing with ${eng.bin}`);
|
|
362
611
|
|
|
363
|
-
//
|
|
364
|
-
//
|
|
612
|
+
// Both engines write files rather than to stdout; give each run a scratch directory of
|
|
613
|
+
// its own so a stray sibling transcript is never mistaken for this one's.
|
|
365
614
|
const out = join(INBOX, "tmp", hash.slice(0, 12));
|
|
366
615
|
mkdirSync(out, { recursive: true });
|
|
367
|
-
const tool = `${bin} (${model})`;
|
|
368
616
|
try {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
});
|
|
373
|
-
if ((await proc.exited) !== 0) return { status: "failed", reason: `${bin} exited non-zero` };
|
|
374
|
-
const txt = readdirSync(out).find((f) => f.endsWith(".txt"));
|
|
375
|
-
if (!txt) return { status: "failed", reason: `${bin} produced no transcript` };
|
|
376
|
-
const text = readFileSync(join(out, txt), "utf8").trim();
|
|
377
|
-
if (!text) return { status: "failed", reason: "empty transcript" };
|
|
378
|
-
return { status: "ok", path: writeCache(dest, path, tool, "low", text), tool, quality: "low" };
|
|
617
|
+
return eng.kind === "vno"
|
|
618
|
+
? await transcribeVno(path, dest, out)
|
|
619
|
+
: await transcribeWhisper(path, dest, out, eng.bin, model);
|
|
379
620
|
} catch (e) {
|
|
380
621
|
return { status: "failed", reason: `transcription failed: ${e}` };
|
|
381
622
|
} finally {
|
package/scripts/lib/log.ts
CHANGED
|
@@ -51,3 +51,43 @@ export function note(msg: string) {
|
|
|
51
51
|
export function outcome(msg: string) {
|
|
52
52
|
console.log(` ${msg} (${secs(Date.now() - itemStart)})`);
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A running counter for a walk whose units are too fast and too many to deserve a line
|
|
57
|
+
* each — thousands of files checked in milliseconds, where `item()` per file would bury
|
|
58
|
+
* the result in its own progress.
|
|
59
|
+
*
|
|
60
|
+
* On a terminal it rewrites one line in place, on **stderr**: the transient text never
|
|
61
|
+
* lands in a redirected log or a pipe, so `khb … > out.txt` still gets clean output. With
|
|
62
|
+
* no terminal it degrades to a milestone line every `every` units, so a CI log or a
|
|
63
|
+
* captured run still shows the walk moving rather than appearing hung.
|
|
64
|
+
*
|
|
65
|
+
* Always call `done()`, including on the early-exit paths — it is what erases the line.
|
|
66
|
+
*/
|
|
67
|
+
export function ticker(label: string, total?: number, every = 500) {
|
|
68
|
+
const tty = process.stderr.isTTY;
|
|
69
|
+
const of = total ? `/${total}` : "";
|
|
70
|
+
let n = 0;
|
|
71
|
+
let lastPaint = 0;
|
|
72
|
+
let width = 0;
|
|
73
|
+
return {
|
|
74
|
+
tick(suffix = "") {
|
|
75
|
+
n++;
|
|
76
|
+
const text = ` ${label} ${n}${of}${suffix ? ` — ${suffix}` : ""}`;
|
|
77
|
+
if (tty) {
|
|
78
|
+
// Repaint at ~12fps, not per unit: the terminal is slower than the walk, and an
|
|
79
|
+
// unthrottled counter spends more time drawing than working.
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
if (now - lastPaint < 80 && n !== total) return;
|
|
82
|
+
lastPaint = now;
|
|
83
|
+
width = Math.max(width, text.length);
|
|
84
|
+
process.stderr.write(`\r${text.padEnd(width)}`);
|
|
85
|
+
} else if (n % every === 0) {
|
|
86
|
+
console.log(text);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
done() {
|
|
90
|
+
if (tty && width) process.stderr.write(`\r${" ".repeat(width)}\r`);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// The machine-level registry: ~/.khb/hubs-config.json.
|
|
2
|
+
//
|
|
3
|
+
// A hub is self-contained and knows nothing about the machine it sits on — that is the
|
|
4
|
+
// point of the marker file. But a person with hubs in three places has no way to find
|
|
5
|
+
// them from a cold terminal, so khb keeps one small file per *machine* listing where the
|
|
6
|
+
// hubs are and which agent to open them with. It holds no knowledge, only paths: delete
|
|
7
|
+
// it and nothing is lost but the shortcuts.
|
|
8
|
+
//
|
|
9
|
+
// Package-side, like paths.ts — importing this must never require a hub to exist.
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, realpathSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join, resolve, basename } from "node:path";
|
|
13
|
+
import { markerIn } from "./paths";
|
|
14
|
+
|
|
15
|
+
/** $KHB_HOME overrides the location — tests and portable installs need it moveable. */
|
|
16
|
+
export const KHB_HOME = process.env.KHB_HOME ? resolve(process.env.KHB_HOME) : join(homedir(), ".khb");
|
|
17
|
+
export const CONFIG = join(KHB_HOME, "hubs-config.json");
|
|
18
|
+
|
|
19
|
+
/** How to start an agent in a hub folder. `args` is appended after the hub is cd'd into. */
|
|
20
|
+
export type AgentSpec = { command: string; args?: string[] };
|
|
21
|
+
|
|
22
|
+
export type HubEntry = {
|
|
23
|
+
name: string;
|
|
24
|
+
description: string;
|
|
25
|
+
path: string;
|
|
26
|
+
added: string;
|
|
27
|
+
lastUsed?: string;
|
|
28
|
+
/**
|
|
29
|
+
* The `created` stamp from the hub's own marker. Copied here purely as an identity
|
|
30
|
+
* fingerprint: after a hub is moved, its registry entry points at nothing, and matching
|
|
31
|
+
* this against the marker at the new location is how `khb update --path` knows which dead entry
|
|
32
|
+
* is the same hub rather than a different one that also went missing.
|
|
33
|
+
*/
|
|
34
|
+
created?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type Config = {
|
|
38
|
+
version: 1;
|
|
39
|
+
/** Key into `agents`. Empty string means "never launch anything, just show the path". */
|
|
40
|
+
defaultAgent: string;
|
|
41
|
+
agents: Record<string, AgentSpec>;
|
|
42
|
+
hubs: HubEntry[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Shipped defaults. Both are the plain binary name — resolved on PATH at launch time, so
|
|
47
|
+
* a machine without one installed simply fails at spawn with its own error rather than
|
|
48
|
+
* khb pretending to know where it lives.
|
|
49
|
+
*/
|
|
50
|
+
const DEFAULT_AGENTS: Record<string, AgentSpec> = {
|
|
51
|
+
claude: { command: "claude", args: [] },
|
|
52
|
+
codex: { command: "codex", args: [] },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const blank = (): Config => ({
|
|
56
|
+
version: 1,
|
|
57
|
+
defaultAgent: "claude",
|
|
58
|
+
agents: { ...DEFAULT_AGENTS },
|
|
59
|
+
hubs: [],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read the registry, creating it on first run. Never throws on a damaged file: a
|
|
64
|
+
* corrupted shortcut list must not block `khb lint` in a hub that is perfectly fine, so
|
|
65
|
+
* unreadable JSON is reported once and treated as empty.
|
|
66
|
+
*/
|
|
67
|
+
export function loadConfig(): Config {
|
|
68
|
+
if (!existsSync(CONFIG)) {
|
|
69
|
+
const fresh = blank();
|
|
70
|
+
saveConfig(fresh);
|
|
71
|
+
return fresh;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const raw = JSON.parse(readFileSync(CONFIG, "utf8")) as Partial<Config>;
|
|
75
|
+
return {
|
|
76
|
+
version: 1,
|
|
77
|
+
defaultAgent: raw.defaultAgent ?? "claude",
|
|
78
|
+
agents: { ...DEFAULT_AGENTS, ...(raw.agents ?? {}) },
|
|
79
|
+
hubs: Array.isArray(raw.hubs) ? raw.hubs.filter((h) => h && typeof h.path === "string") : [],
|
|
80
|
+
};
|
|
81
|
+
} catch {
|
|
82
|
+
console.error(`khb: could not read ${CONFIG} — ignoring it. Fix or delete the file.`);
|
|
83
|
+
return blank();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function saveConfig(cfg: Config): void {
|
|
88
|
+
mkdirSync(KHB_HOME, { recursive: true });
|
|
89
|
+
writeFileSync(CONFIG, JSON.stringify(cfg, null, 2) + "\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The one true spelling of a path. `resolve` is not enough on Windows, where the same
|
|
94
|
+
* folder is reachable as `C:\Users\MANASV~1\…` and `C:\Users\Manasvi Sareen\…` with
|
|
95
|
+
* different casing again on top — three strings, one directory. Registering it under two
|
|
96
|
+
* of them would list one hub twice and defeat the move repair, so every path stored in or
|
|
97
|
+
* compared against the registry goes through here first.
|
|
98
|
+
*
|
|
99
|
+
* realpath also follows symlinks, which is the behaviour we want: a hub reached through a
|
|
100
|
+
* link is the same hub as the hub itself.
|
|
101
|
+
*/
|
|
102
|
+
export function canonical(p: string): string {
|
|
103
|
+
const abs = resolve(p);
|
|
104
|
+
try {
|
|
105
|
+
return realpathSync.native(abs);
|
|
106
|
+
} catch {
|
|
107
|
+
return abs; // path does not exist (a moved-away hub): the resolved form is the best we have
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Case-insensitive on Windows, after canonicalizing — see `canonical`. */
|
|
112
|
+
const samePath = (a: string, b: string) => {
|
|
113
|
+
const [x, y] = [canonical(a), canonical(b)];
|
|
114
|
+
return process.platform === "win32" ? x.toLowerCase() === y.toLowerCase() : x === y;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Whatever the hub's own marker says about itself. Unreadable marker → nothing known. */
|
|
118
|
+
export function markerFields(hub: string): { name?: string; description?: string; created?: string } {
|
|
119
|
+
const marker = markerIn(hub);
|
|
120
|
+
if (!marker) return {};
|
|
121
|
+
try {
|
|
122
|
+
const j = JSON.parse(readFileSync(join(hub, marker), "utf8"));
|
|
123
|
+
const str = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
|
|
124
|
+
return { name: str(j.name), description: str(j.description), created: str(j.created) };
|
|
125
|
+
} catch {
|
|
126
|
+
return {}; // a hub with an unreadable marker still deserves a listing
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A one-line description for a hub. Authored in the hub's own `khb.json` (`description`),
|
|
132
|
+
* so it travels with the hub and is not a second place to maintain the same sentence;
|
|
133
|
+
* with none, describe the hub by what is in it. Deliberately does not scrape
|
|
134
|
+
* `outer.index.md` — its opening line is template boilerplate identical in every hub.
|
|
135
|
+
*/
|
|
136
|
+
export function describeHub(hub: string): string {
|
|
137
|
+
const own = markerFields(hub).description;
|
|
138
|
+
if (own) return own;
|
|
139
|
+
const names = bundleNames(hub);
|
|
140
|
+
if (!names.length) return "no bundles yet";
|
|
141
|
+
const shown = names.slice(0, 4).join(", ");
|
|
142
|
+
return `${names.length} bundle${names.length === 1 ? "" : "s"}: ${shown}${names.length > 4 ? ", …" : ""}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function bundleNames(hub: string): string[] {
|
|
146
|
+
try {
|
|
147
|
+
return readdirSync(join(hub, "bundles"), { withFileTypes: true })
|
|
148
|
+
.filter((d) => d.isDirectory())
|
|
149
|
+
.map((d) => d.name);
|
|
150
|
+
} catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Unique registry name for a hub: what its marker calls itself, else the folder name,
|
|
157
|
+
* suffixed if two hubs on this machine want the same one.
|
|
158
|
+
*/
|
|
159
|
+
function uniqueName(cfg: Config, hub: string): string {
|
|
160
|
+
const base = markerFields(hub).name ?? basename(hub) ?? "hub";
|
|
161
|
+
if (!cfg.hubs.some((h) => h.name === base)) return base;
|
|
162
|
+
for (let i = 2; ; i++) if (!cfg.hubs.some((h) => h.name === `${base}-${i}`)) return `${base}-${i}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Record a hub in the registry, or refresh what is already recorded. Called whenever khb
|
|
167
|
+
* resolves a hub, so hubs created before the registry existed register themselves the
|
|
168
|
+
* first time any command runs in them — no migration step, no `khb register` to remember.
|
|
169
|
+
* A user-edited name is never overwritten; the description is, since it is derived.
|
|
170
|
+
*/
|
|
171
|
+
export function registerHub(hub: string, opts: { name?: string; description?: string } = {}): HubEntry {
|
|
172
|
+
const path = canonical(hub);
|
|
173
|
+
const cfg = loadConfig();
|
|
174
|
+
const own = markerFields(path);
|
|
175
|
+
let entry = cfg.hubs.find((h) => samePath(resolve(h.path), path));
|
|
176
|
+
if (entry) {
|
|
177
|
+
entry.path = path;
|
|
178
|
+
// Both fields are derived from the hub, so both re-derive: rename or re-describe a hub
|
|
179
|
+
// in its own khb.json and the listing follows on the next command run there.
|
|
180
|
+
if (opts.name ?? own.name) entry.name = opts.name ?? own.name!;
|
|
181
|
+
entry.description = opts.description ?? describeHub(path);
|
|
182
|
+
entry.created = own.created;
|
|
183
|
+
} else {
|
|
184
|
+
entry = {
|
|
185
|
+
name: opts.name ?? uniqueName(cfg, path),
|
|
186
|
+
description: opts.description ?? describeHub(path),
|
|
187
|
+
path,
|
|
188
|
+
added: new Date().toISOString(),
|
|
189
|
+
created: own.created,
|
|
190
|
+
};
|
|
191
|
+
cfg.hubs.push(entry);
|
|
192
|
+
}
|
|
193
|
+
saveConfig(cfg);
|
|
194
|
+
return entry;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Stamp a hub as most recently used, so the picker can order by recency. */
|
|
198
|
+
export function touchHub(hub: string): void {
|
|
199
|
+
const path = canonical(hub);
|
|
200
|
+
const cfg = loadConfig();
|
|
201
|
+
const entry = cfg.hubs.find((h) => samePath(resolve(h.path), path));
|
|
202
|
+
if (!entry) return;
|
|
203
|
+
entry.lastUsed = new Date().toISOString();
|
|
204
|
+
saveConfig(cfg);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function forgetHub(nameOrPath: string): HubEntry | undefined {
|
|
208
|
+
const cfg = loadConfig();
|
|
209
|
+
const i = cfg.hubs.findIndex(
|
|
210
|
+
(h) => h.name === nameOrPath || samePath(resolve(h.path), resolve(nameOrPath)),
|
|
211
|
+
);
|
|
212
|
+
if (i < 0) return undefined;
|
|
213
|
+
const [gone] = cfg.hubs.splice(i, 1);
|
|
214
|
+
saveConfig(cfg);
|
|
215
|
+
return gone;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A registered hub whose folder no longer holds a marker — moved, deleted, or unmounted. */
|
|
219
|
+
export const isAlive = (h: HubEntry): boolean => existsSync(h.path) && !!markerIn(h.path);
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Dead entries that are plausibly `hub` under its former name, best evidence first:
|
|
223
|
+
* an identical `created` stamp is proof (it is minted once, at `khb init`), a matching
|
|
224
|
+
* registry name is a guess. Returning candidates rather than picking one is deliberate —
|
|
225
|
+
* `khb update --path` acts on proof and asks when it only has a guess.
|
|
226
|
+
*/
|
|
227
|
+
export function relocationCandidates(hub: string): { certain: HubEntry[]; likely: HubEntry[] } {
|
|
228
|
+
const path = canonical(hub);
|
|
229
|
+
const own = markerFields(path);
|
|
230
|
+
const dead = loadConfig().hubs.filter((h) => !isAlive(h) && !samePath(resolve(h.path), path));
|
|
231
|
+
const certain = own.created ? dead.filter((h) => h.created === own.created) : [];
|
|
232
|
+
const likely = dead.filter(
|
|
233
|
+
(h) => !certain.includes(h) && h.name === (own.name ?? basename(path)),
|
|
234
|
+
);
|
|
235
|
+
return { certain, likely };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Point a registry entry at where the hub now lives. If the new location was already
|
|
240
|
+
* registered separately — a `khb` command was run there before anyone repaired the move —
|
|
241
|
+
* the two entries are the same hub, so fold the older one's history into the survivor
|
|
242
|
+
* rather than leaving a duplicate behind.
|
|
243
|
+
*/
|
|
244
|
+
export function relocateHub(oldPath: string, newPath: string): HubEntry {
|
|
245
|
+
const from = canonical(oldPath);
|
|
246
|
+
const to = canonical(newPath);
|
|
247
|
+
const cfg = loadConfig();
|
|
248
|
+
const stale = cfg.hubs.find((h) => samePath(resolve(h.path), from));
|
|
249
|
+
const already = cfg.hubs.find((h) => samePath(resolve(h.path), to));
|
|
250
|
+
|
|
251
|
+
const survivor = stale ?? already ?? {
|
|
252
|
+
name: uniqueName(cfg, to),
|
|
253
|
+
description: describeHub(to),
|
|
254
|
+
path: to,
|
|
255
|
+
added: new Date().toISOString(),
|
|
256
|
+
};
|
|
257
|
+
if (stale && already && stale !== already) {
|
|
258
|
+
survivor.added = [stale.added, already.added].filter(Boolean).sort()[0];
|
|
259
|
+
survivor.lastUsed = [stale.lastUsed, already.lastUsed].filter(Boolean).sort().pop();
|
|
260
|
+
cfg.hubs.splice(cfg.hubs.indexOf(already), 1);
|
|
261
|
+
}
|
|
262
|
+
if (!cfg.hubs.includes(survivor)) cfg.hubs.push(survivor);
|
|
263
|
+
|
|
264
|
+
survivor.path = to;
|
|
265
|
+
const own = markerFields(to);
|
|
266
|
+
// A name the marker states is the hub's own and survives the move. A name that was only
|
|
267
|
+
// ever the old folder's would otherwise outlive the folder — re-derive it from the new
|
|
268
|
+
// location, so the listing does not go on calling a hub after a directory that is gone.
|
|
269
|
+
if (own.name) survivor.name = own.name;
|
|
270
|
+
else if (survivor.name === basename(from)) {
|
|
271
|
+
const taken = cfg.hubs.filter((h) => h !== survivor);
|
|
272
|
+
survivor.name = uniqueName({ ...cfg, hubs: taken }, to);
|
|
273
|
+
}
|
|
274
|
+
survivor.description = describeHub(to);
|
|
275
|
+
survivor.created = own.created;
|
|
276
|
+
saveConfig(cfg);
|
|
277
|
+
return survivor;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Registered hubs, live ones first, each group most-recently-used first. */
|
|
281
|
+
export function listHubs(): HubEntry[] {
|
|
282
|
+
const recency = (h: HubEntry) => Date.parse(h.lastUsed ?? h.added) || 0;
|
|
283
|
+
return loadConfig().hubs.slice().sort((a, b) => {
|
|
284
|
+
if (isAlive(a) !== isAlive(b)) return isAlive(a) ? -1 : 1;
|
|
285
|
+
return recency(b) - recency(a);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Resolve `khb go <what>` — a registry name, a 1-based list position, or a path. */
|
|
290
|
+
export function findHubEntry(what: string): HubEntry | undefined {
|
|
291
|
+
const hubs = listHubs();
|
|
292
|
+
const byName = hubs.find((h) => h.name === what);
|
|
293
|
+
if (byName) return byName;
|
|
294
|
+
const n = Number(what);
|
|
295
|
+
if (Number.isInteger(n) && n >= 1 && n <= hubs.length) return hubs[n - 1];
|
|
296
|
+
return hubs.find((h) => samePath(resolve(h.path), resolve(what)));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function agentFor(cfg: Config, name?: string): { name: string; spec: AgentSpec } | undefined {
|
|
300
|
+
const key = name ?? cfg.defaultAgent;
|
|
301
|
+
if (!key) return undefined;
|
|
302
|
+
const spec = cfg.agents[key];
|
|
303
|
+
// An unknown name is still usable as a bare command — someone naming an agent khb has
|
|
304
|
+
// never heard of should get their agent, not a lecture about the config file.
|
|
305
|
+
return { name: key, spec: spec ?? { command: key, args: [] } };
|
|
306
|
+
}
|