@kibhq/core 0.1.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.
Files changed (53) hide show
  1. package/package.json +40 -0
  2. package/src/compile/backlinks.test.ts +112 -0
  3. package/src/compile/backlinks.ts +80 -0
  4. package/src/compile/cache.test.ts +126 -0
  5. package/src/compile/cache.ts +125 -0
  6. package/src/compile/compiler.test.ts +278 -0
  7. package/src/compile/compiler.ts +305 -0
  8. package/src/compile/diff.test.ts +164 -0
  9. package/src/compile/diff.ts +121 -0
  10. package/src/compile/index-manager.test.ts +227 -0
  11. package/src/compile/index-manager.ts +148 -0
  12. package/src/compile/prompts.ts +124 -0
  13. package/src/constants.ts +40 -0
  14. package/src/errors.ts +66 -0
  15. package/src/hash.test.ts +21 -0
  16. package/src/hash.ts +24 -0
  17. package/src/index.ts +22 -0
  18. package/src/ingest/extractors/file.test.ts +129 -0
  19. package/src/ingest/extractors/file.ts +136 -0
  20. package/src/ingest/extractors/github.test.ts +47 -0
  21. package/src/ingest/extractors/github.ts +135 -0
  22. package/src/ingest/extractors/interface.ts +26 -0
  23. package/src/ingest/extractors/pdf.ts +130 -0
  24. package/src/ingest/extractors/web.test.ts +242 -0
  25. package/src/ingest/extractors/web.ts +163 -0
  26. package/src/ingest/extractors/youtube.test.ts +44 -0
  27. package/src/ingest/extractors/youtube.ts +166 -0
  28. package/src/ingest/ingest.test.ts +187 -0
  29. package/src/ingest/ingest.ts +179 -0
  30. package/src/ingest/normalize.test.ts +120 -0
  31. package/src/ingest/normalize.ts +83 -0
  32. package/src/ingest/router.test.ts +154 -0
  33. package/src/ingest/router.ts +119 -0
  34. package/src/lint/lint.test.ts +253 -0
  35. package/src/lint/lint.ts +43 -0
  36. package/src/lint/rules.ts +178 -0
  37. package/src/providers/anthropic.ts +107 -0
  38. package/src/providers/index.ts +4 -0
  39. package/src/providers/ollama.ts +101 -0
  40. package/src/providers/openai.ts +67 -0
  41. package/src/providers/router.ts +62 -0
  42. package/src/query/query.test.ts +165 -0
  43. package/src/query/query.ts +136 -0
  44. package/src/schemas.ts +193 -0
  45. package/src/search/engine.test.ts +230 -0
  46. package/src/search/engine.ts +390 -0
  47. package/src/skills/loader.ts +163 -0
  48. package/src/skills/runner.ts +139 -0
  49. package/src/skills/schema.ts +28 -0
  50. package/src/skills/skills.test.ts +134 -0
  51. package/src/types.ts +136 -0
  52. package/src/vault.test.ts +141 -0
  53. package/src/vault.ts +251 -0
@@ -0,0 +1,164 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { extractWikilinks, parseCompileOutput, parseFrontmatter } from "./diff.js";
3
+
4
+ describe("parseCompileOutput", () => {
5
+ test("parses clean JSON array", () => {
6
+ const input = JSON.stringify([
7
+ {
8
+ op: "create",
9
+ path: "wiki/concepts/test.md",
10
+ content: "# Test\n\nContent.",
11
+ },
12
+ ]);
13
+ const result = parseCompileOutput(input);
14
+ expect(result).toHaveLength(1);
15
+ expect(result[0]!.op).toBe("create");
16
+ expect(result[0]!.path).toBe("wiki/concepts/test.md");
17
+ });
18
+
19
+ test("strips markdown code fences", () => {
20
+ const input = '```json\n[{"op":"create","path":"wiki/test.md","content":"# Test"}]\n```';
21
+ const result = parseCompileOutput(input);
22
+ expect(result).toHaveLength(1);
23
+ expect(result[0]!.op).toBe("create");
24
+ });
25
+
26
+ test("strips plain code fences", () => {
27
+ const input = '```\n[{"op":"create","path":"wiki/test.md","content":"x"}]\n```';
28
+ const result = parseCompileOutput(input);
29
+ expect(result).toHaveLength(1);
30
+ });
31
+
32
+ test("extracts JSON from surrounding text", () => {
33
+ const input =
34
+ 'Here are the file operations:\n\n[{"op":"create","path":"wiki/test.md","content":"x"}]\n\nHope that helps!';
35
+ const result = parseCompileOutput(input);
36
+ expect(result).toHaveLength(1);
37
+ });
38
+
39
+ test("parses multiple operations", () => {
40
+ const input = JSON.stringify([
41
+ { op: "create", path: "wiki/concepts/a.md", content: "# A" },
42
+ { op: "update", path: "wiki/topics/b.md", content: "# B updated" },
43
+ { op: "delete", path: "wiki/references/c.md" },
44
+ ]);
45
+ const result = parseCompileOutput(input);
46
+ expect(result).toHaveLength(3);
47
+ expect(result[0]!.op).toBe("create");
48
+ expect(result[1]!.op).toBe("update");
49
+ expect(result[2]!.op).toBe("delete");
50
+ });
51
+
52
+ test("parses empty array", () => {
53
+ const result = parseCompileOutput("[]");
54
+ expect(result).toHaveLength(0);
55
+ });
56
+
57
+ test("throws on invalid JSON", () => {
58
+ expect(() => parseCompileOutput("not json at all")).toThrow("Failed to parse");
59
+ });
60
+
61
+ test("throws on wrong structure", () => {
62
+ expect(() => parseCompileOutput('{"not": "an array"}')).toThrow();
63
+ });
64
+
65
+ test("throws on invalid operation type", () => {
66
+ expect(() => parseCompileOutput('[{"op":"rename","path":"x","content":"y"}]')).toThrow();
67
+ });
68
+
69
+ test("handles whitespace around JSON", () => {
70
+ const input = ' \n\n [{"op":"create","path":"wiki/test.md","content":"x"}] \n\n ';
71
+ const result = parseCompileOutput(input);
72
+ expect(result).toHaveLength(1);
73
+ });
74
+
75
+ test("delete operation doesn't require content", () => {
76
+ const input = '[{"op":"delete","path":"wiki/old.md"}]';
77
+ const result = parseCompileOutput(input);
78
+ expect(result).toHaveLength(1);
79
+ expect(result[0]!.content).toBeUndefined();
80
+ });
81
+ });
82
+
83
+ describe("parseFrontmatter", () => {
84
+ test("parses standard frontmatter", () => {
85
+ const md = `---
86
+ title: Test Article
87
+ slug: test-article
88
+ category: concept
89
+ tags: [deep-learning, nlp]
90
+ created: 2026-04-05
91
+ updated: 2026-04-05
92
+ summary: A test article about testing.
93
+ ---
94
+
95
+ # Test Article
96
+
97
+ Content here.`;
98
+
99
+ const { frontmatter, body } = parseFrontmatter(md);
100
+ expect(frontmatter.title).toBe("Test Article");
101
+ expect(frontmatter.slug).toBe("test-article");
102
+ expect(frontmatter.category).toBe("concept");
103
+ expect(frontmatter.tags).toEqual(["deep-learning", "nlp"]);
104
+ expect(frontmatter.created).toBe("2026-04-05");
105
+ expect(body).toContain("# Test Article");
106
+ expect(body).toContain("Content here.");
107
+ });
108
+
109
+ test("returns empty frontmatter when none exists", () => {
110
+ const { frontmatter, body } = parseFrontmatter("# Just Content\n\nNo frontmatter.");
111
+ expect(frontmatter).toEqual({});
112
+ expect(body).toBe("# Just Content\n\nNo frontmatter.");
113
+ });
114
+
115
+ test("handles quoted values", () => {
116
+ const md = '---\ntitle: "Quoted Title"\n---\n\nBody.';
117
+ const { frontmatter } = parseFrontmatter(md);
118
+ expect(frontmatter.title).toBe("Quoted Title");
119
+ });
120
+
121
+ test("handles boolean values", () => {
122
+ const md = "---\ndraft: true\npublished: false\n---\n\nBody.";
123
+ const { frontmatter } = parseFrontmatter(md);
124
+ expect(frontmatter.draft).toBe(true);
125
+ expect(frontmatter.published).toBe(false);
126
+ });
127
+
128
+ test("handles empty tags array", () => {
129
+ const md = "---\ntags: []\n---\n\nBody.";
130
+ const { frontmatter } = parseFrontmatter(md);
131
+ expect(frontmatter.tags).toEqual([]);
132
+ });
133
+ });
134
+
135
+ describe("extractWikilinks", () => {
136
+ test("extracts single wikilink", () => {
137
+ const content = "This relates to [[transformer-architecture]].";
138
+ expect(extractWikilinks(content)).toEqual(["transformer-architecture"]);
139
+ });
140
+
141
+ test("extracts multiple wikilinks", () => {
142
+ const content = "See [[attention-mechanisms]] and [[positional-encoding]] for details.";
143
+ expect(extractWikilinks(content)).toEqual(["attention-mechanisms", "positional-encoding"]);
144
+ });
145
+
146
+ test("deduplicates wikilinks", () => {
147
+ const content = "The [[transformer]] is great. More about [[transformer]] here.";
148
+ expect(extractWikilinks(content)).toEqual(["transformer"]);
149
+ });
150
+
151
+ test("normalizes to kebab-case", () => {
152
+ const content = "See [[Transformer Architecture]] for details.";
153
+ expect(extractWikilinks(content)).toEqual(["transformer-architecture"]);
154
+ });
155
+
156
+ test("returns empty array when no links", () => {
157
+ expect(extractWikilinks("No links here.")).toEqual([]);
158
+ });
159
+
160
+ test("handles links with spaces around them", () => {
161
+ const content = "See [[ attention mechanisms ]] here.";
162
+ expect(extractWikilinks(content)).toEqual(["attention-mechanisms"]);
163
+ });
164
+ });
@@ -0,0 +1,121 @@
1
+ import { z } from "zod";
2
+ import { FileOperationSchema } from "../schemas.js";
3
+ import type { FileOperation } from "../types.js";
4
+
5
+ const FileOperationsArraySchema = z.array(FileOperationSchema);
6
+
7
+ /**
8
+ * Parse LLM compile output into file operations.
9
+ *
10
+ * The LLM should return a JSON array of {op, path, content} objects.
11
+ * This parser handles various edge cases:
12
+ * - JSON wrapped in markdown code fences
13
+ * - Extra text before/after the JSON
14
+ * - Minor formatting issues
15
+ */
16
+ export function parseCompileOutput(raw: string): FileOperation[] {
17
+ const cleaned = extractJson(raw);
18
+
19
+ try {
20
+ const parsed = JSON.parse(cleaned);
21
+ return FileOperationsArraySchema.parse(parsed);
22
+ } catch (err) {
23
+ throw new Error(
24
+ `Failed to parse LLM compile output: ${err instanceof Error ? err.message : err}\n\nRaw output (first 500 chars):\n${raw.slice(0, 500)}`,
25
+ );
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Extract JSON array from LLM output that may contain surrounding text.
31
+ */
32
+ function extractJson(raw: string): string {
33
+ let text = raw.trim();
34
+
35
+ // Strip markdown code fences
36
+ text = text.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
37
+ text = text.trim();
38
+
39
+ // If it already starts with [, try it directly
40
+ if (text.startsWith("[")) {
41
+ return text;
42
+ }
43
+
44
+ // Try to find a JSON array in the text
45
+ const arrayStart = text.indexOf("[");
46
+ const arrayEnd = text.lastIndexOf("]");
47
+
48
+ if (arrayStart !== -1 && arrayEnd !== -1 && arrayEnd > arrayStart) {
49
+ return text.slice(arrayStart, arrayEnd + 1);
50
+ }
51
+
52
+ // Nothing worked, return as-is and let JSON.parse fail with a clear error
53
+ return text;
54
+ }
55
+
56
+ /**
57
+ * Extract YAML frontmatter from a markdown article string.
58
+ * Returns the frontmatter fields as a Record and the body content.
59
+ */
60
+ export function parseFrontmatter(markdown: string): {
61
+ frontmatter: Record<string, unknown>;
62
+ body: string;
63
+ } {
64
+ const match = markdown.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
65
+ if (!match) {
66
+ return { frontmatter: {}, body: markdown };
67
+ }
68
+
69
+ const rawFrontmatter = match[1]!;
70
+ const body = match[2]!;
71
+
72
+ // Simple YAML-like parser for frontmatter (handles common cases)
73
+ const frontmatter: Record<string, unknown> = {};
74
+
75
+ for (const line of rawFrontmatter.split("\n")) {
76
+ const trimmed = line.trim();
77
+ if (!trimmed || trimmed.startsWith("#")) continue;
78
+
79
+ // Handle continuation lines (for summary: >)
80
+ const colonIdx = trimmed.indexOf(":");
81
+ if (colonIdx === -1) continue;
82
+
83
+ const key = trimmed.slice(0, colonIdx).trim();
84
+ let value: unknown = trimmed.slice(colonIdx + 1).trim();
85
+
86
+ // Parse arrays: [tag1, tag2]
87
+ if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) {
88
+ value = value
89
+ .slice(1, -1)
90
+ .split(",")
91
+ .map((s) => s.trim())
92
+ .filter(Boolean);
93
+ }
94
+ // Parse booleans
95
+ else if (value === "true") value = true;
96
+ else if (value === "false") value = false;
97
+ // Remove quotes
98
+ else if (typeof value === "string" && value.startsWith('"') && value.endsWith('"')) {
99
+ value = value.slice(1, -1);
100
+ }
101
+
102
+ if (key) {
103
+ frontmatter[key] = value;
104
+ }
105
+ }
106
+
107
+ return { frontmatter, body: body.trim() };
108
+ }
109
+
110
+ /**
111
+ * Extract [[wikilinks]] from markdown content.
112
+ * Returns an array of slug strings.
113
+ */
114
+ export function extractWikilinks(content: string): string[] {
115
+ const matches = content.matchAll(/\[\[([^\]]+)\]\]/g);
116
+ const links: string[] = [];
117
+ for (const match of matches) {
118
+ links.push(match[1]!.trim().toLowerCase().replace(/\s+/g, "-"));
119
+ }
120
+ return [...new Set(links)]; // deduplicate
121
+ }
@@ -0,0 +1,227 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { initVault, writeWiki } from "../vault.js";
6
+ import { computeStats, generateIndexMd } from "./index-manager.js";
7
+
8
+ let tempDir: string;
9
+
10
+ afterEach(async () => {
11
+ if (tempDir) await rm(tempDir, { recursive: true, force: true });
12
+ });
13
+
14
+ async function makeTempVault() {
15
+ tempDir = await mkdtemp(join(tmpdir(), "kib-index-test-"));
16
+ await initVault(tempDir, { name: "test" });
17
+ return tempDir;
18
+ }
19
+
20
+ function articleMd(opts: {
21
+ title: string;
22
+ slug: string;
23
+ category: string;
24
+ tags?: string[];
25
+ summary?: string;
26
+ body?: string;
27
+ }) {
28
+ const tags = opts.tags ? `[${opts.tags.join(", ")}]` : "[]";
29
+ return `---
30
+ title: ${opts.title}
31
+ slug: ${opts.slug}
32
+ category: ${opts.category}
33
+ tags: ${tags}
34
+ summary: ${opts.summary ?? ""}
35
+ ---
36
+
37
+ # ${opts.title}
38
+
39
+ ${opts.body ?? "Some content here for the article."}`;
40
+ }
41
+
42
+ describe("generateIndexMd", () => {
43
+ test("generates index with articles grouped by category", async () => {
44
+ const root = await makeTempVault();
45
+
46
+ await writeWiki(
47
+ root,
48
+ "concepts/transformers.md",
49
+ articleMd({
50
+ title: "Transformer Architecture",
51
+ slug: "transformer-architecture",
52
+ category: "concept",
53
+ tags: ["deep-learning", "nlp"],
54
+ summary: "The transformer replaces recurrence with self-attention.",
55
+ }),
56
+ );
57
+
58
+ await writeWiki(
59
+ root,
60
+ "topics/scaling-laws.md",
61
+ articleMd({
62
+ title: "Scaling Laws",
63
+ slug: "scaling-laws",
64
+ category: "topic",
65
+ tags: ["training"],
66
+ summary: "Power-law relationships between compute and loss.",
67
+ }),
68
+ );
69
+
70
+ await writeWiki(
71
+ root,
72
+ "references/vaswani.md",
73
+ articleMd({
74
+ title: "Vaswani et al.",
75
+ slug: "vaswani-et-al",
76
+ category: "reference",
77
+ summary: "Authors of the original transformer paper.",
78
+ }),
79
+ );
80
+
81
+ const index = await generateIndexMd(root);
82
+
83
+ expect(index).toContain("# Knowledge Base Index");
84
+ expect(index).toContain("3 articles");
85
+
86
+ // Categories
87
+ expect(index).toContain("## Concepts");
88
+ expect(index).toContain("## Topics");
89
+ expect(index).toContain("## References");
90
+
91
+ // Articles
92
+ expect(index).toContain("[Transformer Architecture]");
93
+ expect(index).toContain("[Scaling Laws]");
94
+ expect(index).toContain("[Vaswani et al.]");
95
+
96
+ // Tags
97
+ expect(index).toContain("`#deep-learning`");
98
+ expect(index).toContain("`#nlp`");
99
+
100
+ // Summaries
101
+ expect(index).toContain("replaces recurrence with self-attention");
102
+ });
103
+
104
+ test("handles empty wiki", async () => {
105
+ const root = await makeTempVault();
106
+ const index = await generateIndexMd(root);
107
+ expect(index).toContain("0 articles");
108
+ });
109
+
110
+ test("sorts articles alphabetically within categories", async () => {
111
+ const root = await makeTempVault();
112
+
113
+ await writeWiki(
114
+ root,
115
+ "concepts/zebra.md",
116
+ articleMd({
117
+ title: "Zebra Concept",
118
+ slug: "zebra",
119
+ category: "concept",
120
+ }),
121
+ );
122
+ await writeWiki(
123
+ root,
124
+ "concepts/alpha.md",
125
+ articleMd({
126
+ title: "Alpha Concept",
127
+ slug: "alpha",
128
+ category: "concept",
129
+ }),
130
+ );
131
+ await writeWiki(
132
+ root,
133
+ "concepts/mid.md",
134
+ articleMd({
135
+ title: "Mid Concept",
136
+ slug: "mid",
137
+ category: "concept",
138
+ }),
139
+ );
140
+
141
+ const index = await generateIndexMd(root);
142
+ const conceptsIdx = index.indexOf("## Concepts");
143
+ const alphaIdx = index.indexOf("Alpha Concept");
144
+ const midIdx = index.indexOf("Mid Concept");
145
+ const zebraIdx = index.indexOf("Zebra Concept");
146
+
147
+ expect(alphaIdx).toBeLessThan(midIdx);
148
+ expect(midIdx).toBeLessThan(zebraIdx);
149
+ });
150
+
151
+ test("skips INDEX.md and GRAPH.md from listing", async () => {
152
+ const root = await makeTempVault();
153
+
154
+ await writeWiki(root, "INDEX.md", "# Index\nOld index.");
155
+ await writeWiki(root, "GRAPH.md", "# Graph\nOld graph.");
156
+ await writeWiki(
157
+ root,
158
+ "concepts/test.md",
159
+ articleMd({
160
+ title: "Test",
161
+ slug: "test",
162
+ category: "concept",
163
+ }),
164
+ );
165
+
166
+ const index = await generateIndexMd(root);
167
+ expect(index).toContain("1 articles"); // Only the actual article, not INDEX/GRAPH
168
+ });
169
+
170
+ test("only includes sections that have articles", async () => {
171
+ const root = await makeTempVault();
172
+
173
+ await writeWiki(
174
+ root,
175
+ "concepts/test.md",
176
+ articleMd({
177
+ title: "Test",
178
+ slug: "test",
179
+ category: "concept",
180
+ }),
181
+ );
182
+
183
+ const index = await generateIndexMd(root);
184
+ expect(index).toContain("## Concepts");
185
+ expect(index).not.toContain("## Topics");
186
+ expect(index).not.toContain("## References");
187
+ expect(index).not.toContain("## Outputs");
188
+ });
189
+ });
190
+
191
+ describe("computeStats", () => {
192
+ test("computes article count and word count", async () => {
193
+ const root = await makeTempVault();
194
+
195
+ await writeWiki(
196
+ root,
197
+ "concepts/a.md",
198
+ articleMd({
199
+ title: "A",
200
+ slug: "a",
201
+ category: "concept",
202
+ body: "one two three four five",
203
+ }),
204
+ );
205
+ await writeWiki(
206
+ root,
207
+ "concepts/b.md",
208
+ articleMd({
209
+ title: "B",
210
+ slug: "b",
211
+ category: "concept",
212
+ body: "six seven eight nine ten",
213
+ }),
214
+ );
215
+
216
+ const stats = await computeStats(root);
217
+ expect(stats.totalArticles).toBe(2);
218
+ expect(stats.totalWords).toBeGreaterThan(0);
219
+ });
220
+
221
+ test("returns zero for empty wiki", async () => {
222
+ const root = await makeTempVault();
223
+ const stats = await computeStats(root);
224
+ expect(stats.totalArticles).toBe(0);
225
+ expect(stats.totalWords).toBe(0);
226
+ });
227
+ });
@@ -0,0 +1,148 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { relative } from "node:path";
3
+ import { WIKI_DIR } from "../constants.js";
4
+ import { listWiki } from "../vault.js";
5
+ import { parseFrontmatter } from "./diff.js";
6
+
7
+ interface ArticleMeta {
8
+ title: string;
9
+ slug: string;
10
+ category: string;
11
+ tags: string[];
12
+ summary: string;
13
+ relativePath: string;
14
+ wordCount: number;
15
+ }
16
+
17
+ /**
18
+ * Generate INDEX.md content by reading all wiki articles' frontmatter.
19
+ * This is deterministic — no LLM needed.
20
+ */
21
+ export async function generateIndexMd(root: string): Promise<string> {
22
+ const wikiDir = `${root}/${WIKI_DIR}`;
23
+ const files = await listWiki(root);
24
+
25
+ // Skip INDEX.md and GRAPH.md themselves
26
+ const articleFiles = files.filter((f) => !f.endsWith("INDEX.md") && !f.endsWith("GRAPH.md"));
27
+
28
+ const articles: ArticleMeta[] = [];
29
+
30
+ for (const filePath of articleFiles) {
31
+ const content = await readFile(filePath, "utf-8");
32
+ const { frontmatter, body } = parseFrontmatter(content);
33
+
34
+ const relPath = relative(wikiDir, filePath);
35
+ const wordCount = body.split(/\s+/).filter(Boolean).length;
36
+
37
+ articles.push({
38
+ title: (frontmatter.title as string) ?? relPath.replace(/\.md$/, ""),
39
+ slug: (frontmatter.slug as string) ?? "",
40
+ category: (frontmatter.category as string) ?? categorize(relPath),
41
+ tags: Array.isArray(frontmatter.tags) ? (frontmatter.tags as string[]) : [],
42
+ summary: (frontmatter.summary as string) ?? "",
43
+ relativePath: relPath,
44
+ wordCount,
45
+ });
46
+ }
47
+
48
+ // Group by category
49
+ const grouped = new Map<string, ArticleMeta[]>();
50
+ for (const article of articles) {
51
+ const cat = article.category;
52
+ if (!grouped.has(cat)) {
53
+ grouped.set(cat, []);
54
+ }
55
+ grouped.get(cat)!.push(article);
56
+ }
57
+
58
+ // Sort articles within each category alphabetically
59
+ for (const arts of grouped.values()) {
60
+ arts.sort((a, b) => a.title.localeCompare(b.title));
61
+ }
62
+
63
+ // Compute stats
64
+ const totalArticles = articles.length;
65
+ const totalWords = articles.reduce((sum, a) => sum + a.wordCount, 0);
66
+ const now = new Date().toISOString();
67
+
68
+ // Build the index
69
+ const lines: string[] = [
70
+ "# Knowledge Base Index",
71
+ "",
72
+ `> ${totalArticles} articles | ${totalWords.toLocaleString()} words | Last compiled: ${now}`,
73
+ ];
74
+
75
+ // Ordered category display
76
+ const categoryOrder = ["concept", "topic", "reference", "output"];
77
+ const categoryLabels: Record<string, string> = {
78
+ concept: "Concepts",
79
+ topic: "Topics",
80
+ reference: "References",
81
+ output: "Outputs",
82
+ };
83
+
84
+ for (const cat of categoryOrder) {
85
+ const arts = grouped.get(cat);
86
+ if (!arts || arts.length === 0) continue;
87
+
88
+ lines.push("", `## ${categoryLabels[cat] ?? cat}`, "");
89
+
90
+ for (const article of arts) {
91
+ const tags = article.tags.map((t) => `\`#${t}\``).join(" ");
92
+ const summary = article.summary ? ` -- ${article.summary}` : "";
93
+ lines.push(
94
+ `- **[${article.title}](${article.relativePath})**${summary}${tags ? ` ${tags}` : ""}`,
95
+ );
96
+ }
97
+ }
98
+
99
+ // Any categories not in the standard order
100
+ for (const [cat, arts] of grouped) {
101
+ if (categoryOrder.includes(cat)) continue;
102
+ if (arts.length === 0) continue;
103
+
104
+ const label = cat.charAt(0).toUpperCase() + cat.slice(1);
105
+ lines.push("", `## ${label}`, "");
106
+
107
+ for (const article of arts) {
108
+ const tags = article.tags.map((t) => `\`#${t}\``).join(" ");
109
+ const summary = article.summary ? ` -- ${article.summary}` : "";
110
+ lines.push(
111
+ `- **[${article.title}](${article.relativePath})**${summary}${tags ? ` ${tags}` : ""}`,
112
+ );
113
+ }
114
+ }
115
+
116
+ return lines.join("\n") + "\n";
117
+ }
118
+
119
+ /**
120
+ * Infer category from path when not in frontmatter.
121
+ */
122
+ function categorize(relPath: string): string {
123
+ if (relPath.startsWith("concepts/")) return "concept";
124
+ if (relPath.startsWith("topics/")) return "topic";
125
+ if (relPath.startsWith("references/")) return "reference";
126
+ if (relPath.startsWith("outputs/")) return "output";
127
+ return "topic"; // default
128
+ }
129
+
130
+ /**
131
+ * Compute stats from INDEX.md or articles directly.
132
+ */
133
+ export async function computeStats(root: string): Promise<{
134
+ totalArticles: number;
135
+ totalWords: number;
136
+ }> {
137
+ const files = await listWiki(root);
138
+ const articleFiles = files.filter((f) => !f.endsWith("INDEX.md") && !f.endsWith("GRAPH.md"));
139
+
140
+ let totalWords = 0;
141
+ for (const filePath of articleFiles) {
142
+ const content = await readFile(filePath, "utf-8");
143
+ const { body } = parseFrontmatter(content);
144
+ totalWords += body.split(/\s+/).filter(Boolean).length;
145
+ }
146
+
147
+ return { totalArticles: articleFiles.length, totalWords };
148
+ }