@msareen/knowledge-hub-builder 0.1.7 → 0.1.8
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 +16 -0
- package/README.md +93 -2
- package/SPEC.md +210 -0
- package/document/faq.md +1 -0
- package/package.json +1 -1
- package/scripts/cli.ts +86 -17
- package/scripts/hubs.ts +698 -0
- package/scripts/ingest/exclude.ts +37 -0
- package/scripts/ingest/files.ts +8 -2
- package/scripts/ingest/folder.ts +13 -3
- package/scripts/ingest/index.ts +10 -2
- package/scripts/init.ts +27 -13
- package/scripts/lib/args.ts +9 -5
- package/scripts/lib/create.ts +39 -0
- 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/skills/ingest/SKILL.md +14 -0
|
@@ -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/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
|