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.
- package/.claude-plugin/plugin.json +21 -0
- package/.mcp.json +12 -0
- package/dist/db.d.ts +30 -0
- package/dist/db.js +73 -0
- package/dist/db.js.map +1 -0
- package/dist/embedder.d.ts +4 -0
- package/dist/embedder.js +24 -0
- package/dist/embedder.js.map +1 -0
- package/dist/hybrid-search.d.ts +13 -0
- package/dist/hybrid-search.js +85 -0
- package/dist/hybrid-search.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +64 -0
- package/dist/index.js.map +1 -0
- package/dist/parser.d.ts +18 -0
- package/dist/parser.js +39 -0
- package/dist/parser.js.map +1 -0
- package/dist/reindex.d.ts +1 -0
- package/dist/reindex.js +77 -0
- package/dist/reindex.js.map +1 -0
- package/dist/search.d.ts +23 -0
- package/dist/search.js +63 -0
- package/dist/search.js.map +1 -0
- package/dist/traverse.d.ts +22 -0
- package/dist/traverse.js +91 -0
- package/dist/traverse.js.map +1 -0
- package/dist/vector-search.d.ts +15 -0
- package/dist/vector-search.js +52 -0
- package/dist/vector-search.js.map +1 -0
- package/package.json +27 -0
- package/src/__tests__/db.test.ts +51 -0
- package/src/__tests__/hybrid-search.test.ts +112 -0
- package/src/__tests__/index.test.ts +8 -0
- package/src/__tests__/parser.test.ts +100 -0
- package/src/__tests__/search.test.ts +92 -0
- package/src/__tests__/traverse.test.ts +115 -0
- package/src/__tests__/vector-search.test.ts +66 -0
- package/src/db.ts +103 -0
- package/src/embedder.ts +37 -0
- package/src/hybrid-search.ts +102 -0
- package/src/index.ts +76 -0
- package/src/parser.ts +63 -0
- package/src/reindex.ts +89 -0
- package/src/search.ts +92 -0
- package/src/traverse.ts +130 -0
- package/src/vector-search.ts +64 -0
- package/tsconfig.json +17 -0
package/dist/traverse.js
ADDED
|
@@ -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,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
|
+
});
|