@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
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@kibhq/core",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts"
7
+ },
8
+ "files": [
9
+ "src",
10
+ "package.json"
11
+ ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/keeganthomp/kib.git",
15
+ "directory": "packages/core"
16
+ },
17
+ "license": "MIT",
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "scripts": {
22
+ "test": "bun test"
23
+ },
24
+ "dependencies": {
25
+ "@anthropic-ai/sdk": "^0.82.0",
26
+ "@iarna/toml": "^2.2.5",
27
+ "cheerio": "^1.2.0",
28
+ "openai": "^6.33.0",
29
+ "pdf-parse": "^2.4.5",
30
+ "turndown": "^7.2.4",
31
+ "xxhash-wasm": "^1.1.0",
32
+ "yaml": "^2.7.1",
33
+ "zod": "^3.24.4"
34
+ },
35
+ "devDependencies": {
36
+ "@types/bun": "latest",
37
+ "@types/turndown": "^5.0.6",
38
+ "typescript": "^5.8.3"
39
+ }
40
+ }
@@ -0,0 +1,112 @@
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 { buildLinkGraph, generateGraphMd } from "./backlinks.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-backlinks-test-"));
16
+ await initVault(tempDir, { name: "test" });
17
+ return tempDir;
18
+ }
19
+
20
+ describe("buildLinkGraph", () => {
21
+ test("builds forward and backward links", async () => {
22
+ const root = await makeTempVault();
23
+
24
+ await writeWiki(
25
+ root,
26
+ "concepts/transformers.md",
27
+ "---\nslug: transformers\n---\n\nSee [[attention-mechanisms]] and [[positional-encoding]].",
28
+ );
29
+ await writeWiki(
30
+ root,
31
+ "concepts/attention-mechanisms.md",
32
+ "---\nslug: attention-mechanisms\n---\n\nPart of [[transformers]].",
33
+ );
34
+ await writeWiki(
35
+ root,
36
+ "concepts/positional-encoding.md",
37
+ "---\nslug: positional-encoding\n---\n\nUsed in [[transformers]].",
38
+ );
39
+
40
+ const graph = await buildLinkGraph(root);
41
+
42
+ // Forward links
43
+ expect([...graph.forwardLinks.get("transformers")!]).toEqual(
44
+ expect.arrayContaining(["attention-mechanisms", "positional-encoding"]),
45
+ );
46
+ expect([...graph.forwardLinks.get("attention-mechanisms")!]).toEqual(["transformers"]);
47
+ expect([...graph.forwardLinks.get("positional-encoding")!]).toEqual(["transformers"]);
48
+
49
+ // Backlinks
50
+ expect([...graph.backlinks.get("transformers")!]).toEqual(
51
+ expect.arrayContaining(["attention-mechanisms", "positional-encoding"]),
52
+ );
53
+ expect([...graph.backlinks.get("attention-mechanisms")!]).toEqual(["transformers"]);
54
+ expect([...graph.backlinks.get("positional-encoding")!]).toEqual(["transformers"]);
55
+ });
56
+
57
+ test("handles articles with no links", async () => {
58
+ const root = await makeTempVault();
59
+ await writeWiki(root, "concepts/orphan.md", "---\nslug: orphan\n---\n\nNo links here.");
60
+
61
+ const graph = await buildLinkGraph(root);
62
+ expect(graph.forwardLinks.get("orphan")!.size).toBe(0);
63
+ expect(graph.backlinks.get("orphan")!.size).toBe(0);
64
+ });
65
+
66
+ test("handles empty wiki", async () => {
67
+ const root = await makeTempVault();
68
+ const graph = await buildLinkGraph(root);
69
+ expect(graph.forwardLinks.size).toBe(0);
70
+ expect(graph.backlinks.size).toBe(0);
71
+ });
72
+ });
73
+
74
+ describe("generateGraphMd", () => {
75
+ test("generates adjacency list", async () => {
76
+ const root = await makeTempVault();
77
+ await writeWiki(root, "concepts/a.md", "---\nslug: alpha\n---\n\n[[beta]] and [[gamma]].");
78
+ await writeWiki(root, "concepts/b.md", "---\nslug: beta\n---\n\n[[alpha]].");
79
+
80
+ const graph = await buildLinkGraph(root);
81
+ const md = generateGraphMd(graph);
82
+
83
+ expect(md).toContain("# Knowledge Graph");
84
+ expect(md).toContain("alpha -> beta, gamma");
85
+ expect(md).toContain("beta -> alpha");
86
+ });
87
+
88
+ test("handles empty graph", () => {
89
+ const graph = {
90
+ forwardLinks: new Map<string, Set<string>>(),
91
+ backlinks: new Map<string, Set<string>>(),
92
+ };
93
+ const md = generateGraphMd(graph);
94
+ expect(md).toContain("(no connections yet)");
95
+ });
96
+
97
+ test("sorts slugs alphabetically", async () => {
98
+ const root = await makeTempVault();
99
+ await writeWiki(root, "concepts/c.md", "---\nslug: charlie\n---\n\n[[alpha]].");
100
+ await writeWiki(root, "concepts/a.md", "---\nslug: alpha\n---\n\n[[charlie]].");
101
+ await writeWiki(root, "concepts/b.md", "---\nslug: bravo\n---\n\n[[alpha]].");
102
+
103
+ const graph = await buildLinkGraph(root);
104
+ const md = generateGraphMd(graph);
105
+ const lines = md.split("\n").filter((l) => l.includes("->"));
106
+
107
+ // Should be alpha, bravo, charlie order
108
+ expect(lines[0]).toMatch(/^alpha/);
109
+ expect(lines[1]).toMatch(/^bravo/);
110
+ expect(lines[2]).toMatch(/^charlie/);
111
+ });
112
+ });
@@ -0,0 +1,80 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { WIKI_DIR } from "../constants.js";
4
+ import { listWiki } from "../vault.js";
5
+ import { extractWikilinks, parseFrontmatter } from "./diff.js";
6
+
7
+ export interface LinkGraph {
8
+ /** slug → set of slugs this article links to */
9
+ forwardLinks: Map<string, Set<string>>;
10
+ /** slug → set of slugs that link to this article */
11
+ backlinks: Map<string, Set<string>>;
12
+ }
13
+
14
+ /**
15
+ * Scan all wiki articles and build the full link graph.
16
+ */
17
+ export async function buildLinkGraph(root: string): Promise<LinkGraph> {
18
+ const forwardLinks = new Map<string, Set<string>>();
19
+ const backlinks = new Map<string, Set<string>>();
20
+
21
+ const files = await listWiki(root);
22
+
23
+ for (const filePath of files) {
24
+ const content = await readFile(filePath, "utf-8");
25
+ const { frontmatter } = parseFrontmatter(content);
26
+ const slug = (frontmatter.slug as string) ?? extractSlugFromPath(filePath);
27
+
28
+ if (!slug) continue;
29
+
30
+ // Extract forward links
31
+ const links = extractWikilinks(content);
32
+ forwardLinks.set(slug, new Set(links));
33
+
34
+ // Initialize backlinks set for this article
35
+ if (!backlinks.has(slug)) {
36
+ backlinks.set(slug, new Set());
37
+ }
38
+
39
+ // Add backlinks for each target
40
+ for (const target of links) {
41
+ if (!backlinks.has(target)) {
42
+ backlinks.set(target, new Set());
43
+ }
44
+ backlinks.get(target)!.add(slug);
45
+ }
46
+ }
47
+
48
+ return { forwardLinks, backlinks };
49
+ }
50
+
51
+ function extractSlugFromPath(filePath: string): string {
52
+ // Extract slug from path like .../wiki/concepts/my-article.md → my-article
53
+ const parts = filePath.split("/");
54
+ const filename = parts[parts.length - 1] ?? "";
55
+ return filename.replace(/\.md$/, "");
56
+ }
57
+
58
+ /**
59
+ * Generate GRAPH.md content from the link graph.
60
+ */
61
+ export function generateGraphMd(graph: LinkGraph): string {
62
+ const lines: string[] = ["# Knowledge Graph", ""];
63
+
64
+ // Sort by slug for consistent output
65
+ const slugs = [...graph.forwardLinks.keys()].sort();
66
+
67
+ for (const slug of slugs) {
68
+ const targets = graph.forwardLinks.get(slug);
69
+ if (targets && targets.size > 0) {
70
+ const sorted = [...targets].sort();
71
+ lines.push(`${slug} -> ${sorted.join(", ")}`);
72
+ }
73
+ }
74
+
75
+ if (lines.length === 2) {
76
+ lines.push("(no connections yet)");
77
+ }
78
+
79
+ return lines.join("\n") + "\n";
80
+ }
@@ -0,0 +1,126 @@
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 } from "../vault.js";
6
+ import { CompileCache } from "./cache.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-cache-test-"));
16
+ await initVault(tempDir, { name: "test" });
17
+ return tempDir;
18
+ }
19
+
20
+ describe("CompileCache", () => {
21
+ test("set and get round-trip", async () => {
22
+ const root = await makeTempVault();
23
+ const cache = new CompileCache(root);
24
+
25
+ const key = await cache.key("system prompt", "user content");
26
+ await cache.set(key, "LLM response content", { inputTokens: 100, outputTokens: 200 });
27
+
28
+ const entry = await cache.get(key);
29
+ expect(entry).not.toBeNull();
30
+ expect(entry!.content).toBe("LLM response content");
31
+ expect(entry!.usage.inputTokens).toBe(100);
32
+ expect(entry!.usage.outputTokens).toBe(200);
33
+ });
34
+
35
+ test("returns null for missing key", async () => {
36
+ const root = await makeTempVault();
37
+ const cache = new CompileCache(root);
38
+
39
+ const entry = await cache.get("nonexistent-key");
40
+ expect(entry).toBeNull();
41
+ });
42
+
43
+ test("returns null when disabled", async () => {
44
+ const root = await makeTempVault();
45
+ const cache = new CompileCache(root, { enabled: false });
46
+
47
+ const key = await cache.key("system", "user");
48
+ await cache.set(key, "content", { inputTokens: 0, outputTokens: 0 });
49
+
50
+ const entry = await cache.get(key);
51
+ expect(entry).toBeNull();
52
+ });
53
+
54
+ test("same inputs produce same key", async () => {
55
+ const root = await makeTempVault();
56
+ const cache = new CompileCache(root);
57
+
58
+ const key1 = await cache.key("system", "user", "model", 0);
59
+ const key2 = await cache.key("system", "user", "model", 0);
60
+ expect(key1).toBe(key2);
61
+ });
62
+
63
+ test("different inputs produce different keys", async () => {
64
+ const root = await makeTempVault();
65
+ const cache = new CompileCache(root);
66
+
67
+ const key1 = await cache.key("system", "user A");
68
+ const key2 = await cache.key("system", "user B");
69
+ expect(key1).not.toBe(key2);
70
+ });
71
+
72
+ test("expires old entries", async () => {
73
+ const root = await makeTempVault();
74
+ const cache = new CompileCache(root, { ttlHours: 0 }); // Expire immediately
75
+
76
+ const key = await cache.key("system", "user");
77
+ await cache.set(key, "content", { inputTokens: 0, outputTokens: 0 });
78
+
79
+ // Wait a tiny bit for the TTL to expire
80
+ await new Promise((r) => setTimeout(r, 10));
81
+
82
+ const entry = await cache.get(key);
83
+ expect(entry).toBeNull();
84
+ });
85
+
86
+ test("clear removes all entries", async () => {
87
+ const root = await makeTempVault();
88
+ const cache = new CompileCache(root);
89
+
90
+ const key1 = await cache.key("system", "user1");
91
+ const key2 = await cache.key("system", "user2");
92
+ await cache.set(key1, "content1", { inputTokens: 0, outputTokens: 0 });
93
+ await cache.set(key2, "content2", { inputTokens: 0, outputTokens: 0 });
94
+
95
+ const cleared = await cache.clear();
96
+ expect(cleared).toBe(2);
97
+
98
+ const entry1 = await cache.get(key1);
99
+ const entry2 = await cache.get(key2);
100
+ expect(entry1).toBeNull();
101
+ expect(entry2).toBeNull();
102
+ });
103
+
104
+ test("stats returns correct counts", async () => {
105
+ const root = await makeTempVault();
106
+ const cache = new CompileCache(root);
107
+
108
+ const key1 = await cache.key("system", "user1");
109
+ const key2 = await cache.key("system", "user2");
110
+ await cache.set(key1, "content1", { inputTokens: 0, outputTokens: 0 });
111
+ await cache.set(key2, "content2", { inputTokens: 0, outputTokens: 0 });
112
+
113
+ const s = await cache.stats();
114
+ expect(s.entries).toBe(2);
115
+ expect(s.sizeBytes).toBeGreaterThan(0);
116
+ });
117
+
118
+ test("stats returns zeros for empty cache", async () => {
119
+ const root = await makeTempVault();
120
+ const cache = new CompileCache(root);
121
+
122
+ const s = await cache.stats();
123
+ expect(s.entries).toBe(0);
124
+ expect(s.sizeBytes).toBe(0);
125
+ });
126
+ });
@@ -0,0 +1,125 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { CACHE_DIR, VAULT_DIR } from "../constants.js";
5
+ import { hash } from "../hash.js";
6
+
7
+ interface CacheEntry {
8
+ content: string;
9
+ usage: { inputTokens: number; outputTokens: number };
10
+ cachedAt: string;
11
+ ttlHours: number;
12
+ }
13
+
14
+ /**
15
+ * LLM response cache.
16
+ * Keys are xxhash of (system + user content + model + temperature).
17
+ * Values are stored as JSON files in .kb/cache/responses/.
18
+ */
19
+ export class CompileCache {
20
+ private cacheDir: string;
21
+ private ttlHours: number;
22
+ private enabled: boolean;
23
+
24
+ constructor(root: string, opts: { enabled?: boolean; ttlHours?: number } = {}) {
25
+ this.cacheDir = join(root, VAULT_DIR, CACHE_DIR, "responses");
26
+ this.ttlHours = opts.ttlHours ?? 168; // 7 days
27
+ this.enabled = opts.enabled ?? true;
28
+ }
29
+
30
+ /**
31
+ * Generate a cache key from prompt parameters.
32
+ */
33
+ async key(system: string, user: string, model?: string, temperature?: number): Promise<string> {
34
+ const input = `${system}\n---\n${user}\n---\n${model ?? ""}\n---\n${temperature ?? 0}`;
35
+ return hash(input);
36
+ }
37
+
38
+ /**
39
+ * Get a cached response if it exists and isn't expired.
40
+ */
41
+ async get(cacheKey: string): Promise<CacheEntry | null> {
42
+ if (!this.enabled) return null;
43
+
44
+ const filePath = join(this.cacheDir, `${cacheKey}.json`);
45
+ if (!existsSync(filePath)) return null;
46
+
47
+ try {
48
+ const raw = await readFile(filePath, "utf-8");
49
+ const entry = JSON.parse(raw) as CacheEntry;
50
+
51
+ // Check TTL
52
+ const cachedAt = new Date(entry.cachedAt);
53
+ const expiresAt = new Date(cachedAt.getTime() + entry.ttlHours * 60 * 60 * 1000);
54
+ if (new Date() > expiresAt) {
55
+ // Expired — delete it
56
+ await rm(filePath, { force: true });
57
+ return null;
58
+ }
59
+
60
+ return entry;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Store a response in the cache.
68
+ */
69
+ async set(
70
+ cacheKey: string,
71
+ content: string,
72
+ usage: { inputTokens: number; outputTokens: number },
73
+ ): Promise<void> {
74
+ if (!this.enabled) return;
75
+
76
+ await mkdir(this.cacheDir, { recursive: true });
77
+
78
+ const entry: CacheEntry = {
79
+ content,
80
+ usage,
81
+ cachedAt: new Date().toISOString(),
82
+ ttlHours: this.ttlHours,
83
+ };
84
+
85
+ await writeFile(
86
+ join(this.cacheDir, `${cacheKey}.json`),
87
+ JSON.stringify(entry, null, 2),
88
+ "utf-8",
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Clear all cached responses.
94
+ */
95
+ async clear(): Promise<number> {
96
+ if (!existsSync(this.cacheDir)) return 0;
97
+
98
+ const files = await readdir(this.cacheDir);
99
+ const jsonFiles = files.filter((f) => f.endsWith(".json"));
100
+
101
+ for (const file of jsonFiles) {
102
+ await rm(join(this.cacheDir, file), { force: true });
103
+ }
104
+
105
+ return jsonFiles.length;
106
+ }
107
+
108
+ /**
109
+ * Get cache statistics.
110
+ */
111
+ async stats(): Promise<{ entries: number; sizeBytes: number }> {
112
+ if (!existsSync(this.cacheDir)) return { entries: 0, sizeBytes: 0 };
113
+
114
+ const files = await readdir(this.cacheDir);
115
+ const jsonFiles = files.filter((f) => f.endsWith(".json"));
116
+
117
+ let sizeBytes = 0;
118
+ for (const file of jsonFiles) {
119
+ const s = await stat(join(this.cacheDir, file));
120
+ sizeBytes += s.size;
121
+ }
122
+
123
+ return { entries: jsonFiles.length, sizeBytes };
124
+ }
125
+ }