@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
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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" : "
|
|
157
|
-
res = await transcribeCached(path,
|
|
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
|
-
|
|
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} [${
|
|
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
|
+
}
|
package/scripts/ingest/exts.ts
CHANGED
|
@@ -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
|
+
}
|
package/scripts/ingest/files.ts
CHANGED
|
@@ -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
|
|
20
|
-
const at = pos(i + 1,
|
|
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,
|
|
37
|
+
await acquireFile(at, p, basename(p), rawDir, bundleDir, entries, c, scoped);
|
|
27
38
|
}
|
|
28
39
|
report(c);
|
|
29
40
|
}
|
package/scripts/ingest/folder.ts
CHANGED
|
@@ -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
|
|
34
|
-
detail(`${
|
|
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
|
|
41
|
-
await acquireFile(pos(i + 1, files.length), p, rel, rawDir, bundleDir, entries, c,
|
|
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
|
}
|
package/scripts/ingest/index.ts
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
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(
|
|
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(
|
|
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 {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
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
|
}
|
package/scripts/lib/args.ts
CHANGED
|
@@ -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[],
|
|
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
|
|
6
|
-
args
|
|
7
|
-
|
|
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
|
+
}
|