promptgraph-mcp 1.2.0 → 1.3.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/README.md +1 -1
- package/ann.js +50 -0
- package/indexer.js +3 -0
- package/package.json +3 -2
- package/search.js +28 -10
package/README.md
CHANGED
|
@@ -129,7 +129,7 @@ promptgraph-mcp reindex # Re-index all skills
|
|
|
129
129
|
| Skills in context | All 40+ | 1 (router) |
|
|
130
130
|
| Tokens per session | ~20,000–50,000 | ~150 + 1 skill |
|
|
131
131
|
|
|
132
|
-
> **
|
|
132
|
+
> **Search:** Uses HNSW index (via [vectra](https://github.com/Stevenic/vectra)) for O(log N) approximate nearest neighbor search. Falls back to brute-force on first run before index is built.
|
|
133
133
|
|
|
134
134
|
## File Structure
|
|
135
135
|
|
package/ann.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { LocalIndex } from 'vectra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { getDb } from './db.js';
|
|
5
|
+
|
|
6
|
+
const INDEX_PATH = path.join(os.homedir(), '.claude', '.promptgraph', 'hnsw-index');
|
|
7
|
+
|
|
8
|
+
let _index = null;
|
|
9
|
+
|
|
10
|
+
async function getIndex() {
|
|
11
|
+
if (_index) return _index;
|
|
12
|
+
_index = new LocalIndex(INDEX_PATH);
|
|
13
|
+
if (!await _index.isIndexCreated()) {
|
|
14
|
+
await _index.createIndex({ version: 1, deleteIfExists: true });
|
|
15
|
+
}
|
|
16
|
+
return _index;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function buildAnnIndex() {
|
|
20
|
+
const index = await getIndex();
|
|
21
|
+
await index.createIndex({ version: 1, deleteIfExists: true });
|
|
22
|
+
|
|
23
|
+
const db = getDb();
|
|
24
|
+
const chunks = db.prepare('SELECT skill_id, chunk_index, embedding FROM chunks').all();
|
|
25
|
+
|
|
26
|
+
for (const chunk of chunks) {
|
|
27
|
+
const vec = JSON.parse(chunk.embedding);
|
|
28
|
+
await index.insertItem({
|
|
29
|
+
vector: vec,
|
|
30
|
+
metadata: { skill_id: chunk.skill_id, chunk_index: chunk.chunk_index },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
console.error(`[PromptGraph] ANN index built: ${chunks.length} chunks`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function annSearch(queryVec, topK = 20) {
|
|
38
|
+
try {
|
|
39
|
+
const index = await getIndex();
|
|
40
|
+
if (!await index.isIndexCreated()) return null;
|
|
41
|
+
|
|
42
|
+
const results = await index.queryItems(queryVec, topK);
|
|
43
|
+
return results.map(r => ({
|
|
44
|
+
skill_id: r.item.metadata.skill_id,
|
|
45
|
+
score: r.score,
|
|
46
|
+
}));
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
package/indexer.js
CHANGED
|
@@ -4,6 +4,7 @@ import { embed } from './embedder.js';
|
|
|
4
4
|
import { getDb, skillId } from './db.js';
|
|
5
5
|
import { loadConfig } from './config.js';
|
|
6
6
|
import { chunkText } from './chunker.js';
|
|
7
|
+
import { buildAnnIndex } from './ann.js';
|
|
7
8
|
|
|
8
9
|
async function indexSkill(db, skill) {
|
|
9
10
|
const id = skillId(skill.source, skill.name);
|
|
@@ -53,6 +54,8 @@ export async function indexAll() {
|
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
}
|
|
57
|
+
process.stdout.write('\r Building ANN index...');
|
|
58
|
+
await buildAnnIndex();
|
|
56
59
|
console.log(`\n Done. ${count} skills indexed.`);
|
|
57
60
|
}
|
|
58
61
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "promptgraph-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"chokidar": "^5.0.0",
|
|
38
38
|
"fastembed": "^2.1.0",
|
|
39
39
|
"glob": "^13.0.6",
|
|
40
|
-
"gray-matter": "^4.0.3"
|
|
40
|
+
"gray-matter": "^4.0.3",
|
|
41
|
+
"vectra": "^0.15.0"
|
|
41
42
|
}
|
|
42
43
|
}
|
package/search.js
CHANGED
|
@@ -1,34 +1,52 @@
|
|
|
1
1
|
import { embed, cosineSimilarity } from './embedder.js';
|
|
2
2
|
import { getDb } from './db.js';
|
|
3
|
+
import { annSearch } from './ann.js';
|
|
3
4
|
|
|
4
5
|
export async function search(query, topK = 5) {
|
|
5
6
|
const db = getDb();
|
|
6
7
|
const queryVec = await embed(query);
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
// Try ANN index first (fast, O(log N))
|
|
10
|
+
const annResults = await annSearch(queryVec, topK * 4);
|
|
11
|
+
|
|
12
|
+
if (annResults && annResults.length > 0) {
|
|
13
|
+
const bestBySkill = new Map();
|
|
14
|
+
for (const r of annResults) {
|
|
15
|
+
const prev = bestBySkill.get(r.skill_id);
|
|
16
|
+
if (!prev || r.score > prev) bestBySkill.set(r.skill_id, r.score);
|
|
17
|
+
}
|
|
18
|
+
return [...bestBySkill.entries()]
|
|
19
|
+
.sort((a, b) => b[1] - a[1])
|
|
20
|
+
.slice(0, topK)
|
|
21
|
+
.map(([id, score]) => {
|
|
22
|
+
const skill = db.prepare('SELECT id, name, description, path, source FROM skills WHERE id = ?').get(id);
|
|
23
|
+
return skill ? { ...skill, score } : null;
|
|
24
|
+
})
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
}
|
|
9
27
|
|
|
28
|
+
// Fallback: brute force (used before first reindex)
|
|
29
|
+
const chunks = db.prepare('SELECT skill_id, embedding FROM chunks').all();
|
|
10
30
|
const bestBySkill = new Map();
|
|
11
31
|
for (const chunk of chunks) {
|
|
12
32
|
const score = cosineSimilarity(queryVec, JSON.parse(chunk.embedding));
|
|
13
33
|
const prev = bestBySkill.get(chunk.skill_id);
|
|
14
34
|
if (!prev || score > prev) bestBySkill.set(chunk.skill_id, score);
|
|
15
35
|
}
|
|
16
|
-
|
|
17
|
-
const topIds = [...bestBySkill.entries()]
|
|
36
|
+
return [...bestBySkill.entries()]
|
|
18
37
|
.sort((a, b) => b[1] - a[1])
|
|
19
38
|
.slice(0, topK)
|
|
20
|
-
.map(([id]) =>
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
});
|
|
39
|
+
.map(([id, score]) => {
|
|
40
|
+
const skill = db.prepare('SELECT id, name, description, path, source FROM skills WHERE id = ?').get(id);
|
|
41
|
+
return skill ? { ...skill, score } : null;
|
|
42
|
+
})
|
|
43
|
+
.filter(Boolean);
|
|
26
44
|
}
|
|
27
45
|
|
|
28
46
|
export function getContext(id) {
|
|
29
47
|
const db = getDb();
|
|
30
48
|
const skill = db.prepare('SELECT * FROM skills WHERE id = ?').get(id)
|
|
31
|
-
|| db.prepare(
|
|
49
|
+
|| db.prepare('SELECT * FROM skills WHERE name = ? ORDER BY id LIMIT 1').get(id);
|
|
32
50
|
if (!skill) return null;
|
|
33
51
|
const callees = db.prepare('SELECT to_skill FROM edges WHERE from_skill = ?').all(skill.id).map(r => r.to_skill);
|
|
34
52
|
const callers = db.prepare('SELECT from_skill FROM edges WHERE to_skill = ?').all(skill.id).map(r => r.from_skill);
|