@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.
- package/package.json +40 -0
- package/src/compile/backlinks.test.ts +112 -0
- package/src/compile/backlinks.ts +80 -0
- package/src/compile/cache.test.ts +126 -0
- package/src/compile/cache.ts +125 -0
- package/src/compile/compiler.test.ts +278 -0
- package/src/compile/compiler.ts +305 -0
- package/src/compile/diff.test.ts +164 -0
- package/src/compile/diff.ts +121 -0
- package/src/compile/index-manager.test.ts +227 -0
- package/src/compile/index-manager.ts +148 -0
- package/src/compile/prompts.ts +124 -0
- package/src/constants.ts +40 -0
- package/src/errors.ts +66 -0
- package/src/hash.test.ts +21 -0
- package/src/hash.ts +24 -0
- package/src/index.ts +22 -0
- package/src/ingest/extractors/file.test.ts +129 -0
- package/src/ingest/extractors/file.ts +136 -0
- package/src/ingest/extractors/github.test.ts +47 -0
- package/src/ingest/extractors/github.ts +135 -0
- package/src/ingest/extractors/interface.ts +26 -0
- package/src/ingest/extractors/pdf.ts +130 -0
- package/src/ingest/extractors/web.test.ts +242 -0
- package/src/ingest/extractors/web.ts +163 -0
- package/src/ingest/extractors/youtube.test.ts +44 -0
- package/src/ingest/extractors/youtube.ts +166 -0
- package/src/ingest/ingest.test.ts +187 -0
- package/src/ingest/ingest.ts +179 -0
- package/src/ingest/normalize.test.ts +120 -0
- package/src/ingest/normalize.ts +83 -0
- package/src/ingest/router.test.ts +154 -0
- package/src/ingest/router.ts +119 -0
- package/src/lint/lint.test.ts +253 -0
- package/src/lint/lint.ts +43 -0
- package/src/lint/rules.ts +178 -0
- package/src/providers/anthropic.ts +107 -0
- package/src/providers/index.ts +4 -0
- package/src/providers/ollama.ts +101 -0
- package/src/providers/openai.ts +67 -0
- package/src/providers/router.ts +62 -0
- package/src/query/query.test.ts +165 -0
- package/src/query/query.ts +136 -0
- package/src/schemas.ts +193 -0
- package/src/search/engine.test.ts +230 -0
- package/src/search/engine.ts +390 -0
- package/src/skills/loader.ts +163 -0
- package/src/skills/runner.ts +139 -0
- package/src/skills/schema.ts +28 -0
- package/src/skills/skills.test.ts +134 -0
- package/src/types.ts +136 -0
- package/src/vault.test.ts +141 -0
- package/src/vault.ts +251 -0
|
@@ -0,0 +1,134 @@
|
|
|
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 type { CompletionParams, CompletionResult, LLMProvider, StreamChunk } from "../types.js";
|
|
6
|
+
import { initVault, writeWiki } from "../vault.js";
|
|
7
|
+
import { findSkill, loadSkills } from "./loader.js";
|
|
8
|
+
import { runSkill } from "./runner.js";
|
|
9
|
+
|
|
10
|
+
let tempDir: string;
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
if (tempDir) await rm(tempDir, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
async function makeTempVault() {
|
|
17
|
+
tempDir = await mkdtemp(join(tmpdir(), "kib-skills-test-"));
|
|
18
|
+
await initVault(tempDir, { name: "test" });
|
|
19
|
+
return tempDir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function mockProvider(response: string): LLMProvider {
|
|
23
|
+
return {
|
|
24
|
+
name: "mock",
|
|
25
|
+
async complete(): Promise<CompletionResult> {
|
|
26
|
+
return {
|
|
27
|
+
content: response,
|
|
28
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
29
|
+
stopReason: "end_turn",
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
async *stream(): AsyncIterable<StreamChunk> {
|
|
33
|
+
yield { type: "text", text: response };
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function articleMd(title: string, slug: string, content: string): string {
|
|
39
|
+
return `---\ntitle: ${title}\nslug: ${slug}\ncategory: concept\ntags: []\nsummary: ""\n---\n\n# ${title}\n\n${content}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("skill loader", () => {
|
|
43
|
+
test("loads built-in skills", async () => {
|
|
44
|
+
const root = await makeTempVault();
|
|
45
|
+
const skills = await loadSkills(root);
|
|
46
|
+
|
|
47
|
+
expect(skills.length).toBeGreaterThanOrEqual(3);
|
|
48
|
+
expect(skills.some((s) => s.name === "summarize")).toBe(true);
|
|
49
|
+
expect(skills.some((s) => s.name === "flashcards")).toBe(true);
|
|
50
|
+
expect(skills.some((s) => s.name === "connections")).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("finds skill by name", async () => {
|
|
54
|
+
const root = await makeTempVault();
|
|
55
|
+
const skill = await findSkill(root, "summarize");
|
|
56
|
+
|
|
57
|
+
expect(skill).not.toBeNull();
|
|
58
|
+
expect(skill!.name).toBe("summarize");
|
|
59
|
+
expect(skill!.description).toBeTruthy();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("returns null for unknown skill", async () => {
|
|
63
|
+
const root = await makeTempVault();
|
|
64
|
+
const skill = await findSkill(root, "nonexistent");
|
|
65
|
+
expect(skill).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("skill runner", () => {
|
|
70
|
+
test("runs summarize skill", async () => {
|
|
71
|
+
const root = await makeTempVault();
|
|
72
|
+
await writeWiki(
|
|
73
|
+
root,
|
|
74
|
+
"concepts/test.md",
|
|
75
|
+
articleMd("Test Article", "test", "This is test content for summarization."),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const skill = await findSkill(root, "summarize");
|
|
79
|
+
expect(skill).not.toBeNull();
|
|
80
|
+
|
|
81
|
+
const provider = mockProvider("Summary: This is a test article about testing.");
|
|
82
|
+
const result = await runSkill(root, skill!, { provider });
|
|
83
|
+
|
|
84
|
+
expect(result.content).toContain("Summary");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("runs flashcards skill", async () => {
|
|
88
|
+
const root = await makeTempVault();
|
|
89
|
+
await writeWiki(
|
|
90
|
+
root,
|
|
91
|
+
"concepts/ml.md",
|
|
92
|
+
articleMd("Machine Learning", "ml", "ML is a subset of AI that learns from data."),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const skill = await findSkill(root, "flashcards");
|
|
96
|
+
const provider = mockProvider("Q: What is ML?\nA: A subset of AI that learns from data.");
|
|
97
|
+
const result = await runSkill(root, skill!, { provider });
|
|
98
|
+
|
|
99
|
+
expect(result.content).toContain("Q:");
|
|
100
|
+
expect(result.content).toContain("A:");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("throws when LLM required but not provided", async () => {
|
|
104
|
+
const root = await makeTempVault();
|
|
105
|
+
const skill = await findSkill(root, "summarize");
|
|
106
|
+
|
|
107
|
+
expect(runSkill(root, skill!)).rejects.toThrow("requires an LLM provider");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("skill context has access to vault data", async () => {
|
|
111
|
+
const root = await makeTempVault();
|
|
112
|
+
await writeWiki(root, "concepts/a.md", articleMd("Article A", "article-a", "Content A."));
|
|
113
|
+
await writeWiki(root, "concepts/b.md", articleMd("Article B", "article-b", "Content B."));
|
|
114
|
+
|
|
115
|
+
// Custom skill that tests context access
|
|
116
|
+
const testSkill = {
|
|
117
|
+
name: "test-context",
|
|
118
|
+
version: "1.0.0",
|
|
119
|
+
description: "Test skill context",
|
|
120
|
+
input: "wiki" as const,
|
|
121
|
+
output: "report" as const,
|
|
122
|
+
async run(ctx: any) {
|
|
123
|
+
const articles = await ctx.vault.readWiki();
|
|
124
|
+
const index = await ctx.vault.readIndex();
|
|
125
|
+
return {
|
|
126
|
+
content: `Found ${articles.length} articles. Index length: ${index.length}`,
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const result = await runSkill(root, testSkill as any);
|
|
132
|
+
expect(result.content).toContain("Found 2 articles");
|
|
133
|
+
});
|
|
134
|
+
});
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type {
|
|
3
|
+
ArticleCategorySchema,
|
|
4
|
+
ArticleEntrySchema,
|
|
5
|
+
ArticleFrontmatterSchema,
|
|
6
|
+
CompileResultSchema,
|
|
7
|
+
CompletionParamsSchema,
|
|
8
|
+
CompletionResultSchema,
|
|
9
|
+
FileOperationSchema,
|
|
10
|
+
IngestResultSchema,
|
|
11
|
+
LintDiagnosticSchema,
|
|
12
|
+
LintRuleSchema,
|
|
13
|
+
LintSeveritySchema,
|
|
14
|
+
ManifestSchema,
|
|
15
|
+
MessageRoleSchema,
|
|
16
|
+
MessageSchema,
|
|
17
|
+
SearchResultSchema,
|
|
18
|
+
SourceEntrySchema,
|
|
19
|
+
SourceTypeSchema,
|
|
20
|
+
VaultConfigSchema,
|
|
21
|
+
} from "./schemas.js";
|
|
22
|
+
|
|
23
|
+
// ─── Core Data Types ─────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export type SourceType = z.infer<typeof SourceTypeSchema>;
|
|
26
|
+
export type ArticleCategory = z.infer<typeof ArticleCategorySchema>;
|
|
27
|
+
export type SourceEntry = z.infer<typeof SourceEntrySchema>;
|
|
28
|
+
export type ArticleEntry = z.infer<typeof ArticleEntrySchema>;
|
|
29
|
+
export type Manifest = z.infer<typeof ManifestSchema>;
|
|
30
|
+
export type VaultConfig = z.infer<typeof VaultConfigSchema>;
|
|
31
|
+
export type ArticleFrontmatter = z.infer<typeof ArticleFrontmatterSchema>;
|
|
32
|
+
|
|
33
|
+
// ─── LLM Types ───────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export type MessageRole = z.infer<typeof MessageRoleSchema>;
|
|
36
|
+
export type Message = z.infer<typeof MessageSchema>;
|
|
37
|
+
export type CompletionParams = z.infer<typeof CompletionParamsSchema>;
|
|
38
|
+
export type CompletionResult = z.infer<typeof CompletionResultSchema>;
|
|
39
|
+
|
|
40
|
+
// ─── Operation Types ─────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export type FileOperation = z.infer<typeof FileOperationSchema>;
|
|
43
|
+
export type SearchResult = z.infer<typeof SearchResultSchema>;
|
|
44
|
+
export type IngestResult = z.infer<typeof IngestResultSchema>;
|
|
45
|
+
export type CompileResult = z.infer<typeof CompileResultSchema>;
|
|
46
|
+
|
|
47
|
+
// ─── Lint Types ──────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export type LintSeverity = z.infer<typeof LintSeveritySchema>;
|
|
50
|
+
export type LintRule = z.infer<typeof LintRuleSchema>;
|
|
51
|
+
export type LintDiagnostic = z.infer<typeof LintDiagnosticSchema>;
|
|
52
|
+
|
|
53
|
+
// ─── Provider Interface ──────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
export interface StreamChunk {
|
|
56
|
+
type: "text" | "usage";
|
|
57
|
+
text?: string;
|
|
58
|
+
usage?: { inputTokens: number; outputTokens: number };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface LLMProvider {
|
|
62
|
+
name: string;
|
|
63
|
+
|
|
64
|
+
complete(params: CompletionParams): Promise<CompletionResult>;
|
|
65
|
+
|
|
66
|
+
stream(params: CompletionParams): AsyncIterable<StreamChunk>;
|
|
67
|
+
|
|
68
|
+
/** Optional: for vision-based ingest */
|
|
69
|
+
vision?(params: { image: Buffer; prompt: string }): Promise<string>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Skill Types ─────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export interface SkillContext {
|
|
75
|
+
vault: {
|
|
76
|
+
readIndex(): Promise<string>;
|
|
77
|
+
readGraph(): Promise<string>;
|
|
78
|
+
readWiki(): Promise<{ title: string; slug: string; content: string }[]>;
|
|
79
|
+
readRaw(): Promise<{ path: string; content: string }[]>;
|
|
80
|
+
readFile(path: string): Promise<string>;
|
|
81
|
+
writeFile(path: string, content: string): Promise<void>;
|
|
82
|
+
listFiles(glob: string): Promise<string[]>;
|
|
83
|
+
manifest: Manifest;
|
|
84
|
+
config: VaultConfig;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
llm: {
|
|
88
|
+
complete(params: CompletionParams): Promise<CompletionResult>;
|
|
89
|
+
stream(params: CompletionParams): AsyncIterable<StreamChunk>;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
search: {
|
|
93
|
+
query(term: string, opts?: { limit?: number }): Promise<SearchResult[]>;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
logger: {
|
|
97
|
+
info(msg: string): void;
|
|
98
|
+
warn(msg: string): void;
|
|
99
|
+
error(msg: string): void;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
args: Record<string, unknown>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface SkillDefinition {
|
|
106
|
+
name: string;
|
|
107
|
+
version: string;
|
|
108
|
+
description: string;
|
|
109
|
+
author?: string;
|
|
110
|
+
|
|
111
|
+
input: "wiki" | "raw" | "vault" | "selection" | "index" | "none";
|
|
112
|
+
output: "articles" | "report" | "mutations" | "stdout" | "none";
|
|
113
|
+
|
|
114
|
+
llm?: {
|
|
115
|
+
required: boolean;
|
|
116
|
+
model: "default" | "fast";
|
|
117
|
+
systemPrompt: string;
|
|
118
|
+
maxTokens?: number;
|
|
119
|
+
temperature?: number;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
run(ctx: SkillContext): Promise<{ content?: string }>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Extract Types ───────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
export interface ExtractResult {
|
|
128
|
+
title: string;
|
|
129
|
+
content: string;
|
|
130
|
+
metadata: Record<string, unknown>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface Extractor {
|
|
134
|
+
type: SourceType;
|
|
135
|
+
extract(input: string): Promise<ExtractResult>;
|
|
136
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
initVault,
|
|
8
|
+
listWiki,
|
|
9
|
+
loadConfig,
|
|
10
|
+
loadManifest,
|
|
11
|
+
readRaw,
|
|
12
|
+
readWiki,
|
|
13
|
+
resolveVaultRoot,
|
|
14
|
+
writeRaw,
|
|
15
|
+
writeWiki,
|
|
16
|
+
} from "./vault.js";
|
|
17
|
+
|
|
18
|
+
let tempDir: string;
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
if (tempDir) {
|
|
22
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
async function makeTempDir() {
|
|
27
|
+
tempDir = await mkdtemp(join(tmpdir(), "kib-test-"));
|
|
28
|
+
return tempDir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("initVault", () => {
|
|
32
|
+
test("creates vault directory structure", async () => {
|
|
33
|
+
const dir = await makeTempDir();
|
|
34
|
+
const { root, manifest, config } = await initVault(dir, { name: "test-vault" });
|
|
35
|
+
|
|
36
|
+
expect(root).toBe(dir);
|
|
37
|
+
expect(existsSync(join(dir, ".kb"))).toBe(true);
|
|
38
|
+
expect(existsSync(join(dir, ".kb", "manifest.json"))).toBe(true);
|
|
39
|
+
expect(existsSync(join(dir, ".kb", "config.toml"))).toBe(true);
|
|
40
|
+
expect(existsSync(join(dir, "raw"))).toBe(true);
|
|
41
|
+
expect(existsSync(join(dir, "wiki"))).toBe(true);
|
|
42
|
+
expect(existsSync(join(dir, "wiki", "concepts"))).toBe(true);
|
|
43
|
+
expect(existsSync(join(dir, "wiki", "topics"))).toBe(true);
|
|
44
|
+
expect(existsSync(join(dir, "wiki", "references"))).toBe(true);
|
|
45
|
+
expect(existsSync(join(dir, "wiki", "outputs"))).toBe(true);
|
|
46
|
+
expect(existsSync(join(dir, "inbox"))).toBe(true);
|
|
47
|
+
|
|
48
|
+
expect(manifest.vault.name).toBe("test-vault");
|
|
49
|
+
expect(manifest.version).toBe("1");
|
|
50
|
+
expect(config.provider.default).toBe("anthropic");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("throws if vault already exists", async () => {
|
|
54
|
+
const dir = await makeTempDir();
|
|
55
|
+
await initVault(dir);
|
|
56
|
+
expect(initVault(dir)).rejects.toThrow("already exists");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("allows reinit with force", async () => {
|
|
60
|
+
const dir = await makeTempDir();
|
|
61
|
+
await initVault(dir, { name: "first" });
|
|
62
|
+
const { manifest } = await initVault(dir, { name: "second", force: true });
|
|
63
|
+
expect(manifest.vault.name).toBe("second");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("resolveVaultRoot", () => {
|
|
68
|
+
test("finds vault root from subdirectory", async () => {
|
|
69
|
+
const dir = await makeTempDir();
|
|
70
|
+
await initVault(dir);
|
|
71
|
+
const subdir = join(dir, "wiki", "concepts");
|
|
72
|
+
expect(resolveVaultRoot(subdir)).toBe(dir);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("throws if no vault found", async () => {
|
|
76
|
+
const dir = await makeTempDir();
|
|
77
|
+
expect(() => resolveVaultRoot(dir)).toThrow("No vault found");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("manifest operations", () => {
|
|
82
|
+
test("loadManifest round-trips through saveManifest", async () => {
|
|
83
|
+
const dir = await makeTempDir();
|
|
84
|
+
await initVault(dir, { name: "roundtrip-test" });
|
|
85
|
+
const manifest = await loadManifest(dir);
|
|
86
|
+
expect(manifest.vault.name).toBe("roundtrip-test");
|
|
87
|
+
expect(manifest.sources).toEqual({});
|
|
88
|
+
expect(manifest.articles).toEqual({});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("config operations", () => {
|
|
93
|
+
test("loadConfig returns valid config", async () => {
|
|
94
|
+
const dir = await makeTempDir();
|
|
95
|
+
await initVault(dir);
|
|
96
|
+
const config = await loadConfig(dir);
|
|
97
|
+
expect(config.provider.default).toBe("anthropic");
|
|
98
|
+
expect(config.compile.auto_index).toBe(true);
|
|
99
|
+
expect(config.cache.enabled).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("raw file operations", () => {
|
|
104
|
+
test("writeRaw and readRaw round-trip", async () => {
|
|
105
|
+
const dir = await makeTempDir();
|
|
106
|
+
await initVault(dir);
|
|
107
|
+
const content = "# Test Article\n\nSome content here.";
|
|
108
|
+
await writeRaw(dir, "articles/test.md", content);
|
|
109
|
+
const read = await readRaw(dir, "articles/test.md");
|
|
110
|
+
expect(read).toBe(content);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("writeRaw creates subdirectories", async () => {
|
|
114
|
+
const dir = await makeTempDir();
|
|
115
|
+
await initVault(dir);
|
|
116
|
+
await writeRaw(dir, "papers/deep/nested/file.md", "content");
|
|
117
|
+
expect(existsSync(join(dir, "raw", "papers", "deep", "nested", "file.md"))).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("wiki file operations", () => {
|
|
122
|
+
test("writeWiki and readWiki round-trip", async () => {
|
|
123
|
+
const dir = await makeTempDir();
|
|
124
|
+
await initVault(dir);
|
|
125
|
+
const content = "---\ntitle: Test\n---\n\n# Test\n\nContent.";
|
|
126
|
+
await writeWiki(dir, "concepts/test.md", content);
|
|
127
|
+
const read = await readWiki(dir, "concepts/test.md");
|
|
128
|
+
expect(read).toBe(content);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("listWiki returns markdown files", async () => {
|
|
132
|
+
const dir = await makeTempDir();
|
|
133
|
+
await initVault(dir);
|
|
134
|
+
await writeWiki(dir, "concepts/a.md", "# A");
|
|
135
|
+
await writeWiki(dir, "topics/b.md", "# B");
|
|
136
|
+
const files = await listWiki(dir);
|
|
137
|
+
expect(files.length).toBe(2);
|
|
138
|
+
expect(files.some((f) => f.endsWith("a.md"))).toBe(true);
|
|
139
|
+
expect(files.some((f) => f.endsWith("b.md"))).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
package/src/vault.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import TOML from "@iarna/toml";
|
|
5
|
+
import {
|
|
6
|
+
CACHE_DIR,
|
|
7
|
+
CONFIG_FILE,
|
|
8
|
+
DEFAULT_CATEGORIES,
|
|
9
|
+
DEFAULTS,
|
|
10
|
+
GRAPH_FILE,
|
|
11
|
+
INBOX_DIR,
|
|
12
|
+
INDEX_FILE,
|
|
13
|
+
LOGS_DIR,
|
|
14
|
+
MANIFEST_FILE,
|
|
15
|
+
MANIFEST_VERSION,
|
|
16
|
+
RAW_CATEGORIES,
|
|
17
|
+
RAW_DIR,
|
|
18
|
+
SKILLS_DIR,
|
|
19
|
+
VAULT_DIR,
|
|
20
|
+
WIKI_DIR,
|
|
21
|
+
} from "./constants.js";
|
|
22
|
+
import { ManifestError, VaultExistsError, VaultNotFoundError } from "./errors.js";
|
|
23
|
+
import { ManifestSchema, VaultConfigSchema } from "./schemas.js";
|
|
24
|
+
import type { Manifest, VaultConfig } from "./types.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Find the vault root by walking up from startDir looking for .kb/
|
|
28
|
+
*/
|
|
29
|
+
export function resolveVaultRoot(startDir?: string): string {
|
|
30
|
+
let dir = resolve(startDir ?? process.cwd());
|
|
31
|
+
while (true) {
|
|
32
|
+
if (existsSync(join(dir, VAULT_DIR))) {
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
const parent = dirname(dir);
|
|
36
|
+
if (parent === dir) {
|
|
37
|
+
throw new VaultNotFoundError(startDir);
|
|
38
|
+
}
|
|
39
|
+
dir = parent;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Initialize a new vault at the given path.
|
|
45
|
+
*/
|
|
46
|
+
export async function initVault(
|
|
47
|
+
rootDir: string,
|
|
48
|
+
opts: { name?: string; provider?: string; model?: string; force?: boolean } = {},
|
|
49
|
+
): Promise<{ root: string; manifest: Manifest; config: VaultConfig }> {
|
|
50
|
+
const root = resolve(rootDir);
|
|
51
|
+
const kbDir = join(root, VAULT_DIR);
|
|
52
|
+
|
|
53
|
+
if (existsSync(kbDir) && !opts.force) {
|
|
54
|
+
throw new VaultExistsError(root);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Create directory structure
|
|
58
|
+
const dirs = [
|
|
59
|
+
kbDir,
|
|
60
|
+
join(kbDir, CACHE_DIR),
|
|
61
|
+
join(kbDir, CACHE_DIR, "responses"),
|
|
62
|
+
join(kbDir, SKILLS_DIR),
|
|
63
|
+
join(kbDir, LOGS_DIR),
|
|
64
|
+
join(root, INBOX_DIR),
|
|
65
|
+
join(root, WIKI_DIR),
|
|
66
|
+
...DEFAULT_CATEGORIES.map((c) => join(root, WIKI_DIR, c)),
|
|
67
|
+
join(root, RAW_DIR),
|
|
68
|
+
...RAW_CATEGORIES.map((c) => join(root, RAW_DIR, c)),
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
await Promise.all(dirs.map((d) => mkdir(d, { recursive: true })));
|
|
72
|
+
|
|
73
|
+
const now = new Date().toISOString();
|
|
74
|
+
const provider = opts.provider ?? DEFAULTS.provider;
|
|
75
|
+
const model = opts.model ?? DEFAULTS.model;
|
|
76
|
+
const name = opts.name ?? basename(root);
|
|
77
|
+
|
|
78
|
+
const manifest: Manifest = {
|
|
79
|
+
version: MANIFEST_VERSION,
|
|
80
|
+
vault: {
|
|
81
|
+
name,
|
|
82
|
+
created: now,
|
|
83
|
+
lastCompiled: null,
|
|
84
|
+
provider,
|
|
85
|
+
model,
|
|
86
|
+
},
|
|
87
|
+
sources: {},
|
|
88
|
+
articles: {},
|
|
89
|
+
stats: {
|
|
90
|
+
totalSources: 0,
|
|
91
|
+
totalArticles: 0,
|
|
92
|
+
totalWords: 0,
|
|
93
|
+
lastLintAt: null,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const config: VaultConfig = VaultConfigSchema.parse({
|
|
98
|
+
provider: { default: provider, model, fast_model: DEFAULTS.fastModel },
|
|
99
|
+
compile: {},
|
|
100
|
+
ingest: {},
|
|
101
|
+
watch: {},
|
|
102
|
+
search: {},
|
|
103
|
+
query: {},
|
|
104
|
+
cache: {},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await saveManifest(root, manifest);
|
|
108
|
+
await saveConfig(root, config);
|
|
109
|
+
|
|
110
|
+
return { root, manifest, config };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ─── Manifest Operations ─────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
export async function loadManifest(root: string): Promise<Manifest> {
|
|
116
|
+
const path = join(root, VAULT_DIR, MANIFEST_FILE);
|
|
117
|
+
try {
|
|
118
|
+
const raw = await readFile(path, "utf-8");
|
|
119
|
+
const data = JSON.parse(raw);
|
|
120
|
+
return ManifestSchema.parse(data);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
123
|
+
throw new VaultNotFoundError(root);
|
|
124
|
+
}
|
|
125
|
+
throw new ManifestError(`Failed to load manifest: ${err}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function saveManifest(root: string, manifest: Manifest): Promise<void> {
|
|
130
|
+
const path = join(root, VAULT_DIR, MANIFEST_FILE);
|
|
131
|
+
const tmp = `${path}.tmp`;
|
|
132
|
+
await writeFile(tmp, JSON.stringify(manifest, null, 2), "utf-8");
|
|
133
|
+
await rename(tmp, path);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Config Operations ───────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
export async function loadConfig(root: string): Promise<VaultConfig> {
|
|
139
|
+
const path = join(root, VAULT_DIR, CONFIG_FILE);
|
|
140
|
+
try {
|
|
141
|
+
const raw = await readFile(path, "utf-8");
|
|
142
|
+
const data = TOML.parse(raw);
|
|
143
|
+
return VaultConfigSchema.parse(data);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
146
|
+
throw new VaultNotFoundError(root);
|
|
147
|
+
}
|
|
148
|
+
throw new ManifestError(`Failed to load config: ${err}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function saveConfig(root: string, config: VaultConfig): Promise<void> {
|
|
153
|
+
const path = join(root, VAULT_DIR, CONFIG_FILE);
|
|
154
|
+
const tmp = `${path}.tmp`;
|
|
155
|
+
await writeFile(tmp, TOML.stringify(config as any), "utf-8");
|
|
156
|
+
await rename(tmp, path);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Raw File Operations ─────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
export async function writeRaw(
|
|
162
|
+
root: string,
|
|
163
|
+
relativePath: string,
|
|
164
|
+
content: string,
|
|
165
|
+
): Promise<string> {
|
|
166
|
+
const fullPath = join(root, RAW_DIR, relativePath);
|
|
167
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
168
|
+
const tmp = `${fullPath}.tmp`;
|
|
169
|
+
await writeFile(tmp, content, "utf-8");
|
|
170
|
+
await rename(tmp, fullPath);
|
|
171
|
+
return fullPath;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function readRaw(root: string, relativePath: string): Promise<string> {
|
|
175
|
+
const fullPath = join(root, RAW_DIR, relativePath);
|
|
176
|
+
return readFile(fullPath, "utf-8");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function listRaw(root: string): Promise<string[]> {
|
|
180
|
+
return listFilesRecursive(join(root, RAW_DIR));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Wiki File Operations ────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
export async function writeWiki(
|
|
186
|
+
root: string,
|
|
187
|
+
relativePath: string,
|
|
188
|
+
content: string,
|
|
189
|
+
): Promise<string> {
|
|
190
|
+
const fullPath = join(root, WIKI_DIR, relativePath);
|
|
191
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
192
|
+
const tmp = `${fullPath}.tmp`;
|
|
193
|
+
await writeFile(tmp, content, "utf-8");
|
|
194
|
+
await rename(tmp, fullPath);
|
|
195
|
+
return fullPath;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function readWiki(root: string, relativePath: string): Promise<string> {
|
|
199
|
+
const fullPath = join(root, WIKI_DIR, relativePath);
|
|
200
|
+
return readFile(fullPath, "utf-8");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function listWiki(root: string): Promise<string[]> {
|
|
204
|
+
return listFilesRecursive(join(root, WIKI_DIR));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function readIndex(root: string): Promise<string> {
|
|
208
|
+
try {
|
|
209
|
+
return await readFile(join(root, WIKI_DIR, INDEX_FILE), "utf-8");
|
|
210
|
+
} catch {
|
|
211
|
+
return "";
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function readGraph(root: string): Promise<string> {
|
|
216
|
+
try {
|
|
217
|
+
return await readFile(join(root, WIKI_DIR, GRAPH_FILE), "utf-8");
|
|
218
|
+
} catch {
|
|
219
|
+
return "";
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── Helpers ─────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
async function listFilesRecursive(dir: string): Promise<string[]> {
|
|
226
|
+
const results: string[] = [];
|
|
227
|
+
try {
|
|
228
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
229
|
+
for (const entry of entries) {
|
|
230
|
+
const fullPath = join(dir, entry.name);
|
|
231
|
+
if (entry.isDirectory()) {
|
|
232
|
+
results.push(...(await listFilesRecursive(fullPath)));
|
|
233
|
+
} else if (entry.name.endsWith(".md")) {
|
|
234
|
+
results.push(fullPath);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
// Directory doesn't exist yet — that's fine
|
|
239
|
+
}
|
|
240
|
+
return results;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function deleteFile(path: string): Promise<void> {
|
|
244
|
+
try {
|
|
245
|
+
await unlink(path);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|