ralph-hero-knowledge-index 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 (47) hide show
  1. package/.claude-plugin/plugin.json +21 -0
  2. package/.mcp.json +12 -0
  3. package/dist/db.d.ts +30 -0
  4. package/dist/db.js +73 -0
  5. package/dist/db.js.map +1 -0
  6. package/dist/embedder.d.ts +4 -0
  7. package/dist/embedder.js +24 -0
  8. package/dist/embedder.js.map +1 -0
  9. package/dist/hybrid-search.d.ts +13 -0
  10. package/dist/hybrid-search.js +85 -0
  11. package/dist/hybrid-search.js.map +1 -0
  12. package/dist/index.d.ts +14 -0
  13. package/dist/index.js +64 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/parser.d.ts +18 -0
  16. package/dist/parser.js +39 -0
  17. package/dist/parser.js.map +1 -0
  18. package/dist/reindex.d.ts +1 -0
  19. package/dist/reindex.js +77 -0
  20. package/dist/reindex.js.map +1 -0
  21. package/dist/search.d.ts +23 -0
  22. package/dist/search.js +63 -0
  23. package/dist/search.js.map +1 -0
  24. package/dist/traverse.d.ts +22 -0
  25. package/dist/traverse.js +91 -0
  26. package/dist/traverse.js.map +1 -0
  27. package/dist/vector-search.d.ts +15 -0
  28. package/dist/vector-search.js +52 -0
  29. package/dist/vector-search.js.map +1 -0
  30. package/package.json +27 -0
  31. package/src/__tests__/db.test.ts +51 -0
  32. package/src/__tests__/hybrid-search.test.ts +112 -0
  33. package/src/__tests__/index.test.ts +8 -0
  34. package/src/__tests__/parser.test.ts +100 -0
  35. package/src/__tests__/search.test.ts +92 -0
  36. package/src/__tests__/traverse.test.ts +115 -0
  37. package/src/__tests__/vector-search.test.ts +66 -0
  38. package/src/db.ts +103 -0
  39. package/src/embedder.ts +37 -0
  40. package/src/hybrid-search.ts +102 -0
  41. package/src/index.ts +76 -0
  42. package/src/parser.ts +63 -0
  43. package/src/reindex.ts +89 -0
  44. package/src/search.ts +92 -0
  45. package/src/traverse.ts +130 -0
  46. package/src/vector-search.ts +64 -0
  47. package/tsconfig.json +17 -0
@@ -0,0 +1,91 @@
1
+ export class Traverser {
2
+ db;
3
+ constructor(db) {
4
+ this.db = db;
5
+ }
6
+ traverse(fromId, options = {}) {
7
+ const { type, depth = 3 } = options;
8
+ const typeFilter = type ? "AND r.type = @type" : "";
9
+ const sql = `
10
+ WITH RECURSIVE chain AS (
11
+ SELECT r.source_id, r.target_id, r.type, 1 AS depth
12
+ FROM relationships r
13
+ WHERE r.source_id = @fromId ${typeFilter}
14
+
15
+ UNION ALL
16
+
17
+ SELECT r.source_id, r.target_id, r.type, c.depth + 1
18
+ FROM relationships r
19
+ JOIN chain c ON r.source_id = c.target_id
20
+ WHERE c.depth < @depth ${typeFilter}
21
+ )
22
+ SELECT
23
+ chain.source_id AS sourceId,
24
+ chain.target_id AS targetId,
25
+ chain.type,
26
+ chain.depth,
27
+ d.title AS docTitle,
28
+ d.status AS docStatus,
29
+ d.date AS docDate
30
+ FROM chain
31
+ LEFT JOIN documents d ON d.id = chain.target_id
32
+ ORDER BY chain.depth, chain.target_id
33
+ `;
34
+ const params = { fromId, depth };
35
+ if (type)
36
+ params.type = type;
37
+ const rows = this.db.db.prepare(sql).all(params);
38
+ return rows.map((r) => ({
39
+ sourceId: r.sourceId,
40
+ targetId: r.targetId,
41
+ type: r.type,
42
+ depth: r.depth,
43
+ doc: r.docTitle != null
44
+ ? { title: r.docTitle, status: r.docStatus, date: r.docDate }
45
+ : null,
46
+ }));
47
+ }
48
+ traverseIncoming(toId, options = {}) {
49
+ const { type, depth = 3 } = options;
50
+ const typeFilter = type ? "AND r.type = @type" : "";
51
+ const sql = `
52
+ WITH RECURSIVE chain AS (
53
+ SELECT r.source_id, r.target_id, r.type, 1 AS depth
54
+ FROM relationships r
55
+ WHERE r.target_id = @toId ${typeFilter}
56
+
57
+ UNION ALL
58
+
59
+ SELECT r.source_id, r.target_id, r.type, c.depth + 1
60
+ FROM relationships r
61
+ JOIN chain c ON r.target_id = c.source_id
62
+ WHERE c.depth < @depth ${typeFilter}
63
+ )
64
+ SELECT
65
+ chain.source_id AS sourceId,
66
+ chain.target_id AS targetId,
67
+ chain.type,
68
+ chain.depth,
69
+ d.title AS docTitle,
70
+ d.status AS docStatus,
71
+ d.date AS docDate
72
+ FROM chain
73
+ LEFT JOIN documents d ON d.id = chain.source_id
74
+ ORDER BY chain.depth, chain.target_id
75
+ `;
76
+ const params = { toId, depth };
77
+ if (type)
78
+ params.type = type;
79
+ const rows = this.db.db.prepare(sql).all(params);
80
+ return rows.map((r) => ({
81
+ sourceId: r.sourceId,
82
+ targetId: r.targetId,
83
+ type: r.type,
84
+ depth: r.depth,
85
+ doc: r.docTitle != null
86
+ ? { title: r.docTitle, status: r.docStatus, date: r.docDate }
87
+ : null,
88
+ }));
89
+ }
90
+ }
91
+ //# sourceMappingURL=traverse.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"traverse.js","sourceRoot":"","sources":["../src/traverse.ts"],"names":[],"mappings":"AAeA,MAAM,OAAO,SAAS;IACH,EAAE,CAAc;IAEjC,YAAY,EAAe;QACzB,IAAI,CAAC,EAAE,GAAG,EAAE,CAAC;IACf,CAAC;IAED,QAAQ,CAAC,MAAc,EAAE,UAA2B,EAAE;QACpD,MAAM,EAAE,IAAI,EAAE,KAAK,GAAG,CAAC,EAAE,GAAG,OAAO,CAAC;QACpC,MAAM,UAAU,GAAG,IAAI,CAAC,CAAC,CAAC,oBAAoB,CAAC,CAAC,CAAC,EAAE,CAAC;QAEpD,MAAM,GAAG,GAAG;;;;sCAIsB,UAAU;;;;;;;iCAOf,UAAU;;;;;;;;;;;;;KAatC,CAAC;QAEF,MAAM,MAAM,GAA4B,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC;QAC1D,IAAI,IAAI;YAAE,MAAM,CAAC,IAAI,GAAG,IAAI,CAAC;QAE7B,MAAM,IAAI,GAAG,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,MAAM,CAQ7C,CAAC;QAEH,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YACtB,QAAQ,EAAE,CAAC,CAAC,QAAQ;YACpB,QAAQ,EAAE,CAAC,CAAC,QAAQ;YACpB,IAAI,EAAE,CAAC,CAAC,IAAI;YACZ,KAAK,EAAE,CAAC,CAAC,KAAK;YACd,GAAG,EAAE,CAAC,CAAC,QAAQ,IAAI,IAAI;gBACrB,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC,OAAO,EAAE;gBAC7D,CAAC,CAAC,IAAI;SACT,CAAC,CAAC,CAAC;IACN,CAAC;IAED,gBAAgB,CAAC,IAAY,EAAE,UAA2B,EAAE;QAC1D,MAAM,EAAE,IAAI,EAAE,KAAK,GAAG,CAAC,EAAE,GAAG,OAAO,CAAC;QACpC,MAAM,UAAU,GAAG,IAAI,CAAC,CAAC,CAAC,oBAAoB,CAAC,CAAC,CAAC,EAAE,CAAC;QAEpD,MAAM,GAAG,GAAG;;;;oCAIoB,UAAU;;;;;;;iCAOb,UAAU;;;;;;;;;;;;;KAatC,CAAC;QAEF,MAAM,MAAM,GAA4B,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;QACxD,IAAI,IAAI;YAAE,MAAM,CAAC,IAAI,GAAG,IAAI,CAAC;QAE7B,MAAM,IAAI,GAAG,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,MAAM,CAQ7C,CAAC;QAEH,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YACtB,QAAQ,EAAE,CAAC,CAAC,QAAQ;YACpB,QAAQ,EAAE,CAAC,CAAC,QAAQ;YACpB,IAAI,EAAE,CAAC,CAAC,IAAI;YACZ,KAAK,EAAE,CAAC,CAAC,KAAK;YACd,GAAG,EAAE,CAAC,CAAC,QAAQ,IAAI,IAAI;gBACrB,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC,OAAO,EAAE;gBAC7D,CAAC,CAAC,IAAI;SACT,CAAC,CAAC,CAAC;IACN,CAAC;CACF"}
@@ -0,0 +1,15 @@
1
+ import type { KnowledgeDB } from "./db.js";
2
+ export interface VectorResult {
3
+ id: string;
4
+ distance: number;
5
+ }
6
+ export declare class VectorSearch {
7
+ private knowledgeDb;
8
+ private vecLoaded;
9
+ constructor(knowledgeDb: KnowledgeDB);
10
+ private ensureVecLoaded;
11
+ createIndex(): void;
12
+ dropIndex(): void;
13
+ upsertEmbedding(id: string, embedding: Float32Array): void;
14
+ search(queryEmbedding: Float32Array, limit?: number): VectorResult[];
15
+ }
@@ -0,0 +1,52 @@
1
+ import * as sqliteVec from "sqlite-vec";
2
+ function float32ToBuffer(arr) {
3
+ return Buffer.from(arr.buffer, arr.byteOffset, arr.byteLength);
4
+ }
5
+ export class VectorSearch {
6
+ knowledgeDb;
7
+ vecLoaded = false;
8
+ constructor(knowledgeDb) {
9
+ this.knowledgeDb = knowledgeDb;
10
+ }
11
+ ensureVecLoaded() {
12
+ if (!this.vecLoaded) {
13
+ sqliteVec.load(this.knowledgeDb.db);
14
+ this.vecLoaded = true;
15
+ }
16
+ }
17
+ createIndex() {
18
+ this.ensureVecLoaded();
19
+ this.knowledgeDb.db.exec(`
20
+ CREATE VIRTUAL TABLE IF NOT EXISTS documents_vec USING vec0(
21
+ id TEXT PRIMARY KEY,
22
+ embedding float[384] distance_metric=cosine
23
+ )
24
+ `);
25
+ }
26
+ dropIndex() {
27
+ this.knowledgeDb.db.exec("DROP TABLE IF EXISTS documents_vec");
28
+ }
29
+ upsertEmbedding(id, embedding) {
30
+ this.ensureVecLoaded();
31
+ const buf = float32ToBuffer(embedding);
32
+ this.knowledgeDb.db
33
+ .prepare("DELETE FROM documents_vec WHERE id = ?")
34
+ .run(id);
35
+ this.knowledgeDb.db
36
+ .prepare("INSERT INTO documents_vec (id, embedding) VALUES (?, ?)")
37
+ .run(id, buf);
38
+ }
39
+ search(queryEmbedding, limit = 10) {
40
+ this.ensureVecLoaded();
41
+ const buf = float32ToBuffer(queryEmbedding);
42
+ return this.knowledgeDb.db
43
+ .prepare(`
44
+ SELECT id, distance
45
+ FROM documents_vec
46
+ WHERE embedding MATCH ? AND k = ?
47
+ ORDER BY distance
48
+ `)
49
+ .all(buf, limit);
50
+ }
51
+ }
52
+ //# sourceMappingURL=vector-search.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vector-search.js","sourceRoot":"","sources":["../src/vector-search.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,SAAS,MAAM,YAAY,CAAC;AAQxC,SAAS,eAAe,CAAC,GAAiB;IACxC,OAAO,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,UAAU,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC;AACjE,CAAC;AAED,MAAM,OAAO,YAAY;IAGH;IAFZ,SAAS,GAAG,KAAK,CAAC;IAE1B,YAAoB,WAAwB;QAAxB,gBAAW,GAAX,WAAW,CAAa;IAAG,CAAC;IAExC,eAAe;QACrB,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC;YACpB,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC;YACpC,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;QACxB,CAAC;IACH,CAAC;IAED,WAAW;QACT,IAAI,CAAC,eAAe,EAAE,CAAC;QACvB,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC,IAAI,CAAC;;;;;KAKxB,CAAC,CAAC;IACL,CAAC;IAED,SAAS;QACP,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC,IAAI,CAAC,oCAAoC,CAAC,CAAC;IACjE,CAAC;IAED,eAAe,CAAC,EAAU,EAAE,SAAuB;QACjD,IAAI,CAAC,eAAe,EAAE,CAAC;QACvB,MAAM,GAAG,GAAG,eAAe,CAAC,SAAS,CAAC,CAAC;QACvC,IAAI,CAAC,WAAW,CAAC,EAAE;aAChB,OAAO,CAAC,wCAAwC,CAAC;aACjD,GAAG,CAAC,EAAE,CAAC,CAAC;QACX,IAAI,CAAC,WAAW,CAAC,EAAE;aAChB,OAAO,CAAC,yDAAyD,CAAC;aAClE,GAAG,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC;IAClB,CAAC;IAED,MAAM,CAAC,cAA4B,EAAE,QAAgB,EAAE;QACrD,IAAI,CAAC,eAAe,EAAE,CAAC;QACvB,MAAM,GAAG,GAAG,eAAe,CAAC,cAAc,CAAC,CAAC;QAC5C,OAAO,IAAI,CAAC,WAAW,CAAC,EAAE;aACvB,OAAO,CACN;;;;;KAKH,CACE;aACA,GAAG,CAAC,GAAG,EAAE,KAAK,CAAmB,CAAC;IACvC,CAAC;CACF"}
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "ralph-hero-knowledge-index",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "scripts": {
7
+ "build": "tsc",
8
+ "start": "node dist/index.js",
9
+ "reindex": "node dist/reindex.js",
10
+ "test": "vitest run",
11
+ "prepublishOnly": "npm run build"
12
+ },
13
+ "dependencies": {
14
+ "@huggingface/transformers": "^3.0.0",
15
+ "@modelcontextprotocol/sdk": "^1.26.0",
16
+ "better-sqlite3": "^12.6.0",
17
+ "sqlite-vec": "^0.1.7-alpha.10",
18
+ "yaml": "^2.7.0",
19
+ "zod": "^3.25.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/better-sqlite3": "^7.6.13",
23
+ "@types/node": "^22.0.0",
24
+ "typescript": "^5.7.0",
25
+ "vitest": "^4.0.0"
26
+ }
27
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { KnowledgeDB } from "../db.js";
3
+
4
+ let db: KnowledgeDB;
5
+
6
+ beforeEach(() => {
7
+ db = new KnowledgeDB(":memory:");
8
+ });
9
+
10
+ describe("KnowledgeDB", () => {
11
+ it("creates schema without error", () => {
12
+ expect(db).toBeTruthy();
13
+ });
14
+
15
+ it("inserts and retrieves a document", () => {
16
+ db.upsertDocument({ id: "doc-1", path: "thoughts/shared/research/doc-1.md", title: "Test Doc", date: "2026-03-08", type: "research", status: "draft", githubIssue: 100, content: "Some content about caching" });
17
+ const doc = db.getDocument("doc-1");
18
+ expect(doc).toBeTruthy();
19
+ expect(doc!.title).toBe("Test Doc");
20
+ expect(doc!.type).toBe("research");
21
+ });
22
+
23
+ it("inserts and retrieves tags", () => {
24
+ db.upsertDocument({ id: "doc-1", path: "p", title: "t", date: null, type: null, status: null, githubIssue: null, content: "" });
25
+ db.setTags("doc-1", ["caching", "performance"]);
26
+ expect(db.getTags("doc-1")).toEqual(["caching", "performance"]);
27
+ });
28
+
29
+ it("replaces tags on re-set", () => {
30
+ db.upsertDocument({ id: "doc-1", path: "p", title: "t", date: null, type: null, status: null, githubIssue: null, content: "" });
31
+ db.setTags("doc-1", ["old-tag"]);
32
+ db.setTags("doc-1", ["new-tag"]);
33
+ expect(db.getTags("doc-1")).toEqual(["new-tag"]);
34
+ });
35
+
36
+ it("inserts and retrieves relationships", () => {
37
+ db.upsertDocument({ id: "doc-a", path: "a", title: "A", date: null, type: null, status: null, githubIssue: null, content: "" });
38
+ db.upsertDocument({ id: "doc-b", path: "b", title: "B", date: null, type: null, status: null, githubIssue: null, content: "" });
39
+ db.addRelationship("doc-a", "doc-b", "builds_on");
40
+ const outgoing = db.getRelationshipsFrom("doc-a");
41
+ expect(outgoing).toHaveLength(1);
42
+ expect(outgoing[0]).toEqual({ sourceId: "doc-a", targetId: "doc-b", type: "builds_on" });
43
+ expect(db.getRelationshipsTo("doc-b")).toHaveLength(1);
44
+ });
45
+
46
+ it("clears all data for rebuild", () => {
47
+ db.upsertDocument({ id: "doc-1", path: "p", title: "t", date: null, type: null, status: null, githubIssue: null, content: "" });
48
+ db.clearAll();
49
+ expect(db.getDocument("doc-1")).toBeUndefined();
50
+ });
51
+ });
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { KnowledgeDB } from "../db.js";
3
+ import { FtsSearch } from "../search.js";
4
+ import { VectorSearch } from "../vector-search.js";
5
+ import { HybridSearch, type EmbedFn } from "../hybrid-search.js";
6
+
7
+ let db: KnowledgeDB;
8
+ let fts: FtsSearch;
9
+ let vec: VectorSearch;
10
+ let hybrid: HybridSearch;
11
+
12
+ function mockEmbedding(seed: number): Float32Array {
13
+ const v = new Float32Array(384);
14
+ for (let i = 0; i < 384; i++) {
15
+ v[i] = Math.sin(seed * (i + 1) * 0.1);
16
+ }
17
+ let norm = 0;
18
+ for (let i = 0; i < v.length; i++) norm += v[i] * v[i];
19
+ norm = Math.sqrt(norm);
20
+ if (norm > 0) for (let i = 0; i < v.length; i++) v[i] /= norm;
21
+ return v;
22
+ }
23
+
24
+ /** Simple hash of a string to a numeric seed for deterministic mock embeddings. */
25
+ function hashSeed(s: string): number {
26
+ let h = 0;
27
+ for (let i = 0; i < s.length; i++) {
28
+ h = (h * 31 + s.charCodeAt(i)) | 0;
29
+ }
30
+ return Math.abs(h) % 1000;
31
+ }
32
+
33
+ const mockEmbedFn: EmbedFn = async (text: string) =>
34
+ mockEmbedding(hashSeed(text));
35
+
36
+ beforeEach(() => {
37
+ db = new KnowledgeDB(":memory:");
38
+
39
+ db.upsertDocument({
40
+ id: "cache-doc",
41
+ path: "thoughts/shared/research/cache-strategies.md",
42
+ title: "Cache Invalidation Strategies",
43
+ date: "2026-03-01",
44
+ type: "research",
45
+ status: "draft",
46
+ githubIssue: null,
47
+ content:
48
+ "Analysis of cache invalidation patterns including TTL, event-driven, and write-through approaches.",
49
+ });
50
+ db.setTags("cache-doc", ["caching", "performance"]);
51
+
52
+ db.upsertDocument({
53
+ id: "auth-doc",
54
+ path: "thoughts/shared/plans/auth-redesign.md",
55
+ title: "Auth Redesign Plan",
56
+ date: "2026-03-02",
57
+ type: "plan",
58
+ status: "approved",
59
+ githubIssue: 42,
60
+ content:
61
+ "Redesign authentication to use OAuth2 with PKCE flow for improved security.",
62
+ });
63
+ db.setTags("auth-doc", ["auth", "security"]);
64
+
65
+ fts = new FtsSearch(db);
66
+ fts.rebuildIndex();
67
+
68
+ vec = new VectorSearch(db);
69
+ vec.createIndex();
70
+ vec.upsertEmbedding("cache-doc", mockEmbedding(1));
71
+ vec.upsertEmbedding("auth-doc", mockEmbedding(5));
72
+
73
+ hybrid = new HybridSearch(db, fts, vec, mockEmbedFn);
74
+ });
75
+
76
+ describe("HybridSearch", () => {
77
+ it("returns results combining FTS and vector scores", async () => {
78
+ const results = await hybrid.search("cache");
79
+
80
+ expect(results.length).toBeGreaterThanOrEqual(1);
81
+ // cache-doc should appear since it matches "cache" in FTS and also has a vector entry
82
+ const cacheResult = results.find((r) => r.id === "cache-doc");
83
+ expect(cacheResult).toBeDefined();
84
+ // RRF score should be positive
85
+ expect(cacheResult!.score).toBeGreaterThan(0);
86
+
87
+ // Results should be sorted descending by score
88
+ for (let i = 1; i < results.length; i++) {
89
+ expect(results[i - 1].score).toBeGreaterThanOrEqual(results[i].score);
90
+ }
91
+ });
92
+
93
+ it("passes through type filter", async () => {
94
+ const results = await hybrid.search("cache", { type: "plan" });
95
+
96
+ // cache-doc is type=research, so it should be excluded
97
+ const ids = results.map((r) => r.id);
98
+ expect(ids).not.toContain("cache-doc");
99
+ });
100
+
101
+ it("passes through tag filter", async () => {
102
+ const results = await hybrid.search("cache OR auth", {
103
+ tags: ["security"],
104
+ });
105
+
106
+ // Only auth-doc has the "security" tag
107
+ expect(results.length).toBeGreaterThanOrEqual(1);
108
+ const ids = results.map((r) => r.id);
109
+ expect(ids).toContain("auth-doc");
110
+ expect(ids).not.toContain("cache-doc");
111
+ });
112
+ });
@@ -0,0 +1,8 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ describe("knowledge-index server", () => {
4
+ it("exports createServer function", async () => {
5
+ const mod = await import("../index.js");
6
+ expect(typeof mod.createServer).toBe("function");
7
+ });
8
+ });
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseDocument } from "../parser.js";
3
+
4
+ const FULL_DOC = `---
5
+ date: 2026-03-08
6
+ github_issue: 560
7
+ status: draft
8
+ type: research
9
+ tags: [caching, mcp-server, performance]
10
+ ---
11
+
12
+ # GH-560: Response Cache TTL Strategy
13
+
14
+ ## Prior Work
15
+
16
+ - builds_on:: [[2026-02-28-GH-0460-cache-invalidation-research]]
17
+ - builds_on:: [[2026-03-01-GH-0480-session-cache-architecture]]
18
+ - tensions:: [[2026-02-25-GH-0390-aggressive-caching-plan]]
19
+
20
+ ## Problem Statement
21
+
22
+ The current cache has no TTL configuration.
23
+ `;
24
+
25
+ const SUPERSEDED_DOC = `---
26
+ date: 2026-02-20
27
+ github_issue: 200
28
+ status: superseded
29
+ type: plan
30
+ tags: [caching]
31
+ superseded_by: "[[2026-03-08-GH-0560-cache-ttl]]"
32
+ ---
33
+
34
+ # GH-200: Old Caching Strategy
35
+
36
+ Some old content.
37
+ `;
38
+
39
+ const MINIMAL_DOC = `---
40
+ date: 2026-03-01
41
+ type: idea
42
+ ---
43
+
44
+ # A Simple Idea
45
+
46
+ No prior work section.
47
+ `;
48
+
49
+ describe("parseDocument", () => {
50
+ it("parses frontmatter fields", () => {
51
+ const doc = parseDocument("2026-03-08-GH-0560-cache-ttl", "thoughts/shared/research/2026-03-08-GH-0560-cache-ttl.md", FULL_DOC);
52
+ expect(doc.id).toBe("2026-03-08-GH-0560-cache-ttl");
53
+ expect(doc.path).toBe("thoughts/shared/research/2026-03-08-GH-0560-cache-ttl.md");
54
+ expect(doc.date).toBe("2026-03-08");
55
+ expect(doc.type).toBe("research");
56
+ expect(doc.status).toBe("draft");
57
+ expect(doc.githubIssue).toBe(560);
58
+ expect(doc.tags).toEqual(["caching", "mcp-server", "performance"]);
59
+ });
60
+
61
+ it("extracts title from first heading", () => {
62
+ const doc = parseDocument("test", "test.md", FULL_DOC);
63
+ expect(doc.title).toBe("GH-560: Response Cache TTL Strategy");
64
+ });
65
+
66
+ it("extracts builds_on relationships from Prior Work", () => {
67
+ const doc = parseDocument("test", "test.md", FULL_DOC);
68
+ const buildsOn = doc.relationships.filter(r => r.type === "builds_on");
69
+ expect(buildsOn).toHaveLength(2);
70
+ expect(buildsOn[0].targetId).toBe("2026-02-28-GH-0460-cache-invalidation-research");
71
+ expect(buildsOn[1].targetId).toBe("2026-03-01-GH-0480-session-cache-architecture");
72
+ });
73
+
74
+ it("extracts tensions relationships from Prior Work", () => {
75
+ const doc = parseDocument("test", "test.md", FULL_DOC);
76
+ const tensions = doc.relationships.filter(r => r.type === "tensions");
77
+ expect(tensions).toHaveLength(1);
78
+ expect(tensions[0].targetId).toBe("2026-02-25-GH-0390-aggressive-caching-plan");
79
+ });
80
+
81
+ it("extracts superseded_by from frontmatter", () => {
82
+ const doc = parseDocument("test", "test.md", SUPERSEDED_DOC);
83
+ const superseded = doc.relationships.filter(r => r.type === "superseded_by");
84
+ expect(superseded).toHaveLength(1);
85
+ expect(superseded[0].targetId).toBe("2026-03-08-GH-0560-cache-ttl");
86
+ });
87
+
88
+ it("handles documents with no Prior Work section", () => {
89
+ const doc = parseDocument("test", "test.md", MINIMAL_DOC);
90
+ expect(doc.relationships).toEqual([]);
91
+ expect(doc.tags).toEqual([]);
92
+ expect(doc.title).toBe("A Simple Idea");
93
+ });
94
+
95
+ it("extracts content body for FTS indexing", () => {
96
+ const doc = parseDocument("test", "test.md", FULL_DOC);
97
+ expect(doc.content).toContain("current cache has no TTL");
98
+ expect(doc.content).not.toContain("---");
99
+ });
100
+ });
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { KnowledgeDB } from "../db.js";
3
+ import { FtsSearch } from "../search.js";
4
+
5
+ let db: KnowledgeDB;
6
+ let fts: FtsSearch;
7
+
8
+ beforeEach(() => {
9
+ db = new KnowledgeDB(":memory:");
10
+
11
+ db.upsertDocument({
12
+ id: "cache-doc",
13
+ path: "thoughts/shared/research/cache-strategies.md",
14
+ title: "Cache Invalidation Strategies",
15
+ date: "2026-03-01",
16
+ type: "research",
17
+ status: "draft",
18
+ githubIssue: null,
19
+ content: "Analysis of cache invalidation patterns including TTL, event-driven, and write-through approaches.",
20
+ });
21
+ db.setTags("cache-doc", ["caching", "performance"]);
22
+
23
+ db.upsertDocument({
24
+ id: "auth-doc",
25
+ path: "thoughts/shared/plans/auth-redesign.md",
26
+ title: "Auth Redesign Plan",
27
+ date: "2026-03-02",
28
+ type: "plan",
29
+ status: "approved",
30
+ githubIssue: 42,
31
+ content: "Redesign authentication to use OAuth2 with PKCE flow for improved security.",
32
+ });
33
+ db.setTags("auth-doc", ["auth", "security"]);
34
+
35
+ db.upsertDocument({
36
+ id: "cache-old",
37
+ path: "thoughts/shared/plans/old-cache-plan.md",
38
+ title: "Old Cache Plan",
39
+ date: "2026-01-15",
40
+ type: "plan",
41
+ status: "superseded",
42
+ githubIssue: null,
43
+ content: "Original cache implementation plan using Redis with simple TTL expiry.",
44
+ });
45
+ db.setTags("cache-old", ["caching"]);
46
+
47
+ fts = new FtsSearch(db);
48
+ fts.rebuildIndex();
49
+ });
50
+
51
+ describe("FtsSearch", () => {
52
+ it("finds documents by keyword", () => {
53
+ const results = fts.search("cache");
54
+ expect(results.length).toBeGreaterThanOrEqual(1);
55
+ expect(results[0].id).toBe("cache-doc");
56
+ expect(results[0].score).toBeLessThan(0);
57
+ expect(results[0].snippet).toBeTruthy();
58
+ });
59
+
60
+ it("returns empty for no matches", () => {
61
+ const results = fts.search("nonexistent");
62
+ expect(results).toHaveLength(0);
63
+ });
64
+
65
+ it("filters by type", () => {
66
+ const results = fts.search("cache", { type: "plan" });
67
+ expect(results).toHaveLength(0); // cache-old is superseded (excluded), cache-doc is research
68
+ });
69
+
70
+ it("filters by tags", () => {
71
+ const results = fts.search("cache OR auth", { tags: ["security"] });
72
+ expect(results).toHaveLength(1);
73
+ expect(results[0].id).toBe("auth-doc");
74
+ });
75
+
76
+ it("excludes superseded by default", () => {
77
+ const results = fts.search("cache");
78
+ const ids = results.map((r) => r.id);
79
+ expect(ids).not.toContain("cache-old");
80
+ });
81
+
82
+ it("includes superseded when requested", () => {
83
+ const results = fts.search("cache", { includeSuperseded: true });
84
+ const ids = results.map((r) => r.id);
85
+ expect(ids).toContain("cache-old");
86
+ });
87
+
88
+ it("respects limit", () => {
89
+ const results = fts.search("cache", { includeSuperseded: true, limit: 1 });
90
+ expect(results).toHaveLength(1);
91
+ });
92
+ });
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { KnowledgeDB } from "../db.js";
3
+ import { Traverser } from "../traverse.js";
4
+
5
+ let db: KnowledgeDB;
6
+ let traverser: Traverser;
7
+
8
+ beforeEach(() => {
9
+ db = new KnowledgeDB(":memory:");
10
+
11
+ // Chain: doc-c builds_on doc-b builds_on doc-a
12
+ // Plus: doc-c tensions doc-a
13
+ db.upsertDocument({
14
+ id: "doc-a",
15
+ path: "thoughts/shared/research/doc-a.md",
16
+ title: "Foundation Research",
17
+ date: "2026-02-01",
18
+ type: "research",
19
+ status: "approved",
20
+ githubIssue: null,
21
+ content: "Foundational research document.",
22
+ });
23
+
24
+ db.upsertDocument({
25
+ id: "doc-b",
26
+ path: "thoughts/shared/plans/doc-b.md",
27
+ title: "Implementation Plan",
28
+ date: "2026-02-15",
29
+ type: "plan",
30
+ status: "draft",
31
+ githubIssue: 10,
32
+ content: "Plan that builds on foundation.",
33
+ });
34
+
35
+ db.upsertDocument({
36
+ id: "doc-c",
37
+ path: "thoughts/shared/plans/doc-c.md",
38
+ title: "Revised Plan",
39
+ date: "2026-03-01",
40
+ type: "plan",
41
+ status: "draft",
42
+ githubIssue: 20,
43
+ content: "Revised plan that builds on implementation and tensions with foundation.",
44
+ });
45
+
46
+ db.addRelationship("doc-b", "doc-a", "builds_on");
47
+ db.addRelationship("doc-c", "doc-b", "builds_on");
48
+ db.addRelationship("doc-c", "doc-a", "tensions");
49
+
50
+ traverser = new Traverser(db);
51
+ });
52
+
53
+ describe("Traverser", () => {
54
+ it("finds direct outgoing relationships", () => {
55
+ const results = traverser.traverse("doc-c", { depth: 1 });
56
+ expect(results).toHaveLength(2);
57
+ const targetIds = results.map((r) => r.targetId).sort();
58
+ expect(targetIds).toEqual(["doc-a", "doc-b"]);
59
+ });
60
+
61
+ it("walks multi-hop builds_on chain", () => {
62
+ const results = traverser.traverse("doc-c", { type: "builds_on" });
63
+ expect(results).toHaveLength(2);
64
+ expect(results[0]).toMatchObject({ sourceId: "doc-c", targetId: "doc-b", depth: 1 });
65
+ expect(results[1]).toMatchObject({ sourceId: "doc-b", targetId: "doc-a", depth: 2 });
66
+ });
67
+
68
+ it("respects depth limit", () => {
69
+ const results = traverser.traverse("doc-c", { type: "builds_on", depth: 1 });
70
+ expect(results).toHaveLength(1);
71
+ expect(results[0].targetId).toBe("doc-b");
72
+ });
73
+
74
+ it("filters by relationship type", () => {
75
+ const results = traverser.traverse("doc-c", { type: "tensions" });
76
+ expect(results).toHaveLength(1);
77
+ expect(results[0]).toMatchObject({ sourceId: "doc-c", targetId: "doc-a", type: "tensions" });
78
+ });
79
+
80
+ it("finds incoming relationships", () => {
81
+ const results = traverser.traverseIncoming("doc-a");
82
+ // doc-b builds_on doc-a (depth 1), doc-c tensions doc-a (depth 1), doc-c builds_on doc-b (depth 2)
83
+ expect(results.length).toBeGreaterThanOrEqual(2);
84
+ const depth1 = results.filter((r) => r.depth === 1);
85
+ const sourceIds = depth1.map((r) => r.sourceId).sort();
86
+ expect(sourceIds).toEqual(["doc-b", "doc-c"]);
87
+ });
88
+
89
+ it("includes document metadata in results", () => {
90
+ const results = traverser.traverse("doc-c", { type: "builds_on", depth: 1 });
91
+ expect(results).toHaveLength(1);
92
+ expect(results[0].doc).toEqual({
93
+ title: "Implementation Plan",
94
+ status: "draft",
95
+ date: "2026-02-15",
96
+ });
97
+ });
98
+
99
+ it("returns empty for document with no relationships", () => {
100
+ db.upsertDocument({
101
+ id: "doc-orphan",
102
+ path: "thoughts/shared/ideas/orphan.md",
103
+ title: "Orphan Idea",
104
+ date: "2026-03-05",
105
+ type: "idea",
106
+ status: "draft",
107
+ githubIssue: null,
108
+ content: "An isolated idea with no connections.",
109
+ });
110
+ const outgoing = traverser.traverse("doc-orphan");
111
+ const incoming = traverser.traverseIncoming("doc-orphan");
112
+ expect(outgoing).toHaveLength(0);
113
+ expect(incoming).toHaveLength(0);
114
+ });
115
+ });