@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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Mechanical path rewriting for `khb update --path`: after a hub is moved, replace every literal
|
|
2
|
+
// reference to its old location with the new one.
|
|
3
|
+
//
|
|
4
|
+
// Strictly a conversion, not an interpretation — the same substring, in and out, with no
|
|
5
|
+
// judgement about what a path means. That is what keeps it in the CLI: the alternative is
|
|
6
|
+
// an agent reading each file and deciding, which for a byte-identical prefix swap is both
|
|
7
|
+
// slower and less reliable than a regex. See AGENTS.md, "Division of labor".
|
|
8
|
+
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join, relative } from "node:path";
|
|
10
|
+
import { ticker } from "./log";
|
|
11
|
+
|
|
12
|
+
/** Directories never walked: VCS internals, installed packages, regenerable caches. */
|
|
13
|
+
const SKIP_DIRS = new Set([".git", "node_modules", "inbox"]);
|
|
14
|
+
|
|
15
|
+
/** A file bigger than this is a corpus artefact, not something holding a path reference. */
|
|
16
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
export type Hit = { file: string; count: number };
|
|
19
|
+
export type RewriteResult = {
|
|
20
|
+
scanned: number;
|
|
21
|
+
hits: Hit[];
|
|
22
|
+
/** Files that matched but could not be written — read-only, locked, gone. */
|
|
23
|
+
failed: { file: string; reason: string }[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function* walk(dir: string, root = dir): Generator<string> {
|
|
27
|
+
let entries;
|
|
28
|
+
try {
|
|
29
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
30
|
+
} catch {
|
|
31
|
+
return; // unreadable directory: nothing to rewrite in what we cannot open
|
|
32
|
+
}
|
|
33
|
+
for (const e of entries) {
|
|
34
|
+
const p = join(dir, e.name);
|
|
35
|
+
if (e.isDirectory()) {
|
|
36
|
+
if (!SKIP_DIRS.has(e.name)) yield* walk(p, root);
|
|
37
|
+
} else if (e.isFile()) {
|
|
38
|
+
yield p;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Heuristic, and the standard one: a NUL byte in the first block means not text. */
|
|
44
|
+
function readText(path: string): string | undefined {
|
|
45
|
+
try {
|
|
46
|
+
if (statSync(path).size > MAX_BYTES) return undefined;
|
|
47
|
+
const buf = readFileSync(path);
|
|
48
|
+
if (buf.subarray(0, 8192).includes(0)) return undefined;
|
|
49
|
+
return buf.toString("utf8");
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The spellings one path takes in a hub's files, in a fixed order. A Windows path appears
|
|
59
|
+
* natively (`D:\a\b`), with forward slashes (`D:/a/b`, common in YAML people typed by
|
|
60
|
+
* hand), and backslash-escaped inside JSON — which is exactly how `raw/` provenance
|
|
61
|
+
* headers and `log.md` store a source. All are the same location and all must move.
|
|
62
|
+
* The order is what lets a matched spelling be mapped back to the same spelling of the
|
|
63
|
+
* new path, so a JSON-escaped source stays JSON-escaped after the rewrite.
|
|
64
|
+
*/
|
|
65
|
+
function spellings(path: string): string[] {
|
|
66
|
+
const back = path.replace(/\//g, "\\");
|
|
67
|
+
return [back.replace(/\\/g, "\\\\"), back, path.replace(/\\/g, "/")];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Pair every spelling of every old path with the matching spelling of the new one.
|
|
72
|
+
*
|
|
73
|
+
* Several old paths, not one, because the same directory has more than one true name:
|
|
74
|
+
* a hub registered as `C:\Users\MANASV~1\…` and later canonicalized to
|
|
75
|
+
* `C:\Users\Manasvi Sareen\…` has both strings sitting in files written at different
|
|
76
|
+
* times, and a repair that fixed only the canonical one would leave the rest dangling.
|
|
77
|
+
*/
|
|
78
|
+
function pairs(froms: string[], to: string): { find: string; replace: string }[] {
|
|
79
|
+
const out = new Map<string, string>();
|
|
80
|
+
const news = spellings(to);
|
|
81
|
+
for (const from of froms) {
|
|
82
|
+
spellings(from).forEach((form, i) => {
|
|
83
|
+
if (form && !out.has(form)) out.set(form, news[i]);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
// The new path also maps to itself. Two cases need it, and both are ordinary moves:
|
|
87
|
+
// a hub lifted out of its parent (`…/kb/hub` → `…/kb`) and one pushed down into a
|
|
88
|
+
// subdirectory of where it stood (`…/kb` → `…/kb/hub`). The paths then share a prefix,
|
|
89
|
+
// and in the second case the old path matches *inside* every reference that is already
|
|
90
|
+
// correct — prepending the move a second time. Claiming those matches for an identity
|
|
91
|
+
// rewrite is what makes the overlap safe, and makes any re-run a no-op.
|
|
92
|
+
for (const form of news) if (form && !out.has(form)) out.set(form, form);
|
|
93
|
+
// Longest first, so a `\\`-escaped form is consumed whole rather than partly matched by
|
|
94
|
+
// a shorter spelling of the same path — and so the identity above wins wherever it and
|
|
95
|
+
// an old path could both match, alternation being ordered.
|
|
96
|
+
return [...out].map(([find, replace]) => ({ find, replace })).sort((a, b) => b.find.length - a.find.length);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Replace every reference to any of `froms` with `to` in the text files under `root`.
|
|
101
|
+
* `dryRun` reports what would change without touching anything.
|
|
102
|
+
*/
|
|
103
|
+
export function rewritePaths(
|
|
104
|
+
root: string,
|
|
105
|
+
froms: string[],
|
|
106
|
+
to: string,
|
|
107
|
+
opts: { dryRun?: boolean; onStart?: (files: number) => void } = {},
|
|
108
|
+
): RewriteResult {
|
|
109
|
+
const table = pairs(froms, to);
|
|
110
|
+
const ci = process.platform === "win32";
|
|
111
|
+
const key = (s: string) => (ci ? s.toLowerCase() : s);
|
|
112
|
+
const lookup = new Map(table.map((p) => [key(p.find), p.replace]));
|
|
113
|
+
// Only where the match ends at a path boundary: without the lookahead, moving `…/old`
|
|
114
|
+
// would also rewrite `…/older`, a sibling whose name merely starts the same way.
|
|
115
|
+
const re = new RegExp(
|
|
116
|
+
`(?:${table.map((p) => escapeRe(p.find)).join("|")})(?=[\\\\/"'\\s,;:)\\]}]|$)`,
|
|
117
|
+
ci ? "gi" : "g",
|
|
118
|
+
);
|
|
119
|
+
const hits: Hit[] = [];
|
|
120
|
+
const failed: { file: string; reason: string }[] = [];
|
|
121
|
+
let scanned = 0;
|
|
122
|
+
|
|
123
|
+
// Enumerate before reading, so the counter can say "of how many". The walk is directory
|
|
124
|
+
// entries only — cheap next to opening every file, and worth it for a hub whose raw/ has
|
|
125
|
+
// grown to thousands of documents and would otherwise sit silent.
|
|
126
|
+
const files = [...walk(root)];
|
|
127
|
+
opts.onStart?.(files.length);
|
|
128
|
+
const progress = ticker("checking", files.length);
|
|
129
|
+
|
|
130
|
+
for (const file of files) {
|
|
131
|
+
progress.tick(hits.length ? `${hits.length} file(s) with references` : "");
|
|
132
|
+
const text = readText(file);
|
|
133
|
+
if (text === undefined) continue;
|
|
134
|
+
scanned++;
|
|
135
|
+
let count = 0;
|
|
136
|
+
const next = text.replace(re, (m) => {
|
|
137
|
+
const to = lookup.get(key(m));
|
|
138
|
+
if (to === undefined) return m; // not one of ours; leave the text exactly as found
|
|
139
|
+
if (key(to) === key(m)) return m; // already the new path — matched only to shield it
|
|
140
|
+
count++;
|
|
141
|
+
return to;
|
|
142
|
+
});
|
|
143
|
+
if (!count) continue;
|
|
144
|
+
hits.push({ file: relative(root, file) || file, count });
|
|
145
|
+
if (opts.dryRun) continue;
|
|
146
|
+
try {
|
|
147
|
+
writeFileSync(file, next);
|
|
148
|
+
} catch (e) {
|
|
149
|
+
failed.push({ file: relative(root, file) || file, reason: (e as Error).message });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
progress.done();
|
|
153
|
+
return { scanned, hits, failed };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* True when the two spellings name one directory — the only case `khb update --path`
|
|
158
|
+
* refuses, since there is then no move to repair. Overlapping-but-different paths used to
|
|
159
|
+
* be refused alongside it; `pairs()` now shields the new path from being matched inside
|
|
160
|
+
* itself, which is what made the overlap safe to rewrite.
|
|
161
|
+
*/
|
|
162
|
+
export function sameLocation(from: string, to: string): boolean {
|
|
163
|
+
const norm = (p: string) => (process.platform === "win32" ? p.toLowerCase() : p).replace(/[\\/]+$/, "");
|
|
164
|
+
return norm(from) === norm(to);
|
|
165
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The current shape of bundles/<b>/sources.yaml, and a comment-preserving diff/apply against
|
|
2
|
+
// it. Not a persisted schema-version: the "schema" is just this file's current field lists,
|
|
3
|
+
// checked fresh every run — same as MANAGED/RETIRED in paths.ts are static lists re-checked on
|
|
4
|
+
// every upgrade rather than tracked historically. Deliberately excludes util.ts, so it can be
|
|
5
|
+
// imported from lib/upgrade.ts, which must resolve before a hub is.
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { parseDocument, isMap, isSeq, YAMLMap } from "yaml";
|
|
9
|
+
|
|
10
|
+
export type FieldSchema = { key: string; default: unknown } | { key: string; deprecated: true };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Optional, backfillable fields per source `type`. Required fields (`path`, `paths`, `urls`)
|
|
14
|
+
* are not listed here — a missing one is already a hard validation error in
|
|
15
|
+
* scripts/ingest/index.ts, not something to silently default.
|
|
16
|
+
*/
|
|
17
|
+
export const SOURCES_SCHEMA: Record<string, FieldSchema[]> = {
|
|
18
|
+
folder: [{ key: "exclude", default: [] }],
|
|
19
|
+
files: [{ key: "exclude", default: [] }],
|
|
20
|
+
web: [],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SourcesDiff = { path: string; doc: ReturnType<typeof parseDocument>; changes: string[] };
|
|
24
|
+
|
|
25
|
+
/** Every bundle directory under a hub, without going through util.ts's HUB singleton. */
|
|
26
|
+
export function listBundleDirs(hub: string): string[] {
|
|
27
|
+
const dir = join(hub, "bundles");
|
|
28
|
+
if (!existsSync(dir)) return [];
|
|
29
|
+
return readdirSync(dir)
|
|
30
|
+
.map((d) => join(dir, d))
|
|
31
|
+
.filter((d) => statSync(d).isDirectory());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Diff one bundle's sources.yaml against SOURCES_SCHEMA. Null if there's nothing to change. */
|
|
35
|
+
export function diffSourcesYaml(bundleDir: string): SourcesDiff | null {
|
|
36
|
+
const path = join(bundleDir, "sources.yaml");
|
|
37
|
+
if (!existsSync(path)) return null;
|
|
38
|
+
const doc = parseDocument(readFileSync(path, "utf8"));
|
|
39
|
+
const sources = doc.get("sources");
|
|
40
|
+
if (!isSeq(sources)) return null;
|
|
41
|
+
|
|
42
|
+
const changes: string[] = [];
|
|
43
|
+
sources.items.forEach((item, i) => {
|
|
44
|
+
if (!isMap(item)) return;
|
|
45
|
+
const type = item.get("type");
|
|
46
|
+
if (typeof type !== "string") return;
|
|
47
|
+
const fields = SOURCES_SCHEMA[type];
|
|
48
|
+
if (!fields) return;
|
|
49
|
+
for (const field of fields) {
|
|
50
|
+
if ("deprecated" in field) {
|
|
51
|
+
if (item.has(field.key)) {
|
|
52
|
+
item.delete(field.key);
|
|
53
|
+
changes.push(`sources[${i}] (${type}): remove deprecated '${field.key}'`);
|
|
54
|
+
}
|
|
55
|
+
} else if (!item.has(field.key)) {
|
|
56
|
+
(item as YAMLMap).set(field.key, field.default);
|
|
57
|
+
changes.push(`sources[${i}] (${type}): add '${field.key}: ${JSON.stringify(field.default)}'`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return changes.length ? { path, doc, changes } : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Diff every bundle in a hub. */
|
|
66
|
+
export function diffSourcesYamlAll(hub: string): SourcesDiff[] {
|
|
67
|
+
return listBundleDirs(hub)
|
|
68
|
+
.map(diffSourcesYaml)
|
|
69
|
+
.filter((d): d is SourcesDiff => d !== null);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Write a diff's staged changes back to disk. */
|
|
73
|
+
export function applySourcesDiff(diff: SourcesDiff): void {
|
|
74
|
+
writeFileSync(diff.path, diff.doc.toString());
|
|
75
|
+
}
|
package/scripts/lib/upgrade.ts
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
// Nothing here may import util.ts: the drift check runs before a hub is resolved, and
|
|
6
6
|
// util.ts resolves one or exits.
|
|
7
7
|
import { cpSync, mkdirSync, writeFileSync, readFileSync, existsSync, statSync, rmSync } from "node:fs";
|
|
8
|
-
import { join, dirname } from "node:path";
|
|
8
|
+
import { join, dirname, resolve } from "node:path";
|
|
9
9
|
import { PKG, MANAGED, RETIRED, MARKER, markerIn, version } from "./paths";
|
|
10
|
+
import { canonical } from "./registry";
|
|
11
|
+
import { diffSourcesYamlAll } from "./schema";
|
|
10
12
|
|
|
11
13
|
export type UpgradeResult = {
|
|
12
14
|
/** Version recorded in the hub's marker before the upgrade, if it recorded one. */
|
|
@@ -56,16 +58,159 @@ function pruneRetired(hub: string): string[] {
|
|
|
56
58
|
return gone;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
/**
|
|
60
|
-
export function
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
61
|
+
/** Whatever is in the hub's marker, or an empty object if it has none or it is broken. */
|
|
62
|
+
export function readMarker(hub: string): Record<string, unknown> {
|
|
63
|
+
try {
|
|
64
|
+
const found = markerIn(hub);
|
|
65
|
+
if (!found) return {};
|
|
66
|
+
const j = JSON.parse(readFileSync(join(hub, found), "utf8"));
|
|
67
|
+
return j && typeof j === "object" ? j : {};
|
|
68
|
+
} catch {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `movedFrom` reads as one path or several — normalise both to a list. */
|
|
74
|
+
const asList = (v: unknown): string[] =>
|
|
75
|
+
typeof v === "string" ? [v] : Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : [];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Where the hub is, in the two spellings that matter. `path` is canonical, so it is
|
|
79
|
+
* comparable; `pathAs` is the spelling khb was actually invoked through, kept only when it
|
|
80
|
+
* differs — `C:\Users\MANASV~1\…` for `C:\Users\Manasvi Sareen\…`, or a symlink standing in
|
|
81
|
+
* for its target. That second one is not decoration: files written during a run hold the
|
|
82
|
+
* spelling of *that* run, so a hub only ever reached by its short name has a `raw/` full of
|
|
83
|
+
* short-name sources, and a repair that knew only the canonical form would miss every one.
|
|
84
|
+
*/
|
|
85
|
+
function location(hub: string): { path: string; pathAs?: string } {
|
|
86
|
+
const path = canonical(hub);
|
|
87
|
+
const pathAs = resolve(hub);
|
|
88
|
+
return pathAs === path ? { path } : { path, pathAs };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Write khb.json with the installed version. khb owns six keys — `khb`, `created`,
|
|
93
|
+
* `upgraded`, `path`, `pathAs`, `movedFrom` — and everything else in the marker belongs to
|
|
94
|
+
* whoever put it there: the hub's `name` and `description`, or a note the user added. Merge
|
|
95
|
+
* rather than replace, or an upgrade would silently eat what it does not recognise.
|
|
96
|
+
*/
|
|
97
|
+
export function stamp(hub: string, created?: string, extra: Record<string, unknown> = {}) {
|
|
98
|
+
const path = join(hub, MARKER);
|
|
99
|
+
const existing = readMarker(hub);
|
|
100
|
+
const merged: Record<string, unknown> = {
|
|
101
|
+
...existing,
|
|
102
|
+
...extra,
|
|
103
|
+
khb: version(),
|
|
104
|
+
created: created ?? (existing.created as string) ?? new Date().toISOString(),
|
|
105
|
+
upgraded: new Date().toISOString(),
|
|
106
|
+
...location(hub),
|
|
107
|
+
};
|
|
108
|
+
if (!location(hub).pathAs) delete merged.pathAs;
|
|
109
|
+
delete merged.bkr; // pre-rename version field; `khb` replaces it
|
|
110
|
+
writeFileSync(path, JSON.stringify(merged, null, 2) + "\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type Located = {
|
|
114
|
+
/** Where the marker said the hub was, when that is no longer where it is. */
|
|
115
|
+
moved?: string;
|
|
116
|
+
/** Every stale location recorded and not yet repaired, oldest first. */
|
|
117
|
+
movedFrom: string[];
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Keep the hub's own record of where it lives current, and notice when it changed.
|
|
122
|
+
*
|
|
123
|
+
* The marker is the one thing that travels *with* the folder, so it is the only place a
|
|
124
|
+
* move can be detected without being told: the machine registry knows a hub went missing
|
|
125
|
+
* but not which live hub it became, and after `~/.khb` is deleted or the folder is opened
|
|
126
|
+
* on another machine it knows nothing at all. A `path` key costs one string and turns
|
|
127
|
+
* "khb update --path --from <the path you must now remember>" into a command with no
|
|
128
|
+
* arguments.
|
|
129
|
+
*
|
|
130
|
+
* Stale locations accumulate in `movedFrom` rather than replacing each other: a hub moved
|
|
131
|
+
* twice before anyone repaired it has references to both former homes, and `update --path`
|
|
132
|
+
* rewrites the whole list in one pass. `khb update --path` clears it — see `clearMoved`.
|
|
133
|
+
*
|
|
134
|
+
* Writes only when something actually changed, so the common case leaves khb.json — and
|
|
135
|
+
* anyone's git status — untouched.
|
|
136
|
+
*/
|
|
137
|
+
export function recordLocation(hub: string): Located {
|
|
138
|
+
const now = location(hub);
|
|
139
|
+
const marker = readMarker(hub);
|
|
140
|
+
const was = typeof marker.path === "string" ? marker.path : undefined;
|
|
141
|
+
const movedFrom = asList(marker.movedFrom);
|
|
142
|
+
|
|
143
|
+
if (was === now.path) {
|
|
144
|
+
// Same place. Only one thing can still be missing: the alias this run came in through,
|
|
145
|
+
// when the hub was last located by its canonical name and is now being reached by a
|
|
146
|
+
// short name or a symlink. Filled in once, never replaced — replacing it would rewrite
|
|
147
|
+
// khb.json on every alternation between two equally valid spellings.
|
|
148
|
+
if (now.pathAs && typeof marker.pathAs !== "string") {
|
|
149
|
+
try {
|
|
150
|
+
writeFileSync(
|
|
151
|
+
join(hub, markerIn(hub) ?? MARKER),
|
|
152
|
+
JSON.stringify({ ...marker, ...now }, null, 2) + "\n",
|
|
153
|
+
);
|
|
154
|
+
} catch {
|
|
155
|
+
/* read-only hub */
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { movedFrom };
|
|
159
|
+
}
|
|
160
|
+
const moved = was;
|
|
161
|
+
// The alias first, the canonical form last: `update --path` reports the final entry as the
|
|
162
|
+
// move, and one directory's two names are better reported under the comparable one.
|
|
163
|
+
for (const p of [marker.pathAs, was])
|
|
164
|
+
if (typeof p === "string" && p && !movedFrom.includes(p)) movedFrom.push(p);
|
|
165
|
+
|
|
166
|
+
const next: Record<string, unknown> = { ...marker, ...now };
|
|
167
|
+
if (!now.pathAs) delete next.pathAs;
|
|
168
|
+
if (movedFrom.length) next.movedFrom = movedFrom;
|
|
169
|
+
try {
|
|
170
|
+
// Into the marker as it is *named* here, not MARKER: a hub still carrying a pre-rename
|
|
171
|
+
// filename is about to be renamed by the drift check, and writing the new name first
|
|
172
|
+
// would leave it holding two markers.
|
|
173
|
+
writeFileSync(join(hub, markerIn(hub) ?? MARKER), JSON.stringify(next, null, 2) + "\n");
|
|
174
|
+
} catch {
|
|
175
|
+
/* read-only hub: the location is a convenience, never a precondition for the command */
|
|
176
|
+
}
|
|
177
|
+
return { moved, movedFrom };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Every location this hub has recorded that is no longer where it stands, oldest first and
|
|
182
|
+
* ending with the most recent one — which is the move to report.
|
|
183
|
+
*
|
|
184
|
+
* Reads `path`/`pathAs` as well as the `movedFrom` backlog, because `khb update --path` runs
|
|
185
|
+
* outside a hub and therefore outside the drift check that calls `recordLocation`: repairing
|
|
186
|
+
* a move directly, with no khb command run in between, must work exactly as well.
|
|
187
|
+
*/
|
|
188
|
+
export function staleLocations(hub: string): string[] {
|
|
189
|
+
const marker = readMarker(hub);
|
|
190
|
+
const key = (s: string) => (process.platform === "win32" ? s.toLowerCase() : s);
|
|
191
|
+
const mine = new Set([key(canonical(hub)), key(resolve(hub))]);
|
|
192
|
+
const seen = new Set<string>();
|
|
193
|
+
const out: string[] = [];
|
|
194
|
+
for (const p of [...asList(marker.movedFrom), marker.pathAs, marker.path]) {
|
|
195
|
+
if (typeof p !== "string" || !p) continue;
|
|
196
|
+
// canonical() of a folder that no longer exists is just its resolved form, which is
|
|
197
|
+
// what makes a former home comparable at all.
|
|
198
|
+
if (mine.has(key(canonical(p))) || mine.has(key(p)) || seen.has(key(p))) continue;
|
|
199
|
+
seen.add(key(p));
|
|
200
|
+
out.push(p);
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The move is repaired: drop the backlog, keep `path` pointing where the hub now is. */
|
|
206
|
+
export function clearMoved(hub: string): void {
|
|
207
|
+
const marker = readMarker(hub);
|
|
208
|
+
const now = location(hub);
|
|
209
|
+
if (!("movedFrom" in marker) && marker.path === now.path && marker.pathAs === now.pathAs) return;
|
|
210
|
+
delete marker.movedFrom;
|
|
211
|
+
Object.assign(marker, now);
|
|
212
|
+
if (!now.pathAs) delete marker.pathAs;
|
|
213
|
+
writeFileSync(join(hub, markerIn(hub) ?? MARKER), JSON.stringify(marker, null, 2) + "\n");
|
|
69
214
|
}
|
|
70
215
|
|
|
71
216
|
/**
|
|
@@ -78,16 +223,40 @@ export function upgradeHub(hub: string): UpgradeResult {
|
|
|
78
223
|
const found = markerIn(hub)!;
|
|
79
224
|
let created: string | undefined;
|
|
80
225
|
let from: string | undefined;
|
|
226
|
+
// Carried forward by hand rather than left to stamp(): under a legacy name the file is
|
|
227
|
+
// deleted below, so its contents must be read out before it goes.
|
|
228
|
+
let carried: Record<string, unknown> = {};
|
|
81
229
|
try {
|
|
82
230
|
const before = JSON.parse(readFileSync(join(hub, found), "utf8"));
|
|
83
231
|
created = before.created;
|
|
84
232
|
from = before.khb ?? before.bkr;
|
|
233
|
+
carried = before;
|
|
85
234
|
} catch {
|
|
86
235
|
/* unreadable marker: rewritten below with today's date */
|
|
87
236
|
}
|
|
88
237
|
if (found !== MARKER) rmSync(join(hub, found));
|
|
89
238
|
const synced = syncManaged(hub);
|
|
90
239
|
const pruned = pruneRetired(hub);
|
|
91
|
-
stamp(hub, created);
|
|
240
|
+
stamp(hub, created, carried);
|
|
92
241
|
return { from, to: version(), synced, pruned, renamed: found === MARKER ? undefined : found };
|
|
93
242
|
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A one-line nudge, printed after `upgradeHub` runs (explicit `khb upgrade` or an
|
|
246
|
+
* auto-triggered one): `khb update` has a path repair and/or a sources.yaml schema backfill
|
|
247
|
+
* pending. Never a prompt — `upgrade` already runs unattended inside unrelated commands, so
|
|
248
|
+
* offering more than a suggestion here would mean blocking commands that have nothing to do
|
|
249
|
+
* with either half of `update`.
|
|
250
|
+
*/
|
|
251
|
+
export function updateHint(hub: string): string | undefined {
|
|
252
|
+
const pathPending = staleLocations(hub).length > 0;
|
|
253
|
+
const schemaDiffs = diffSourcesYamlAll(hub);
|
|
254
|
+
if (!pathPending && !schemaDiffs.length) return undefined;
|
|
255
|
+
const parts: string[] = [];
|
|
256
|
+
if (pathPending) parts.push("path: repair needed");
|
|
257
|
+
if (schemaDiffs.length) {
|
|
258
|
+
const fields = schemaDiffs.reduce((n, d) => n + d.changes.length, 0);
|
|
259
|
+
parts.push(`schema: ${fields} field(s) across ${schemaDiffs.length} bundle(s)`);
|
|
260
|
+
}
|
|
261
|
+
return `khb: 'khb update' has changes available (${parts.join("; ")}) — run 'khb update' to apply, or 'khb update --dry-run' to preview.`;
|
|
262
|
+
}
|
package/scripts/lib/util.ts
CHANGED
|
@@ -124,6 +124,13 @@ export function retargetRaw(bundleDir: string, rawRel: string, source: string):
|
|
|
124
124
|
return true;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* One spelling of a path, for comparing two of them. Absolute, and case-folded on Windows,
|
|
129
|
+
* where `D:\Corpus\Talk.mp4` and `d:/corpus/talk.mp4` are the same file.
|
|
130
|
+
*/
|
|
131
|
+
export const normPath = (p: string) =>
|
|
132
|
+
process.platform === "win32" ? resolve(p).toLowerCase() : resolve(p);
|
|
133
|
+
|
|
127
134
|
export const sha256 = (buf: Buffer | string) => createHash("sha256").update(buf).digest("hex");
|
|
128
135
|
|
|
129
136
|
/** Hash a file in chunks — corpora contain multi-GB binaries we must not slurp. */
|
package/scripts/lint.ts
CHANGED
|
@@ -4,6 +4,9 @@ import { detail, section, totalElapsed } from "./lib/log";
|
|
|
4
4
|
import { readdirSync, statSync } from "node:fs";
|
|
5
5
|
import { dirname, relative } from "node:path";
|
|
6
6
|
import { parse as parseYaml } from "yaml";
|
|
7
|
+
import { rejectUnknownFlags } from "./lib/args";
|
|
8
|
+
|
|
9
|
+
rejectUnknownFlags(process.argv.slice(2), "khb lint");
|
|
7
10
|
|
|
8
11
|
/** OKF v0.1 concept frontmatter. Unknown keys are warned, not rejected — OKF is permissive,
|
|
9
12
|
* but a `titel:` typo silently loses the field, so it is worth one line of noise. */
|
package/scripts/new-bundle.ts
CHANGED
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { BUNDLES, join } from "./lib/util";
|
|
5
5
|
import { createBundle, VALID_NAME } from "./lib/scaffold";
|
|
6
|
+
import { rejectUnknownFlags } from "./lib/args";
|
|
6
7
|
|
|
7
|
-
const
|
|
8
|
+
const argv = process.argv.slice(2);
|
|
9
|
+
rejectUnknownFlags(argv, 'khb new-bundle <name> ["scope"]');
|
|
10
|
+
const [name, scope = "TODO scope"] = argv;
|
|
8
11
|
if (!name || !VALID_NAME.test(name)) {
|
|
9
12
|
console.error("Usage: khb new-bundle <name> [scope] (lowercase, digits, hyphens)");
|
|
10
13
|
process.exit(1);
|
package/scripts/visualize.ts
CHANGED
|
@@ -7,19 +7,24 @@
|
|
|
7
7
|
// process. `--port N` pins a specific port.
|
|
8
8
|
import { buildGraphData, readConceptFile } from "./lib/graph";
|
|
9
9
|
import { renderGraphPage } from "./lib/graph-page";
|
|
10
|
+
import { takeFlag, takeOpt, rejectUnknownFlags } from "./lib/args";
|
|
10
11
|
|
|
12
|
+
const USAGE = "khb visualize [--port N] [--no-open]";
|
|
11
13
|
const argv = process.argv.slice(2);
|
|
12
14
|
// port 0 asks the OS for any free port — no fixed default to collide with something else
|
|
13
|
-
// already running on the machine.
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
// already running on the machine. `--port N` and `--port=N` both pin it.
|
|
16
|
+
const portArg = takeOpt(argv, "--port");
|
|
17
|
+
const noOpen = takeFlag(argv, "--no-open");
|
|
18
|
+
rejectUnknownFlags(argv, USAGE);
|
|
19
|
+
|
|
20
|
+
// A port that is not a port used to become NaN and silently serve on a random one, which
|
|
21
|
+
// looks like the flag working right up until nothing is listening where you expected.
|
|
22
|
+
const requestedPort = portArg === undefined ? 0 : Number(portArg);
|
|
23
|
+
if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) {
|
|
24
|
+
console.error(`--port must be a number between 0 and 65535, not: ${portArg}`);
|
|
25
|
+
console.error(`Usage: ${USAGE}`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
23
28
|
|
|
24
29
|
function summarize(data: ReturnType<typeof buildGraphData>) {
|
|
25
30
|
const concepts = Object.values(data.bundleGraphs).reduce((n, g) => n + g.concepts.length, 0);
|
package/skills/ingest/SKILL.md
CHANGED
|
@@ -64,16 +64,26 @@ empty `sources.yaml` has nothing to re-ingest, so there the only answer is a new
|
|
|
64
64
|
for it. Do not infer sources from nearby files, do not edit `sources.yaml`, and do not run
|
|
65
65
|
`khb ingest` until the user has answered.
|
|
66
66
|
|
|
67
|
+
For a `folder` or `files` source, ask one more thing before running `khb ingest`: **anything
|
|
68
|
+
to exclude from this source?** (default: no — most sources want everything ingested). If the
|
|
69
|
+
user names folders, files or patterns to skip, write them into that source's `exclude:` list
|
|
70
|
+
yourself — do not run `khb ingest` until this is settled either, for the same reason as the
|
|
71
|
+
question above: a declaration you have not confirmed is not yet a plan.
|
|
72
|
+
|
|
67
73
|
Sources live in `bundles/<bundle>/sources.yaml`:
|
|
68
74
|
|
|
69
75
|
```yaml
|
|
70
76
|
sources:
|
|
71
77
|
- type: folder # walk a directory tree
|
|
72
78
|
path: /abs/path/to/project-x
|
|
79
|
+
exclude: # optional — skip these before ingesting
|
|
80
|
+
- drafts/ # a plain entry: matches this path or anything under it
|
|
81
|
+
- "**/*.tmp" # a glob (has * ? [): matched with Bun.Glob
|
|
73
82
|
- type: files # a scattered, explicitly named set
|
|
74
83
|
paths:
|
|
75
84
|
- /abs/path/to/one.pdf
|
|
76
85
|
- /abs/path/to/two.xlsx
|
|
86
|
+
# exclude: also accepted here, matched against each path's basename
|
|
77
87
|
- type: web
|
|
78
88
|
urls:
|
|
79
89
|
- https://example.com/design-doc
|
|
@@ -83,6 +93,10 @@ sources:
|
|
|
83
93
|
space: PROJX
|
|
84
94
|
```
|
|
85
95
|
|
|
96
|
+
`exclude` entries can also be fully-qualified absolute paths (e.g. `D:\corpus\project-x\drafts`
|
|
97
|
+
or `/abs/path/to/project-x/drafts`) instead of paths/patterns relative to the source — either
|
|
98
|
+
form matches, plain or glob.
|
|
99
|
+
|
|
86
100
|
Nothing is copied by declaring a source.
|
|
87
101
|
|
|
88
102
|
## 3. Run it
|
|
@@ -103,6 +117,7 @@ locally. Read the summary it prints — the counts are the state of the world:
|
|
|
103
117
|
| `unchanged, skipped` | already acquired at this exact content hash |
|
|
104
118
|
| `extracted` / `reused from the extraction cache` | converted now / converted by an earlier run or another bundle |
|
|
105
119
|
| `read by OCR` / `transcribed` | lossy routes — see quality, below |
|
|
120
|
+
| `read from a caption sidecar` | a recording whose words were read off its `.vtt`/`.srt` instead |
|
|
106
121
|
| `marked quality: low` | verify these against the source when cataloging |
|
|
107
122
|
| `not extracted` | got a ledger row with an empty `raw`; the per-file line says why |
|
|
108
123
|
|
|
@@ -136,22 +151,79 @@ curation, not transcription.
|
|
|
136
151
|
| `.xlsx` | `fflate` → one markdown table per sheet | high |
|
|
137
152
|
| `.pdf` (scanned, no text layer) | `pdfium` + `tesseract.js`, automatically | **low** |
|
|
138
153
|
| `.png .jpg .webp .tif .gif` | `tesseract.js`, automatically | **low** |
|
|
139
|
-
| `.mp3 .wav .m4a .mp4 .mov .mkv` | local `whisper` / `faster-whisper` | **low** |
|
|
154
|
+
| `.mp3 .wav .m4a .mp4 .mov .mkv` | local `vno` (whisper.cpp), else `whisper` / `faster-whisper` | **low** |
|
|
155
|
+
| `.vtt .srt` | built-in caption reader | high |
|
|
140
156
|
|
|
141
157
|
Extracted text is cached hub-wide by content hash at `inbox/extracted/<sha256>.md`, so the
|
|
142
158
|
same file appearing in two bundles converts once.
|
|
143
159
|
|
|
160
|
+
**A recording next to its captions is one source, not two.** `talk.vtt` (or `talk.en.vtt`,
|
|
161
|
+
or `talk.srt`) beside `talk.mp4` is that recording's words, already written down by someone
|
|
162
|
+
who could hear it — so khb reads them instead of guessing at them with whisper. The pair
|
|
163
|
+
gets one `log.md` row, under the recording; the sidecar earns no row and no `raw/` file of
|
|
164
|
+
its own, and the recording's `extract_tool` names the file the text came from. It is both
|
|
165
|
+
free and better than transcription, so it happens even under `--skip-audio`.
|
|
166
|
+
|
|
167
|
+
Two things follow. The pair's identity is *both* files, so correcting a caption re-ingests
|
|
168
|
+
the recording rather than leaving a stale row marked unchanged. And khb never picks between
|
|
169
|
+
sidecars: `talk.en.vtt` next to `talk.fr.vtt` is a choice about audience, so it transcribes
|
|
170
|
+
instead and leaves both files to be pointed at explicitly. A caption with no recording
|
|
171
|
+
beside it — or one whose recording this source does not visit, because it is excluded or
|
|
172
|
+
simply not listed — is an ordinary source and gets its own row. That is also the lever:
|
|
173
|
+
excluding a sidecar does not unpair it, since `exclude` governs what earns a `raw/` file and
|
|
174
|
+
a paired sidecar never earns one; exclude the *recording* to have its captions ingested
|
|
175
|
+
alone.
|
|
176
|
+
|
|
177
|
+
The caption reader drops what belongs to the player and keeps what belongs to the
|
|
178
|
+
transcript: cue indices and timecodes go, `<v Name>` becomes a speaker label, the rolling
|
|
179
|
+
repetition auto-generated captions leave behind is collapsed, and anything longer than five
|
|
180
|
+
minutes gets a coarse `## h:mm:ss` heading per interval so a passage can be found in the
|
|
181
|
+
source recording. Quality is `high` — the words are what the file says, not what an
|
|
182
|
+
extractor guessed — but auto-generated captions are still ASR underneath, so treat a
|
|
183
|
+
transcript that reads like a machine wrote it the way you would treat one.
|
|
184
|
+
|
|
144
185
|
**OCR needs no setup.** `@hyzyla/pdfium`, `sharp` and `tesseract.js` are dependencies of khb
|
|
145
186
|
itself, so a scanned PDF or a photographed page is read on the first run, in any hub, without
|
|
146
187
|
asking the user to install anything.
|
|
147
188
|
|
|
148
|
-
Transcription is the one route that can be absent
|
|
149
|
-
|
|
189
|
+
Transcription is the one route that can be absent. It wants a transcriber on `PATH`, and
|
|
190
|
+
takes the first of these it finds:
|
|
150
191
|
|
|
151
192
|
```
|
|
152
|
-
|
|
193
|
+
npm install -g @msareen/voice-notes-organizer # vno — whisper.cpp, preferred
|
|
194
|
+
pip install -U openai-whisper # whisper (faster-whisper also works)
|
|
153
195
|
```
|
|
154
196
|
|
|
197
|
+
`vno` is preferred where both are set up: it is whisper.cpp rather than the Python
|
|
198
|
+
whisper, so it is markedly faster on the same audio and uses whatever acceleration the
|
|
199
|
+
machine has, it installs its own ffmpeg and model, and it emits WebVTT — which means a
|
|
200
|
+
transcript with `## h:mm:ss` anchors instead of an undifferentiated wall of text. khb runs
|
|
201
|
+
it as `vno t <file> -o <cache path> --no-open` with stdin closed, so nothing is written
|
|
202
|
+
beside your recordings and vno's setup offers degrade to printed instructions instead of
|
|
203
|
+
prompts.
|
|
204
|
+
|
|
205
|
+
khb gates on `vno status` before using it, because installed and ready are different things
|
|
206
|
+
— vno needs ffmpeg, whisper.cpp and a model, and reports on all three. **A vno that is not
|
|
207
|
+
set up is an amber gate, never a red one.** The run does not stop and nothing else is
|
|
208
|
+
affected: whisper takes over if you have it, and if you don't, the recordings pend with an
|
|
209
|
+
empty `raw` exactly like any other unavailable extractor while the rest of the corpus is
|
|
210
|
+
ingested normally. What you get is the reason and the fix, on the file's own line and again
|
|
211
|
+
in `log.md`:
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
[ 3/12] D:\corpus\standup.m4a
|
|
215
|
+
no captions beside it — transcribing (minutes per file) …
|
|
216
|
+
vno is installed but not set up: ffmpeg, whisper.cpp — run: vno setup
|
|
217
|
+
pending — vno is installed but not set up: ffmpeg, whisper.cpp — run: vno setup
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Run `vno setup` yourself and re-run the ingest; the pending rows fill in. khb will not run
|
|
221
|
+
it for you — installing software nobody asked it to install is not a conversion step.
|
|
222
|
+
|
|
223
|
+
Either engine is a local binary doing a reproducible conversion, and its output is
|
|
224
|
+
`quality: low` all the same: it is a machine's guess at audio, and the recording is still
|
|
225
|
+
the thing to re-read when a passage looks wrong.
|
|
226
|
+
|
|
155
227
|
When any extractor is unavailable khb says so once and records the affected files as pending
|
|
156
228
|
rather than failing the run — a `log.md` row with an empty `raw`, waiting for the dependency.
|
|
157
229
|
If khb ever prints a `bun add` hint for the OCR packages, its own install tree is incomplete;
|