@msareen/knowledge-hub-builder 0.1.3
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/.agents/skills/catalog/SKILL.md +7 -0
- package/.agents/skills/export/SKILL.md +7 -0
- package/.agents/skills/ingest/SKILL.md +7 -0
- package/.agents/skills/lint/SKILL.md +7 -0
- package/.agents/skills/new-bundle/SKILL.md +7 -0
- package/.agents/skills/query/SKILL.md +7 -0
- package/.agents/skills/visualize/SKILL.md +7 -0
- package/.bundle_template/index.md +9 -0
- package/.bundle_template/log.md +10 -0
- package/.bundle_template/raw/.gitkeep +15 -0
- package/.bundle_template/refs.md +6 -0
- package/.bundle_template/sources.yaml +13 -0
- package/.claude/skills/catalog/SKILL.md +7 -0
- package/.claude/skills/export/SKILL.md +7 -0
- package/.claude/skills/ingest/SKILL.md +7 -0
- package/.claude/skills/lint/SKILL.md +7 -0
- package/.claude/skills/new-bundle/SKILL.md +7 -0
- package/.claude/skills/query/SKILL.md +7 -0
- package/.claude/skills/visualize/SKILL.md +7 -0
- package/AGENTS.md +167 -0
- package/CLAUDE.md +13 -0
- package/README.md +289 -0
- package/SPEC.md +354 -0
- package/document/faq.md +156 -0
- package/package.json +52 -0
- package/scripts/cli.ts +66 -0
- package/scripts/export.ts +42 -0
- package/scripts/ingest/acquire.ts +189 -0
- package/scripts/ingest/exts.ts +29 -0
- package/scripts/ingest/files.ts +29 -0
- package/scripts/ingest/folder.ts +44 -0
- package/scripts/ingest/index.ts +125 -0
- package/scripts/ingest/protect.ts +42 -0
- package/scripts/ingest/web.ts +54 -0
- package/scripts/init.ts +93 -0
- package/scripts/lib/args.ts +8 -0
- package/scripts/lib/extract.ts +384 -0
- package/scripts/lib/graph-page.ts +477 -0
- package/scripts/lib/graph.ts +117 -0
- package/scripts/lib/ledger.ts +136 -0
- package/scripts/lib/log.ts +53 -0
- package/scripts/lib/paths.ts +55 -0
- package/scripts/lib/scaffold.ts +56 -0
- package/scripts/lib/util.ts +154 -0
- package/scripts/lint.ts +165 -0
- package/scripts/new-bundle.ts +17 -0
- package/scripts/visualize.ts +104 -0
- package/skills/catalog/SKILL.md +164 -0
- package/skills/export/SKILL.md +31 -0
- package/skills/ingest/SKILL.md +229 -0
- package/skills/lint/SKILL.md +69 -0
- package/skills/new-bundle/SKILL.md +25 -0
- package/skills/query/SKILL.md +114 -0
- package/skills/visualize/SKILL.md +33 -0
- package/templates/hub/gitattributes +12 -0
- package/templates/hub/gitignore +12 -0
- package/templates/hub/outer.index.md +13 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// One local file → one raw/*.md, fully extracted.
|
|
2
|
+
//
|
|
3
|
+
// This is the whole of ingest's judgement: pick the extractor by file kind, run it, write
|
|
4
|
+
// the text with provenance. Every acquisition path (folder walk, explicit file list) funnels
|
|
5
|
+
// through here so a `.pdf` behaves identically however it was named, and so there is exactly
|
|
6
|
+
// one place that decides what "acquired" means.
|
|
7
|
+
//
|
|
8
|
+
// Nothing here interprets content. Ingest ends the moment bytes have become text — deciding
|
|
9
|
+
// what the text *says* is the catalog pass (skills/catalog/SKILL.md).
|
|
10
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
11
|
+
import { basename } from "node:path";
|
|
12
|
+
import { writeRaw, sha256File, rawNameFor, retargetRaw } from "../lib/util";
|
|
13
|
+
import { record, isFresh, identify, adopt, type Entry } from "../lib/ledger";
|
|
14
|
+
import {
|
|
15
|
+
extractCached, ocrCached, ocrImageCached, transcribeCached,
|
|
16
|
+
extractedBody, extractedPath, type Extraction,
|
|
17
|
+
} from "../lib/extract";
|
|
18
|
+
import { kindOf, extOf, mdName } from "./exts";
|
|
19
|
+
import { detectPasswordProtected, PROTECTABLE } from "./protect";
|
|
20
|
+
import { item, note, outcome } from "../lib/log";
|
|
21
|
+
|
|
22
|
+
export type Options = {
|
|
23
|
+
force: boolean; // re-acquire even when the content hash is unchanged
|
|
24
|
+
ocr: boolean; // OCR scanned PDFs and images (default on; ~seconds/page)
|
|
25
|
+
audio: boolean; // transcribe audio/video (default on; ~minutes/file)
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type Counters = {
|
|
29
|
+
copied: number; // text files, taken verbatim
|
|
30
|
+
extracted: number; // converted this run
|
|
31
|
+
fromCache: number; // converted by an earlier run or another bundle
|
|
32
|
+
ocrd: number;
|
|
33
|
+
transcribed: number;
|
|
34
|
+
lowQuality: number; // OCR/ASR output — worth re-reading from source during curation
|
|
35
|
+
skipped: number; // unchanged since last ingest
|
|
36
|
+
moved: number; // same bytes at a new path — row re-pointed, nothing re-extracted
|
|
37
|
+
// Everything that did not become a raw/ file — protected, unreadable, no extractor, or
|
|
38
|
+
// skipped by a flag. One bucket, not one per cause: the per-file line above already gave
|
|
39
|
+
// the reason, and splitting it into `pending` + `failed` only ever double-counted.
|
|
40
|
+
pending: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const newCounters = (): Counters => ({
|
|
44
|
+
copied: 0, extracted: 0, fromCache: 0, ocrd: 0, transcribed: 0,
|
|
45
|
+
lowQuality: 0, skipped: 0, moved: 0, pending: 0,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** Record the source with an empty `raw` so the owed work survives this process. */
|
|
49
|
+
function pend(entries: Map<string, Entry>, path: string, hash: string, c: Counters, why: string) {
|
|
50
|
+
record(entries, { source: path, sha256: hash, fetched: new Date().toISOString(), raw: "" });
|
|
51
|
+
c.pending++;
|
|
52
|
+
// The path is already on this item's own line; say only why it stopped.
|
|
53
|
+
outcome(`pending — ${why}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function acquireFile(
|
|
57
|
+
at: string,
|
|
58
|
+
path: string,
|
|
59
|
+
name: string,
|
|
60
|
+
rawDir: string,
|
|
61
|
+
bundleDir: string,
|
|
62
|
+
entries: Map<string, Entry>,
|
|
63
|
+
c: Counters,
|
|
64
|
+
opts: Options,
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
// Announce the file first: hashing a multi-GB binary and every extractor below can take
|
|
67
|
+
// real time, and a run that printed only successes left the slow file unnamed.
|
|
68
|
+
item(at, path);
|
|
69
|
+
const kind = kindOf(path);
|
|
70
|
+
const hash = await sha256File(path);
|
|
71
|
+
if (kind === "skip") {
|
|
72
|
+
pend(entries, path, hash, c, "no extractor for this format");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
// Identity before freshness. A file that moved is the row it already has: resolve that
|
|
76
|
+
// first, or it reads as an unrelated arrival and earns a second raw/ file, a second row
|
|
77
|
+
// with an empty `curated`, and eventually a second concept for material already written.
|
|
78
|
+
let adopted: Entry | undefined;
|
|
79
|
+
const id = identify(entries, bundleDir, path, hash);
|
|
80
|
+
if (id.kind === "moved") {
|
|
81
|
+
adopted = adopt(entries, id.from, path);
|
|
82
|
+
retargetRaw(bundleDir, adopted.raw, path);
|
|
83
|
+
c.moved++;
|
|
84
|
+
outcome(`moved from ${id.from.source} — kept ${adopted.raw}${adopted.curated ? " and its catalog entry" : ""}`);
|
|
85
|
+
if (!opts.force) return;
|
|
86
|
+
} else if (id.kind === "copy") {
|
|
87
|
+
note(`same bytes as ${id.twin.source}, which is also still on disk — ingesting as its own source`);
|
|
88
|
+
} else if (id.kind === "ambiguous") {
|
|
89
|
+
note(`same bytes as ${id.twins.length} rows whose sources have all gone — not guessing which one moved here`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!opts.force && isFresh(entries, bundleDir, path, hash)) {
|
|
93
|
+
c.skipped++;
|
|
94
|
+
outcome("unchanged, skipped");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A source keeps its raw/ filename for life. Deriving the name from the path again on
|
|
99
|
+
// every run means a file that moved and *then* changed re-extracts under a new name,
|
|
100
|
+
// stranding the old raw file with the concept's Citations still pointing at it. Only fall
|
|
101
|
+
// back to a fresh name when this source has no raw file in this directory yet.
|
|
102
|
+
const held = adopted ?? entries.get(path);
|
|
103
|
+
const file =
|
|
104
|
+
held?.raw?.startsWith(`raw/${basename(rawDir)}/`)
|
|
105
|
+
? basename(held.raw)
|
|
106
|
+
: rawNameFor(rawDir, mdName(name), path, entries.values());
|
|
107
|
+
const stamp = (raw: string) => record(entries, { source: path, sha256: hash, fetched: new Date().toISOString(), raw });
|
|
108
|
+
|
|
109
|
+
if (kind === "text") {
|
|
110
|
+
const raw = writeRaw(rawDir, file, { source: path, sha256: hash.slice(0, 12), tool: "copy", quality: "high" }, readFileSync(path, "utf8"));
|
|
111
|
+
stamp(raw);
|
|
112
|
+
c.copied++;
|
|
113
|
+
outcome(`copied → ${raw}`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A password-protected document defeats every extractor identically, and finding that out
|
|
118
|
+
// costs a byte-level peek instead of a failed conversion. Say so rather than reporting a
|
|
119
|
+
// mystery empty file — the remedy (supply the password, re-export) is the user's.
|
|
120
|
+
const ext = extOf(path);
|
|
121
|
+
if (PROTECTABLE.has(ext) && detectPasswordProtected(path, ext, Bun.file(path).size)) {
|
|
122
|
+
pend(entries, path, hash, c, "password-protected");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const hit = existsSync(extractedPath(hash));
|
|
127
|
+
let res: Extraction;
|
|
128
|
+
|
|
129
|
+
if (kind === "doc") {
|
|
130
|
+
note(hit ? `${ext.slice(1)} — reusing cached extraction` : `extracting ${ext.slice(1)} …`);
|
|
131
|
+
res = await extractCached(path, hash, ext);
|
|
132
|
+
// Pages but no text layer: the file is fine, the reader was wrong. OCR is the remedy,
|
|
133
|
+
// and running it here is what keeps ingest a single pass instead of a hunt afterwards.
|
|
134
|
+
if (res.status === "needs-ocr") {
|
|
135
|
+
if (!opts.ocr) {
|
|
136
|
+
pend(entries, path, hash, c, `scanned, ${res.pages}p (--skip-ocr)`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
note(`no text layer, ${res.pages}p — scanned, running OCR (seconds per page)`);
|
|
140
|
+
res = await ocrCached(path, hash);
|
|
141
|
+
if (res.status === "ok") c.ocrd++;
|
|
142
|
+
}
|
|
143
|
+
} else if (kind === "image") {
|
|
144
|
+
if (!opts.ocr) {
|
|
145
|
+
pend(entries, path, hash, c, "image (--skip-ocr)");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
note(hit ? "image — reusing cached OCR" : "image — running OCR …");
|
|
149
|
+
res = await ocrImageCached(path, hash);
|
|
150
|
+
if (res.status === "ok" && !hit) c.ocrd++;
|
|
151
|
+
} else {
|
|
152
|
+
if (!opts.audio) {
|
|
153
|
+
pend(entries, path, hash, c, "audio/video (--skip-audio)");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
note(hit ? "audio/video — reusing cached transcript" : "transcribing with whisper (minutes per file) …");
|
|
157
|
+
res = await transcribeCached(path, hash);
|
|
158
|
+
if (res.status === "ok" && !hit) c.transcribed++;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (res.status !== "ok") {
|
|
162
|
+
pend(entries, path, hash, c, res.status === "unsupported" ? "no extractor for this format" : res.reason);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Copy out of the hash-keyed cache rather than moving: raw/ stays derived and the cache
|
|
167
|
+
// 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));
|
|
169
|
+
stamp(raw);
|
|
170
|
+
if (hit) c.fromCache++;
|
|
171
|
+
else if (kind === "doc") c.extracted++;
|
|
172
|
+
if (res.quality === "low") c.lowQuality++;
|
|
173
|
+
// Name the tool and the quality on the line: `quality: low` is the flag that tells
|
|
174
|
+
// 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}]`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function report(c: Counters) {
|
|
179
|
+
const line = (n: number, s: string) => (n ? console.log(` ${n} ${s}`) : undefined);
|
|
180
|
+
line(c.skipped, "unchanged, skipped");
|
|
181
|
+
line(c.moved, "moved/renamed — existing raw file and catalog entry kept");
|
|
182
|
+
line(c.copied, "text file(s) copied");
|
|
183
|
+
line(c.extracted, "extracted");
|
|
184
|
+
line(c.fromCache, "reused from the extraction cache (inbox/extracted/)");
|
|
185
|
+
line(c.ocrd, "read by OCR");
|
|
186
|
+
line(c.transcribed, "transcribed");
|
|
187
|
+
line(c.lowQuality, "marked `quality: low` — verify against the source when curating");
|
|
188
|
+
line(c.pending, "not extracted (empty `raw` in log.md)");
|
|
189
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Shared file-type classification for every acquisition path.
|
|
2
|
+
//
|
|
3
|
+
// The kind decides which extractor runs, and every kind here has one — ingest is a single
|
|
4
|
+
// flat pass that converts everything it can, locally, with no agent turn in the middle.
|
|
5
|
+
export const TEXT = [".md", ".txt", ".rst", ".adoc", ".html", ".csv", ".json", ".yaml", ".yml"];
|
|
6
|
+
/** Born-digital documents: khb's own pure-JS libraries read these, no system install. */
|
|
7
|
+
export const DOC = [".pdf", ".docx", ".odt", ".xlsx", ".pptx"];
|
|
8
|
+
/** Pixels. Read by tesseract OCR (lossy — `quality: low`), or by an agent vision pass. */
|
|
9
|
+
export const IMAGE = [".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff", ".gif"];
|
|
10
|
+
/** Audio and video: local whisper. Minutes of CPU per file, hence `--skip-audio`. */
|
|
11
|
+
export const AV = [".mp3", ".wav", ".m4a", ".flac", ".ogg", ".mp4", ".mov", ".mkv", ".webm"];
|
|
12
|
+
|
|
13
|
+
export const extOf = (p: string) => {
|
|
14
|
+
const i = p.lastIndexOf(".");
|
|
15
|
+
return i < 0 ? "" : p.slice(i).toLowerCase();
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** raw/ files are always markdown — don't produce "budget.md.md". */
|
|
19
|
+
export const mdName = (n: string) => (n.toLowerCase().endsWith(".md") ? n : n + ".md");
|
|
20
|
+
|
|
21
|
+
export type Kind = "text" | "doc" | "image" | "av" | "skip";
|
|
22
|
+
export function kindOf(path: string): Kind {
|
|
23
|
+
const e = extOf(path);
|
|
24
|
+
if (TEXT.includes(e)) return "text";
|
|
25
|
+
if (DOC.includes(e)) return "doc";
|
|
26
|
+
if (IMAGE.includes(e)) return "image";
|
|
27
|
+
if (AV.includes(e)) return "av";
|
|
28
|
+
return "skip";
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Explicit file list → raw/files/. Same acquisition as a folder source, for the case where
|
|
2
|
+
// the interesting files are scattered and naming them is easier than naming a root.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { basename } from "../lib/util";
|
|
5
|
+
import type { Entry } from "../lib/ledger";
|
|
6
|
+
import { acquireFile, newCounters, report, type Options } from "./acquire";
|
|
7
|
+
import { detail, item, outcome, pos } from "../lib/log";
|
|
8
|
+
import type { Source } from "./index";
|
|
9
|
+
|
|
10
|
+
export async function ingestFiles(
|
|
11
|
+
s: Extract<Source, { type: "files" }>,
|
|
12
|
+
rawDir: string,
|
|
13
|
+
bundleDir: string,
|
|
14
|
+
entries: Map<string, Entry>,
|
|
15
|
+
opts: Options,
|
|
16
|
+
) {
|
|
17
|
+
detail(`${s.paths.length} file(s) declared`);
|
|
18
|
+
const c = newCounters();
|
|
19
|
+
for (const [i, p] of s.paths.entries()) {
|
|
20
|
+
const at = pos(i + 1, s.paths.length);
|
|
21
|
+
if (!existsSync(p)) {
|
|
22
|
+
item(at, p);
|
|
23
|
+
outcome("missing, skipped");
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
await acquireFile(at, p, basename(p), rawDir, bundleDir, entries, c, opts);
|
|
27
|
+
}
|
|
28
|
+
report(c);
|
|
29
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Local folder → raw/folder/. Walks the tree and hands every file to acquireFile, which
|
|
2
|
+
// owns the extraction decisions. Unchanged files (same content hash, raw/ copy still
|
|
3
|
+
// present) are skipped.
|
|
4
|
+
import { readdirSync, statSync, existsSync } from "node:fs";
|
|
5
|
+
import { join } from "../lib/util";
|
|
6
|
+
import type { Entry } from "../lib/ledger";
|
|
7
|
+
import { acquireFile, newCounters, report, type Options } from "./acquire";
|
|
8
|
+
import { detail, pos } from "../lib/log";
|
|
9
|
+
import type { Source } from "./index";
|
|
10
|
+
|
|
11
|
+
export async function ingestFolder(
|
|
12
|
+
s: Extract<Source, { type: "folder" }>,
|
|
13
|
+
rawDir: string,
|
|
14
|
+
bundleDir: string,
|
|
15
|
+
entries: Map<string, Entry>,
|
|
16
|
+
opts: Options,
|
|
17
|
+
) {
|
|
18
|
+
if (!existsSync(s.path)) {
|
|
19
|
+
console.warn(` missing folder, skipped: ${s.path}`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const walk = (d: string): string[] =>
|
|
24
|
+
readdirSync(d).flatMap((f) => {
|
|
25
|
+
const p = join(d, f);
|
|
26
|
+
return statSync(p).isDirectory() ? walk(p) : [p];
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Walk the whole tree up front rather than streaming it: a corpus on a network share can
|
|
30
|
+
// take a while to enumerate, and knowing the denominator is what makes "[ 3/57]" mean
|
|
31
|
+
// anything to someone deciding whether to wait.
|
|
32
|
+
detail(`scanning ${s.path} …`);
|
|
33
|
+
const files = walk(s.path);
|
|
34
|
+
detail(`${files.length} file(s) found`);
|
|
35
|
+
|
|
36
|
+
const c = newCounters();
|
|
37
|
+
for (const [i, p] of files.entries()) {
|
|
38
|
+
// Flatten the subtree into the filename so two `notes.md` in sibling folders don't
|
|
39
|
+
// 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);
|
|
42
|
+
}
|
|
43
|
+
report(c);
|
|
44
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// bun scripts/ingest/index.ts <bundle> [--force] [--skip-ocr] [--skip-audio]
|
|
2
|
+
//
|
|
3
|
+
// Ingest is ONE flat phase: get every declared source into bundles/<b>/raw/ as markdown,
|
|
4
|
+
// with provenance, as cheaply and as locally as possible. It converts; it never interprets.
|
|
5
|
+
// Whatever the text means is the catalog pass's problem (skills/catalog/SKILL.md).
|
|
6
|
+
//
|
|
7
|
+
// Everything mechanically convertible is converted here in one go — text, PDF, DOCX, ODT,
|
|
8
|
+
// XLSX, PPTX, images (OCR), audio and video (whisper) — because leaving half the corpus as
|
|
9
|
+
// "pending, agent please run a CLI" made ingest a multi-round negotiation rather than a
|
|
10
|
+
// step. Sources that need an authenticated API (Confluence, ADO, git hosts) stay with the
|
|
11
|
+
// agent via MCP/CLI, which is a plugin boundary, not a phase.
|
|
12
|
+
import { parse } from "yaml";
|
|
13
|
+
import { read, join, HUB } from "../lib/util";
|
|
14
|
+
import { detail, section, totalElapsed } from "../lib/log";
|
|
15
|
+
import { bundleForIngest, DEFAULT_BUNDLE } from "../lib/scaffold";
|
|
16
|
+
import { readLedger, writeLedger } from "../lib/ledger";
|
|
17
|
+
import { takeFlag } from "../lib/args";
|
|
18
|
+
import { ingestFolder } from "./folder";
|
|
19
|
+
import { ingestFiles } from "./files";
|
|
20
|
+
import { ingestWeb } from "./web";
|
|
21
|
+
import type { Options } from "./acquire";
|
|
22
|
+
|
|
23
|
+
export type Source =
|
|
24
|
+
| { type: "folder"; path: string }
|
|
25
|
+
| { type: "files"; paths: string[] }
|
|
26
|
+
| { type: "web"; urls: string[] };
|
|
27
|
+
|
|
28
|
+
const argv = process.argv.slice(2);
|
|
29
|
+
const opts: Options = {
|
|
30
|
+
force: takeFlag(argv, "--force"),
|
|
31
|
+
// On by default: a corpus half-ingested because the expensive formats were opt-in is a
|
|
32
|
+
// corpus you have to remember to come back to. The flags exist for the impatient run.
|
|
33
|
+
ocr: !takeFlag(argv, "--skip-ocr"),
|
|
34
|
+
audio: !takeFlag(argv, "--skip-audio"),
|
|
35
|
+
};
|
|
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("--"));
|
|
42
|
+
if (positional.length > 1) {
|
|
43
|
+
console.error("Usage: khb ingest [bundle] [--force] [--skip-ocr] [--skip-audio]");
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
// No bundle named → `default`, created on the spot if the hub has none. Bytes always have
|
|
47
|
+
// somewhere to land; sorting them into real bundles is a later, cheaper decision (a concept
|
|
48
|
+
// is one file, and moving it is a `git mv`). Naming a bundle explicitly stays the norm.
|
|
49
|
+
const bundle = positional[0] ?? DEFAULT_BUNDLE;
|
|
50
|
+
|
|
51
|
+
const dir = bundleForIngest(bundle);
|
|
52
|
+
let cfg: unknown;
|
|
53
|
+
try {
|
|
54
|
+
cfg = parse(read(join(dir, "sources.yaml")));
|
|
55
|
+
} catch (e) {
|
|
56
|
+
console.error(`${bundle}: sources.yaml is not valid YAML: ${(e as Error).message}`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
const declared =
|
|
60
|
+
cfg && typeof cfg === "object" && "sources" in cfg
|
|
61
|
+
? (cfg as { sources?: unknown }).sources
|
|
62
|
+
: undefined;
|
|
63
|
+
if (declared !== undefined && !Array.isArray(declared)) {
|
|
64
|
+
console.error(`${bundle}: sources.yaml 'sources' must be a list`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
const sources = (declared ?? []) as any[];
|
|
68
|
+
for (const [i, s] of sources.entries()) {
|
|
69
|
+
const at = `sources.yaml sources[${i}]`;
|
|
70
|
+
if (!s || typeof s !== "object" || typeof s.type !== "string") {
|
|
71
|
+
console.error(`${bundle}: ${at} must be an object with a string 'type'`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
if (s.type === "folder" && typeof s.path !== "string") {
|
|
75
|
+
console.error(`${bundle}: ${at}.path must be a string`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
for (const [type, field] of [["files", "paths"], ["web", "urls"]] as const) {
|
|
79
|
+
if (s.type === type && (!Array.isArray(s[field]) || s[field].some((v: unknown) => typeof v !== "string"))) {
|
|
80
|
+
console.error(`${bundle}: ${at}.${field} must be a list of strings`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!sources.length) {
|
|
86
|
+
console.log(`${bundle}: no sources configured.`);
|
|
87
|
+
console.log(`Declare them in bundles/${bundle}/sources.yaml, then re-run: khb ingest ${bundle}`);
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const entries = readLedger(dir);
|
|
92
|
+
|
|
93
|
+
// State the whole plan before doing any of it: which hub (a --hub/$KHB_HUB run can target a
|
|
94
|
+
// folder you did not expect), which bundle, and which of the expensive extractors are armed.
|
|
95
|
+
console.log(`khb ingest → bundle '${bundle}'`);
|
|
96
|
+
detail(`hub: ${HUB}`);
|
|
97
|
+
detail(`bundle: ${dir}`);
|
|
98
|
+
detail(`sources: ${sources.length} declared in bundles/${bundle}/sources.yaml`);
|
|
99
|
+
detail(`options: ocr=${opts.ocr ? "on" : "off"} audio=${opts.audio ? "on" : "off"} force=${opts.force ? "on" : "off"}`);
|
|
100
|
+
detail(`ledger: ${entries.size} existing row(s) in log.md`);
|
|
101
|
+
|
|
102
|
+
for (const [i, s] of sources.entries()) {
|
|
103
|
+
const label =
|
|
104
|
+
s.type === "folder" ? s.path
|
|
105
|
+
: s.type === "files" ? `${s.paths.length} path(s)`
|
|
106
|
+
: s.type === "web" ? `${s.urls.length} url(s)`
|
|
107
|
+
: "";
|
|
108
|
+
section(`[${i + 1}/${sources.length}] ${s.type}${label ? ` — ${label}` : ""} → bundles/${bundle}/raw/${s.type}/`);
|
|
109
|
+
const rawDir = join(dir, "raw", s.type);
|
|
110
|
+
if (s.type === "folder") await ingestFolder(s, rawDir, dir, entries, opts);
|
|
111
|
+
else if (s.type === "files") await ingestFiles(s, rawDir, dir, entries, opts);
|
|
112
|
+
else if (s.type === "web") await ingestWeb(s, rawDir, dir, entries, opts);
|
|
113
|
+
else console.warn(` '${(s as any).type}' has no scripted ingester — the agent pulls it via MCP/CLI (see skills/ingest/SKILL.md)`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
writeLedger(dir, entries, bundle);
|
|
117
|
+
|
|
118
|
+
const all = [...entries.values()];
|
|
119
|
+
const pending = all.filter((e) => !e.raw).length;
|
|
120
|
+
const uncurated = all.filter((e) => e.raw && !e.curated).length;
|
|
121
|
+
console.log(`\ndone in ${totalElapsed()}`);
|
|
122
|
+
console.log(`ledger: ${all.length} source(s) in ${join(dir, "log.md")}`);
|
|
123
|
+
if (pending) console.log(` ${pending} with an empty 'raw' — not extracted; see the reasons above`);
|
|
124
|
+
console.log(` ${uncurated} in raw/ but not yet cataloged (empty 'curated')`);
|
|
125
|
+
console.log(`Next: catalog ${bundle} — turn raw/ into concept docs (skills/catalog/SKILL.md).`);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Lightweight, dependency-free heuristics for detecting password-protected documents,
|
|
2
|
+
// so ingest can say "password-protected" instead of reporting a mystery empty extraction.
|
|
3
|
+
// Covers only the two document formats
|
|
4
|
+
// (exts.ts) where a cheap byte-level signal exists:
|
|
5
|
+
// .pdf — trailer/xref carries an /Encrypt dictionary reference
|
|
6
|
+
// .docx — MS wraps an encrypted OOXML payload in an OLE2 (CFB) container instead
|
|
7
|
+
// of the normal PK zip; that's a hard format marker, not a text scan
|
|
8
|
+
// Legacy binary Office formats (.doc/.xls/.ppt) always use the CFB container whether
|
|
9
|
+
// encrypted or not, so there's no cheap signal and they're not checked here.
|
|
10
|
+
import { openSync, readSync, closeSync } from "node:fs";
|
|
11
|
+
|
|
12
|
+
export const PROTECTABLE = new Set([".pdf", ".docx"]);
|
|
13
|
+
|
|
14
|
+
const CFB_MAGIC = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
|
|
15
|
+
|
|
16
|
+
/** Best-effort: false negatives are possible (unusual encoders), false positives are not — both signals are exact format markers. */
|
|
17
|
+
export function detectPasswordProtected(path: string, ext: string, size: number): boolean {
|
|
18
|
+
const fd = openSync(path, "r");
|
|
19
|
+
try {
|
|
20
|
+
if (ext === ".docx") {
|
|
21
|
+
const buf = Buffer.alloc(8);
|
|
22
|
+
readSync(fd, buf, 0, 8, 0);
|
|
23
|
+
return buf.equals(CFB_MAGIC);
|
|
24
|
+
}
|
|
25
|
+
if (ext === ".pdf") {
|
|
26
|
+
const headLen = Math.min(size, 8192);
|
|
27
|
+
const headBuf = Buffer.alloc(headLen);
|
|
28
|
+
readSync(fd, headBuf, 0, headLen, 0);
|
|
29
|
+
if (headBuf.includes("/Encrypt")) return true;
|
|
30
|
+
if (size > headLen) {
|
|
31
|
+
const tailLen = Math.min(8192, size);
|
|
32
|
+
const tailBuf = Buffer.alloc(tailLen);
|
|
33
|
+
readSync(fd, tailBuf, 0, tailLen, size - tailLen);
|
|
34
|
+
if (tailBuf.includes("/Encrypt")) return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
} finally {
|
|
40
|
+
closeSync(fd);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Internet links → raw/web/. Naive HTML→text; swap in a readability lib later.
|
|
2
|
+
// A fetch is unavoidable (the hash only exists once the body is retrieved), but an
|
|
3
|
+
// unchanged body still short-circuits the rewrite and keeps the ledger row stable.
|
|
4
|
+
import { writeRaw, sha256, rawNameFor } from "../lib/util";
|
|
5
|
+
import { record, isFresh, type Entry } from "../lib/ledger";
|
|
6
|
+
import { detail, item, note, outcome, pos } from "../lib/log";
|
|
7
|
+
import type { Options } from "./acquire";
|
|
8
|
+
import type { Source } from "./index";
|
|
9
|
+
|
|
10
|
+
export async function ingestWeb(
|
|
11
|
+
s: Extract<Source, { type: "web" }>,
|
|
12
|
+
rawDir: string,
|
|
13
|
+
bundleDir: string,
|
|
14
|
+
entries: Map<string, Entry>,
|
|
15
|
+
{ force }: Options,
|
|
16
|
+
) {
|
|
17
|
+
detail(`${s.urls.length} url(s) declared`);
|
|
18
|
+
let skipped = 0;
|
|
19
|
+
for (const [i, url] of s.urls.entries()) {
|
|
20
|
+
item(pos(i + 1, s.urls.length), url);
|
|
21
|
+
try {
|
|
22
|
+
note("fetching …");
|
|
23
|
+
const res = await fetch(url);
|
|
24
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
25
|
+
const html = await res.text();
|
|
26
|
+
const text = html
|
|
27
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
28
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
29
|
+
.replace(/<[^>]+>/g, " ")
|
|
30
|
+
.replace(/ |&|<|>|"/g, (m) => ({ " ": " ", "&": "&", "<": "<", ">": ">", """: '"' })[m]!)
|
|
31
|
+
.replace(/[ \t]+/g, " ")
|
|
32
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
33
|
+
.trim();
|
|
34
|
+
const hash = sha256(text);
|
|
35
|
+
if (!force && isFresh(entries, bundleDir, url, hash)) {
|
|
36
|
+
skipped++;
|
|
37
|
+
outcome("unchanged, skipped");
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const name = new URL(url).pathname.split("/").filter(Boolean).pop() || new URL(url).hostname;
|
|
41
|
+
const file = rawNameFor(rawDir, `${name}.md`, url, entries.values());
|
|
42
|
+
const raw = writeRaw(rawDir, file, { source: url, sha256: hash.slice(0, 12), tool: "html-strip", quality: "high" }, text);
|
|
43
|
+
record(entries, { source: url, sha256: hash, fetched: new Date().toISOString(), raw });
|
|
44
|
+
outcome(`fetched → ${raw} [html-strip, ${text.length} chars]`);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// A transient refresh failure must not erase a previously acquired, still-usable
|
|
47
|
+
// copy. Record a pending row only when this source has never succeeded before.
|
|
48
|
+
if (!entries.has(url))
|
|
49
|
+
record(entries, { source: url, sha256: "", fetched: new Date().toISOString(), raw: "" });
|
|
50
|
+
outcome(`failed — ${e}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (skipped) console.log(` ${skipped} unchanged, skipped`);
|
|
54
|
+
}
|
package/scripts/init.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// khb init [dir] / khb upgrade — create a hub, or refresh a hub's package-owned files.
|
|
2
|
+
//
|
|
3
|
+
// A hub is the user's knowledge: khb.json + outer.index.md + bundles/. The khb package
|
|
4
|
+
// holds no knowledge, so the contract docs an agent needs (AGENTS.md, skills/, …) are
|
|
5
|
+
// copied INTO the hub — an agent opened on the hub folder must be able to read them
|
|
6
|
+
// without knowing where khb is installed. Those copies are package-owned: `upgrade`
|
|
7
|
+
// overwrites them.
|
|
8
|
+
import { cpSync, mkdirSync, writeFileSync, existsSync, statSync, rmSync } from "node:fs";
|
|
9
|
+
import { join, resolve, basename, dirname } from "node:path";
|
|
10
|
+
import { PKG, HUB_TEMPLATE, MANAGED, RETIRED, MARKER, markerIn, version } from "./lib/paths";
|
|
11
|
+
|
|
12
|
+
const upgrading = process.env.KHB_SUBCOMMAND === "upgrade";
|
|
13
|
+
const [dirArg] = process.argv.slice(2);
|
|
14
|
+
|
|
15
|
+
/** Copy every package-owned contract file into the hub, replacing what is there. */
|
|
16
|
+
function syncManaged(hub: string): string[] {
|
|
17
|
+
const done: string[] = [];
|
|
18
|
+
for (const f of MANAGED) {
|
|
19
|
+
const src = join(PKG, f);
|
|
20
|
+
if (!existsSync(src)) continue;
|
|
21
|
+
const dest = join(hub, f);
|
|
22
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
23
|
+
cpSync(src, dest, { recursive: true, force: true });
|
|
24
|
+
done.push(statSync(src).isDirectory() ? `${f}/` : f);
|
|
25
|
+
}
|
|
26
|
+
return done;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Drop package-owned files that later versions stopped shipping. */
|
|
30
|
+
function pruneRetired(hub: string): string[] {
|
|
31
|
+
const gone: string[] = [];
|
|
32
|
+
for (const f of RETIRED) {
|
|
33
|
+
const p = join(hub, f);
|
|
34
|
+
if (!existsSync(p)) continue;
|
|
35
|
+
rmSync(p, { recursive: true, force: true });
|
|
36
|
+
gone.push(f);
|
|
37
|
+
}
|
|
38
|
+
return gone;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stamp(hub: string, created?: string) {
|
|
42
|
+
writeFileSync(
|
|
43
|
+
join(hub, MARKER),
|
|
44
|
+
JSON.stringify(
|
|
45
|
+
{ khb: version(), created: created ?? new Date().toISOString(), upgraded: new Date().toISOString() },
|
|
46
|
+
null,
|
|
47
|
+
2,
|
|
48
|
+
) + "\n",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (upgrading) {
|
|
53
|
+
const { HUB } = await import("./lib/util"); // resolves the hub, or exits with guidance
|
|
54
|
+
// The hub may still carry a marker name from an older version; stamp() writes MARKER,
|
|
55
|
+
// so drop the old file rather than leaving the hub with two.
|
|
56
|
+
const found = markerIn(HUB)!;
|
|
57
|
+
const before = JSON.parse(await Bun.file(join(HUB, found)).text());
|
|
58
|
+
if (found !== MARKER) rmSync(join(HUB, found));
|
|
59
|
+
const synced = syncManaged(HUB);
|
|
60
|
+
const pruned = pruneRetired(HUB);
|
|
61
|
+
stamp(HUB, before.created);
|
|
62
|
+
console.log(`Upgraded ${HUB}: ${before.khb ?? before.bkr ?? "?"} -> ${version()}`);
|
|
63
|
+
console.log(` refreshed: ${synced.join(", ")}`);
|
|
64
|
+
if (found !== MARKER) console.log(` renamed: ${found} -> ${MARKER}`);
|
|
65
|
+
if (pruned.length) console.log(` removed (no longer part of the contract): ${pruned.join(", ")}`);
|
|
66
|
+
console.log(`Your bundles/ and outer.index.md were not touched. Next: khb lint`);
|
|
67
|
+
} else {
|
|
68
|
+
const hub = resolve(dirArg ?? process.cwd());
|
|
69
|
+
|
|
70
|
+
if (markerIn(hub)) {
|
|
71
|
+
console.error(`Already a KHB hub: ${hub}`);
|
|
72
|
+
console.error(`To refresh its contract docs: khb upgrade`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
mkdirSync(join(hub, "bundles"), { recursive: true });
|
|
77
|
+
cpSync(join(HUB_TEMPLATE, "outer.index.md"), join(hub, "outer.index.md"));
|
|
78
|
+
// Dotfiles: shipped unprefixed so npm doesn't swallow them, renamed on the way in.
|
|
79
|
+
// Never clobber — `khb init` may be run inside a folder that is already a git repo.
|
|
80
|
+
for (const f of ["gitignore", "gitattributes"])
|
|
81
|
+
if (!existsSync(join(hub, `.${f}`))) cpSync(join(HUB_TEMPLATE, f), join(hub, `.${f}`));
|
|
82
|
+
const synced = syncManaged(hub);
|
|
83
|
+
stamp(hub);
|
|
84
|
+
|
|
85
|
+
console.log(`Hub created: ${hub}`);
|
|
86
|
+
console.log(` khb.json, outer.index.md, bundles/, .gitignore, .gitattributes`);
|
|
87
|
+
console.log(` contract docs (package-owned, refreshed by 'khb upgrade'): ${synced.join(", ")}`);
|
|
88
|
+
console.log(`\nNext:`);
|
|
89
|
+
console.log(` cd ${basename(hub)}`);
|
|
90
|
+
console.log(` git init # optional, but recommended`);
|
|
91
|
+
console.log(` khb new-bundle <name> "<scope>" # your first bundle`);
|
|
92
|
+
console.log(`\nThen open this folder with Claude or Codex — both load AGENTS.md and the workflow skills.`);
|
|
93
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Tiny argv helpers shared by the scripts that take flags. Each `take*` removes what it
|
|
2
|
+
// consumed from the array, so whatever is left is the positional arguments.
|
|
3
|
+
export function takeFlag(args: string[], name: string): boolean {
|
|
4
|
+
const i = args.indexOf(name);
|
|
5
|
+
if (i < 0) return false;
|
|
6
|
+
args.splice(i, 1);
|
|
7
|
+
return true;
|
|
8
|
+
}
|