@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,124 @@
1
+ /**
2
+ * System prompt for compiling raw sources into wiki articles.
3
+ */
4
+ export function compileSystemPrompt(categories: string[]): string {
5
+ return `You are a knowledge compiler. You receive raw source material and an existing wiki index. Your job is to:
6
+
7
+ 1. Extract key concepts, topics, and entities from the source
8
+ 2. Create new wiki articles OR update existing ones
9
+ 3. Add [[wiki-style]] links for cross-references between articles
10
+ 4. Maintain consistent style and depth across the wiki
11
+
12
+ RULES:
13
+ - Each article should cover ONE concept or topic clearly
14
+ - Articles should be 200-1000 words
15
+ - Use YAML frontmatter with these fields: title, slug, category, tags, sources, created, updated, summary
16
+ - Use [[wiki-style]] links to reference other articles (use the slug as the link target)
17
+ - Prefer updating existing articles over creating duplicates
18
+ - If a concept already has a wiki article, update it with new information rather than creating a new one
19
+ - Categories: ${categories.join(", ")}
20
+ - Slugs should be kebab-case (e.g., "transformer-architecture")
21
+ - Tags should be lowercase, hyphenated (e.g., "deep-learning")
22
+ - Summary should be 1-2 sentences
23
+
24
+ OUTPUT FORMAT:
25
+ Respond with ONLY a JSON array of file operations. No other text, no markdown code fences, just the raw JSON array:
26
+ [
27
+ {
28
+ "op": "create",
29
+ "path": "wiki/concepts/example-concept.md",
30
+ "content": "---\\ntitle: Example Concept\\nslug: example-concept\\ncategory: concept\\ntags: [example, demo]\\nsources:\\n - raw/articles/source-file.md\\ncreated: 2026-04-05\\nupdated: 2026-04-05\\nsummary: >\\n A brief summary of this concept.\\n---\\n\\n# Example Concept\\n\\nArticle content here..."
31
+ },
32
+ {
33
+ "op": "update",
34
+ "path": "wiki/topics/existing-topic.md",
35
+ "content": "full updated content including frontmatter"
36
+ }
37
+ ]
38
+
39
+ Valid operations:
40
+ - "create": Create a new article at the given path
41
+ - "update": Replace an existing article's content
42
+ - "delete": Remove an article (use sparingly)`;
43
+ }
44
+
45
+ /**
46
+ * Build the user message for a compile pass.
47
+ */
48
+ export function compileUserPrompt(params: {
49
+ indexContent: string;
50
+ sourceContent: string;
51
+ sourcePath: string;
52
+ existingArticles: { path: string; content: string }[];
53
+ today: string;
54
+ }): string {
55
+ const parts: string[] = [];
56
+
57
+ parts.push("CURRENT WIKI INDEX:");
58
+ if (params.indexContent) {
59
+ parts.push(params.indexContent);
60
+ } else {
61
+ parts.push("(empty — this is the first compilation)");
62
+ }
63
+
64
+ if (params.existingArticles.length > 0) {
65
+ parts.push("\n\nEXISTING ARTICLES THAT MAY NEED UPDATES:");
66
+ for (const article of params.existingArticles) {
67
+ parts.push(`\n--- ${article.path} ---`);
68
+ parts.push(article.content);
69
+ }
70
+ }
71
+
72
+ parts.push(`\n\nNEW SOURCE TO COMPILE (from ${params.sourcePath}):`);
73
+ parts.push(params.sourceContent);
74
+
75
+ parts.push(`\n\nToday's date: ${params.today}`);
76
+
77
+ return parts.join("\n");
78
+ }
79
+
80
+ /**
81
+ * System prompt for generating INDEX.md from article metadata.
82
+ */
83
+ export function indexSystemPrompt(): string {
84
+ return `You are a wiki index generator. Given a list of articles with their metadata, generate a clean INDEX.md file.
85
+
86
+ Format:
87
+ # Knowledge Base Index
88
+
89
+ > {count} articles | {total_words} words | Last compiled: {timestamp}
90
+
91
+ ## Concepts
92
+ - **[Title](path)** — Summary. \`#tag1\` \`#tag2\`
93
+
94
+ ## Topics
95
+ - **[Title](path)** — Summary. \`#tag1\` \`#tag2\`
96
+
97
+ ## References
98
+ - **[Title](path)** — Summary. \`#tag1\` \`#tag2\`
99
+
100
+ ## Outputs
101
+ - **[Title](path)** — Summary. \`#tag1\` \`#tag2\`
102
+
103
+ Only include sections that have articles. Sort articles alphabetically within each section.
104
+ Output ONLY the markdown content, no JSON, no code fences.`;
105
+ }
106
+
107
+ /**
108
+ * System prompt for generating GRAPH.md from article links.
109
+ */
110
+ export function graphSystemPrompt(): string {
111
+ return `You are a knowledge graph generator. Given articles and their forward/backward links, generate a GRAPH.md adjacency list.
112
+
113
+ Format:
114
+ # Knowledge Graph
115
+
116
+ slug-a -> slug-b, slug-c, slug-d
117
+ slug-b -> slug-a, slug-e
118
+ slug-c -> slug-a
119
+
120
+ Each line is: source-slug -> comma-separated target slugs
121
+ Only include articles that have at least one connection.
122
+ Sort alphabetically by source slug.
123
+ Output ONLY the markdown content, no JSON, no code fences.`;
124
+ }
@@ -0,0 +1,40 @@
1
+ /** Directory names within a vault */
2
+ export const VAULT_DIR = ".kb";
3
+ export const RAW_DIR = "raw";
4
+ export const WIKI_DIR = "wiki";
5
+ export const INBOX_DIR = "inbox";
6
+ export const CACHE_DIR = "cache";
7
+ export const SKILLS_DIR = "skills";
8
+ export const LOGS_DIR = "logs";
9
+
10
+ /** Files within .kb/ */
11
+ export const MANIFEST_FILE = "manifest.json";
12
+ export const CONFIG_FILE = "config.toml";
13
+
14
+ /** Files within wiki/ */
15
+ export const INDEX_FILE = "INDEX.md";
16
+ export const GRAPH_FILE = "GRAPH.md";
17
+
18
+ /** Wiki categories (subdirectories of wiki/) */
19
+ export const DEFAULT_CATEGORIES = ["concepts", "topics", "references", "outputs"] as const;
20
+
21
+ /** Raw source categories (subdirectories of raw/) */
22
+ export const RAW_CATEGORIES = ["articles", "papers", "repos", "images", "transcripts"] as const;
23
+
24
+ /** Default config values */
25
+ export const DEFAULTS = {
26
+ provider: "anthropic",
27
+ model: "claude-sonnet-4-20250514",
28
+ fastModel: "claude-haiku-4-5-20251001",
29
+ maxSourcesPerPass: 10,
30
+ searchMaxResults: 20,
31
+ cacheTtlHours: 168, // 7 days
32
+ cacheMaxSizeMb: 500,
33
+ watchPollIntervalMs: 2000,
34
+ maxFileSizeMb: 50,
35
+ compileArticleMinWords: 200,
36
+ compileArticleMaxWords: 1000,
37
+ } as const;
38
+
39
+ /** Manifest version */
40
+ export const MANIFEST_VERSION = "1" as const;
package/src/errors.ts ADDED
@@ -0,0 +1,66 @@
1
+ export class KibError extends Error {
2
+ constructor(
3
+ message: string,
4
+ public readonly code: string,
5
+ ) {
6
+ super(message);
7
+ this.name = "KibError";
8
+ }
9
+ }
10
+
11
+ export class VaultNotFoundError extends KibError {
12
+ constructor(path?: string) {
13
+ super(
14
+ path
15
+ ? `No vault found at ${path}. Run 'kib init' to create one.`
16
+ : "No vault found. Run 'kib init' to create one.",
17
+ "VAULT_NOT_FOUND",
18
+ );
19
+ this.name = "VaultNotFoundError";
20
+ }
21
+ }
22
+
23
+ export class VaultExistsError extends KibError {
24
+ constructor(path: string) {
25
+ super(`Vault already exists at ${path}. Use --force to reinitialize.`, "VAULT_EXISTS");
26
+ this.name = "VaultExistsError";
27
+ }
28
+ }
29
+
30
+ export class ProviderError extends KibError {
31
+ constructor(message: string) {
32
+ super(message, "PROVIDER_ERROR");
33
+ this.name = "ProviderError";
34
+ }
35
+ }
36
+
37
+ export class NoProviderError extends KibError {
38
+ constructor() {
39
+ super(
40
+ "No LLM provider found. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or start Ollama.",
41
+ "NO_PROVIDER",
42
+ );
43
+ this.name = "NoProviderError";
44
+ }
45
+ }
46
+
47
+ export class IngestError extends KibError {
48
+ constructor(message: string) {
49
+ super(message, "INGEST_ERROR");
50
+ this.name = "IngestError";
51
+ }
52
+ }
53
+
54
+ export class CompileError extends KibError {
55
+ constructor(message: string) {
56
+ super(message, "COMPILE_ERROR");
57
+ this.name = "CompileError";
58
+ }
59
+ }
60
+
61
+ export class ManifestError extends KibError {
62
+ constructor(message: string) {
63
+ super(message, "MANIFEST_ERROR");
64
+ this.name = "ManifestError";
65
+ }
66
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { hash } from "./hash.js";
3
+
4
+ describe("hash", () => {
5
+ test("returns a hex string", async () => {
6
+ const h = await hash("hello world");
7
+ expect(h).toMatch(/^[0-9a-f]+$/);
8
+ });
9
+
10
+ test("same input produces same hash", async () => {
11
+ const h1 = await hash("test content");
12
+ const h2 = await hash("test content");
13
+ expect(h1).toBe(h2);
14
+ });
15
+
16
+ test("different input produces different hash", async () => {
17
+ const h1 = await hash("content A");
18
+ const h2 = await hash("content B");
19
+ expect(h1).not.toBe(h2);
20
+ });
21
+ });
package/src/hash.ts ADDED
@@ -0,0 +1,24 @@
1
+ let h64: ((input: string) => bigint) | null = null;
2
+
3
+ async function init() {
4
+ if (h64) return;
5
+ const xxhash = await import("xxhash-wasm");
6
+ const hasher = await xxhash.default();
7
+ h64 = hasher.h64;
8
+ }
9
+
10
+ /**
11
+ * Fast content hash using xxhash64.
12
+ * Returns a hex string.
13
+ */
14
+ export async function hash(content: string): Promise<string> {
15
+ try {
16
+ await init();
17
+ return h64!(content).toString(16);
18
+ } catch {
19
+ // Fallback to Bun's built-in hasher
20
+ const hasher = new Bun.CryptoHasher("sha256");
21
+ hasher.update(content);
22
+ return hasher.digest("hex").slice(0, 16);
23
+ }
24
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ export { buildLinkGraph, generateGraphMd } from "./compile/backlinks.js";
2
+ export { CompileCache } from "./compile/cache.js";
3
+ export { compileVault } from "./compile/compiler.js";
4
+ export { extractWikilinks, parseCompileOutput, parseFrontmatter } from "./compile/diff.js";
5
+ export { computeStats, generateIndexMd } from "./compile/index-manager.js";
6
+ export * from "./constants.js";
7
+ export * from "./errors.js";
8
+ export * from "./hash.js";
9
+ export { ingestSource } from "./ingest/ingest.js";
10
+ export { countWords, slugify } from "./ingest/normalize.js";
11
+ export { detectSourceType } from "./ingest/router.js";
12
+ export { lintVault } from "./lint/lint.js";
13
+ export { ALL_RULES } from "./lint/rules.js";
14
+ export { createProvider, detectProvider } from "./providers/router.js";
15
+ export { queryVault } from "./query/query.js";
16
+ export * from "./schemas.js";
17
+ export { SearchIndex } from "./search/engine.js";
18
+ export { findSkill, loadSkills } from "./skills/loader.js";
19
+ export { runSkill } from "./skills/runner.js";
20
+ export { SkillDefinitionSchema } from "./skills/schema.js";
21
+ export * from "./types.js";
22
+ export * from "./vault.js";
@@ -0,0 +1,129 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createFileExtractor } from "./file.js";
6
+
7
+ let tempDir: string;
8
+ const extractor = createFileExtractor();
9
+
10
+ afterEach(async () => {
11
+ if (tempDir) await rm(tempDir, { recursive: true, force: true });
12
+ });
13
+
14
+ async function makeTempDir() {
15
+ tempDir = await mkdtemp(join(tmpdir(), "kib-file-test-"));
16
+ return tempDir;
17
+ }
18
+
19
+ describe("file extractor", () => {
20
+ test("extracts markdown files as-is", async () => {
21
+ const dir = await makeTempDir();
22
+ const path = join(dir, "notes.md");
23
+ await writeFile(path, "# My Notes\n\nSome content here.");
24
+
25
+ const result = await extractor.extract(path);
26
+ expect(result.title).toBe("My Notes"); // Extracted from H1 heading
27
+ expect(result.content).toBe("# My Notes\n\nSome content here.");
28
+ expect(result.metadata.fileType).toBe(".md");
29
+ });
30
+
31
+ test("extracts plain text files as-is", async () => {
32
+ const dir = await makeTempDir();
33
+ const path = join(dir, "readme.txt");
34
+ await writeFile(path, "Plain text content.");
35
+
36
+ const result = await extractor.extract(path);
37
+ expect(result.title).toBe("Readme");
38
+ expect(result.content).toBe("Plain text content.");
39
+ });
40
+
41
+ test("wraps code files in fenced code blocks", async () => {
42
+ const dir = await makeTempDir();
43
+ const path = join(dir, "index.ts");
44
+ await writeFile(path, "const x = 1;\nconsole.log(x);");
45
+
46
+ const result = await extractor.extract(path);
47
+ expect(result.title).toBe("Index");
48
+ expect(result.content).toContain("```typescript");
49
+ expect(result.content).toContain("const x = 1;");
50
+ expect(result.content).toContain("```");
51
+ expect(result.metadata.language).toBe("typescript");
52
+ });
53
+
54
+ test("wraps Python files with python language tag", async () => {
55
+ const dir = await makeTempDir();
56
+ const path = join(dir, "script.py");
57
+ await writeFile(path, "def hello():\n print('hello')");
58
+
59
+ const result = await extractor.extract(path);
60
+ expect(result.content).toContain("```python");
61
+ expect(result.metadata.language).toBe("python");
62
+ });
63
+
64
+ test("wraps JSON files in code blocks", async () => {
65
+ const dir = await makeTempDir();
66
+ const path = join(dir, "config.json");
67
+ await writeFile(path, '{"key": "value"}');
68
+
69
+ const result = await extractor.extract(path);
70
+ expect(result.content).toContain("```json");
71
+ expect(result.content).toContain('"key": "value"');
72
+ });
73
+
74
+ test("wraps YAML files in code blocks", async () => {
75
+ const dir = await makeTempDir();
76
+ const path = join(dir, "config.yaml");
77
+ await writeFile(path, "key: value");
78
+
79
+ const result = await extractor.extract(path);
80
+ expect(result.content).toContain("```yaml");
81
+ });
82
+
83
+ test("uses custom title from options", async () => {
84
+ const dir = await makeTempDir();
85
+ const path = join(dir, "data.md");
86
+ await writeFile(path, "# Content");
87
+
88
+ const result = await extractor.extract(path, { title: "Custom Title" });
89
+ expect(result.title).toBe("Custom Title");
90
+ });
91
+
92
+ test("formats filename with dashes into title", async () => {
93
+ const dir = await makeTempDir();
94
+ const path = join(dir, "my-cool-notes.md");
95
+ await writeFile(path, "content");
96
+
97
+ const result = await extractor.extract(path);
98
+ expect(result.title).toBe("My Cool Notes");
99
+ });
100
+
101
+ test("formats filename with underscores into title", async () => {
102
+ const dir = await makeTempDir();
103
+ const path = join(dir, "project_ideas.md");
104
+ await writeFile(path, "content");
105
+
106
+ const result = await extractor.extract(path);
107
+ expect(result.title).toBe("Project Ideas");
108
+ });
109
+
110
+ test("handles Rust files", async () => {
111
+ const dir = await makeTempDir();
112
+ const path = join(dir, "main.rs");
113
+ await writeFile(path, 'fn main() {\n println!("Hello");\n}');
114
+
115
+ const result = await extractor.extract(path);
116
+ expect(result.content).toContain("```rust");
117
+ expect(result.metadata.language).toBe("rust");
118
+ });
119
+
120
+ test("handles Go files", async () => {
121
+ const dir = await makeTempDir();
122
+ const path = join(dir, "main.go");
123
+ await writeFile(path, "package main\n\nfunc main() {}");
124
+
125
+ const result = await extractor.extract(path);
126
+ expect(result.content).toContain("```go");
127
+ expect(result.metadata.language).toBe("go");
128
+ });
129
+ });
@@ -0,0 +1,136 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename, extname } from "node:path";
3
+ import type { ExtractOptions, Extractor, ExtractResult } from "./interface.js";
4
+
5
+ const CODE_EXTENSIONS = new Set([
6
+ ".ts",
7
+ ".js",
8
+ ".tsx",
9
+ ".jsx",
10
+ ".py",
11
+ ".go",
12
+ ".rs",
13
+ ".java",
14
+ ".c",
15
+ ".cpp",
16
+ ".h",
17
+ ".hpp",
18
+ ".rb",
19
+ ".sh",
20
+ ".bash",
21
+ ".zsh",
22
+ ".sql",
23
+ ".swift",
24
+ ".kt",
25
+ ".scala",
26
+ ".cs",
27
+ ".php",
28
+ ".r",
29
+ ".lua",
30
+ ".zig",
31
+ ".hs",
32
+ ".ex",
33
+ ".exs",
34
+ ".clj",
35
+ ".ml",
36
+ ".fs",
37
+ ]);
38
+
39
+ const LANGUAGE_MAP: Record<string, string> = {
40
+ ".ts": "typescript",
41
+ ".js": "javascript",
42
+ ".tsx": "tsx",
43
+ ".jsx": "jsx",
44
+ ".py": "python",
45
+ ".go": "go",
46
+ ".rs": "rust",
47
+ ".java": "java",
48
+ ".c": "c",
49
+ ".cpp": "cpp",
50
+ ".h": "c",
51
+ ".hpp": "cpp",
52
+ ".rb": "ruby",
53
+ ".sh": "bash",
54
+ ".bash": "bash",
55
+ ".zsh": "zsh",
56
+ ".sql": "sql",
57
+ ".swift": "swift",
58
+ ".kt": "kotlin",
59
+ ".scala": "scala",
60
+ ".cs": "csharp",
61
+ ".php": "php",
62
+ ".r": "r",
63
+ ".lua": "lua",
64
+ ".zig": "zig",
65
+ ".hs": "haskell",
66
+ ".ex": "elixir",
67
+ ".exs": "elixir",
68
+ ".clj": "clojure",
69
+ ".ml": "ocaml",
70
+ ".fs": "fsharp",
71
+ };
72
+
73
+ export function createFileExtractor(): Extractor {
74
+ return {
75
+ type: "file",
76
+
77
+ async extract(filePath: string, options?: ExtractOptions): Promise<ExtractResult> {
78
+ const content = await readFile(filePath, "utf-8");
79
+ const ext = extname(filePath).toLowerCase();
80
+ const name = basename(filePath, ext);
81
+ const title = options?.title ?? extractMarkdownTitle(content) ?? formatTitle(name);
82
+
83
+ // Code files get wrapped in fenced code blocks
84
+ if (CODE_EXTENSIONS.has(ext)) {
85
+ const lang = LANGUAGE_MAP[ext] ?? "";
86
+ const wrappedContent = `# ${title}\n\nSource: \`${basename(filePath)}\`\n\n\`\`\`${lang}\n${content}\n\`\`\``;
87
+ return {
88
+ title,
89
+ content: wrappedContent,
90
+ metadata: { fileType: ext, language: lang },
91
+ };
92
+ }
93
+
94
+ // HTML files get a basic strip
95
+ if (ext === ".html" || ext === ".htm") {
96
+ const { extractFromHtml } = await import("./web.js");
97
+ return extractFromHtml(content, `file://${filePath}`, options);
98
+ }
99
+
100
+ // JSON/YAML/TOML get wrapped in code blocks
101
+ if (
102
+ ext === ".json" ||
103
+ ext === ".yaml" ||
104
+ ext === ".yml" ||
105
+ ext === ".toml" ||
106
+ ext === ".xml" ||
107
+ ext === ".csv"
108
+ ) {
109
+ const lang = ext.replace(".", "");
110
+ const wrappedContent = `# ${title}\n\nSource: \`${basename(filePath)}\`\n\n\`\`\`${lang}\n${content}\n\`\`\``;
111
+ return {
112
+ title,
113
+ content: wrappedContent,
114
+ metadata: { fileType: ext },
115
+ };
116
+ }
117
+
118
+ // Markdown and text files pass through directly
119
+ return {
120
+ title,
121
+ content,
122
+ metadata: { fileType: ext },
123
+ };
124
+ },
125
+ };
126
+ }
127
+
128
+ function formatTitle(filename: string): string {
129
+ return filename.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
130
+ }
131
+
132
+ /** Extract the first H1 heading from markdown content. */
133
+ function extractMarkdownTitle(content: string): string | null {
134
+ const match = content.match(/^#\s+(.+)$/m);
135
+ return match?.[1]?.trim() ?? null;
136
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { parseGithubUrl } from "./github.js";
3
+
4
+ describe("github extractor", () => {
5
+ describe("parseGithubUrl", () => {
6
+ test("parses owner/repo URL", () => {
7
+ const result = parseGithubUrl("https://github.com/anthropics/claude-code");
8
+ expect(result).toEqual({ owner: "anthropics", repo: "claude-code", branch: undefined });
9
+ });
10
+
11
+ test("parses URL with tree/branch", () => {
12
+ const result = parseGithubUrl("https://github.com/anthropics/claude-code/tree/main/src");
13
+ expect(result).toEqual({ owner: "anthropics", repo: "claude-code", branch: "main" });
14
+ });
15
+
16
+ test("parses URL with www", () => {
17
+ const result = parseGithubUrl("https://www.github.com/user/repo");
18
+ expect(result).toEqual({ owner: "user", repo: "repo", branch: undefined });
19
+ });
20
+
21
+ test("returns null for non-github URL", () => {
22
+ expect(parseGithubUrl("https://gitlab.com/user/repo")).toBeNull();
23
+ });
24
+
25
+ test("returns null for github.com with only user", () => {
26
+ expect(parseGithubUrl("https://github.com/user")).toBeNull();
27
+ });
28
+
29
+ test("returns null for github.com root", () => {
30
+ expect(parseGithubUrl("https://github.com")).toBeNull();
31
+ });
32
+
33
+ test("returns null for invalid URL", () => {
34
+ expect(parseGithubUrl("not a url")).toBeNull();
35
+ });
36
+
37
+ test("handles trailing slashes", () => {
38
+ const result = parseGithubUrl("https://github.com/user/repo/");
39
+ expect(result).toEqual({ owner: "user", repo: "repo", branch: undefined });
40
+ });
41
+
42
+ test("handles whitespace", () => {
43
+ const result = parseGithubUrl(" https://github.com/user/repo ");
44
+ expect(result).toEqual({ owner: "user", repo: "repo", branch: undefined });
45
+ });
46
+ });
47
+ });